commit dd2e91d61b99b00fc35f4eb8e330c49c409330ed Author: Brenno de Winter Date: Tue Jun 2 23:28:39 2026 +0200 Initial commit: OciDeck Marp presentation builder Flutter desktop app for building Marp presentations via structured slide editors, with live preview, fullscreen presenter, and PDF/PPTX export. Includes Makefile quality gate, CI workflow, and full test suite. Co-Authored-By: Claude Opus 4.8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb48434 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + name: Analyze & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.0 + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run quality gate + run: make check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..768732a --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: android + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: ios + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: linux + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: macos + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: web + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: windows + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1ed97d --- /dev/null +++ b/Makefile @@ -0,0 +1,116 @@ +.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 + +help: + @echo "OciDeck quality targets:" + @echo " make check Format check + static analysis + full Flutter test suite." + @echo " make check-full make check + dependency outdated report." + @echo " make test-contracts Markdown/save-load contract and parsing tests." + @echo " make test-preview Slide rendering, footer, TLP, inline markdown, and preview tests." + @echo " make test-export Export and file-service smoke tests." + @echo " make test-state Provider/state/recovery tests." + @echo " make test-services Caption/description/image service tests." + @echo " make test-presenter Fullscreen presenter interaction tests." + @echo " make deps-outdated Advisory dependency freshness report." + +# Install Flutter/Dart dependencies. +setup: + @echo "== OciDeck setup ==" + @echo "Purpose: install Flutter/Dart dependencies with 'flutter pub get'." + flutter pub get + +# Auto-format all Dart code in-place. +format: + @echo "== OciDeck format ==" + @echo "Purpose: rewrite Dart files using the repository formatter." + dart format . + +# Verify formatting without modifying files. +format-check: + @echo "== OciDeck check: format ==" + @echo "Command: dart format --output=none --set-exit-if-changed ." + @echo "Covers: all Dart source and test files tracked in this workspace." + @echo "Failure means: at least one Dart file needs 'dart format .'." + dart format --output=none --set-exit-if-changed . + +# Static analysis. +analyze: + @echo "== OciDeck check: static analysis ==" + @echo "Command: flutter analyze" + @echo "Covers: analyzer/lint/type checks for the Flutter app and tests." + @echo "Failure means: inspect analyzer diagnostics above the final summary." + flutter analyze + +# Run the full unit/widget test suite. +test: + @echo "== OciDeck check: tests ==" + @echo "Command: flutter test" + @echo "Covers: all unit/widget tests under test/, including markdown round-trip, preview, export, provider, footer, and presenter tests." + @echo "Failure means: inspect the named failing test file and test case in the Flutter output." + flutter test + +# Contract tests for persistence and parsing. +test-contracts: + @echo "== OciDeck targeted check: contracts ==" + @echo "Command: flutter test test/markdown_round_trip_test.dart test/markdown_service_test.dart" + @echo "Covers: Markdown generation/parsing, save-load round-trips, slide field migration defaults, theme profile metadata." + @echo "Failure means: a UI/model field may not persist correctly, or old presentations may migrate incorrectly." + flutter test test/markdown_round_trip_test.dart test/markdown_service_test.dart + +# Visual/rendering-focused widget tests. +test-preview: + @echo "== OciDeck targeted check: preview/rendering ==" + @echo "Command: flutter test preview-related widget tests" + @echo "Covers: slide preview rendering, image panels, footer placement, TLP badge, inline markdown, text style regressions." + @echo "Failure means: inspect visual layout/rendering logic before changing export or slide-preview code." + flutter test test/bullets_image_preview_test.dart test/footer_preview_test.dart test/image_slides_preview_test.dart test/inline_markdown_test.dart test/slide_text_style_test.dart test/tlp_test.dart + +# Export and filesystem integration smoke tests. +test-export: + @echo "== OciDeck targeted check: export/files ==" + @echo "Command: flutter test test/export_service_test.dart test/file_service_test.dart" + @echo "Covers: PDF/PPTX export smoke tests and project file-save behavior, including copied logo assets." + @echo "Failure means: inspect export_service/file_service and generated artifact structure." + flutter test test/export_service_test.dart test/file_service_test.dart + +# State-management and recovery tests. +test-state: + @echo "== OciDeck targeted check: state/recovery ==" + @echo "Command: flutter test provider and recovery tests" + @echo "Covers: deck mutations, undo/redo, skip state, search/replace, settings profiles, recovery snapshots." + @echo "Failure means: inspect provider state transitions or recovery serialization." + flutter test test/deck_provider_test.dart test/settings_provider_test.dart test/recovery_service_test.dart + +# Service-level tests. +test-services: + @echo "== OciDeck targeted check: services ==" + @echo "Command: flutter test service tests" + @echo "Covers: image path/copy behavior, captions, descriptions, and sidecar metadata services." + @echo "Failure means: inspect service path handling, sidecar reads/writes, or filesystem assumptions." + flutter test test/caption_service_test.dart test/description_service_test.dart test/image_service_test.dart + +# Presenter interaction tests. +test-presenter: + @echo "== OciDeck targeted check: presenter ==" + @echo "Command: flutter test test/fullscreen_presenter_test.dart" + @echo "Covers: fullscreen presenter navigation, presenter view, keyboard shortcuts, grid navigation." + @echo "Failure means: inspect fullscreen presenter keyboard/focus/navigation behavior." + flutter test test/fullscreen_presenter_test.dart + +# Advisory dependency freshness report; not part of normal check because it can +# depend on network availability and does not imply the current code is broken. +deps-outdated: + @echo "== OciDeck advisory check: dependencies ==" + @echo "Command: flutter pub outdated" + @echo "Covers: dependency freshness only. This is advisory and may require network access." + @echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions." + flutter pub outdated + +# Full local quality gate. Intended for humans, CI logs, and LLM-assisted debugging. +check: format-check analyze test + @echo "== OciDeck check complete ==" + @echo "Validated: formatting, static analysis, and the full Flutter test suite." + +# Extended local check with advisory dependency freshness after the required gate. +check-full: check deps-outdated + @echo "== OciDeck extended check complete ==" + @echo "Validated: required quality gate plus dependency freshness report." diff --git a/README.md b/README.md new file mode 100644 index 0000000..3979d4b --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# 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. + +Built with Flutter for macOS, Windows, and Linux. + +## 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. +- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. +- **Fullscreen presenter** — keyboard-driven navigation, presenter view, and a slide-grid overview. +- **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 and PPTX. Decks are saved as a self-contained package with copied assets. +- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, and tabbed multi-deck editing. +- **Crash recovery** — automatic snapshots so work survives an unexpected exit. +- **Theming** — a bundled Marp CSS theme (`assets/themes/ocideck.css`) and Google Fonts. + +## Requirements + +- Flutter SDK `^3.12.0` (Dart 3.12+) +- A desktop target enabled: macOS, Windows, or Linux + +## Getting started + +```sh +make setup # flutter pub get +flutter run -d macos # or -d windows / -d linux +``` + +## Development + +The `Makefile` is the entry point for all quality checks. Run `make help` for the full list. + +```sh +make check # format check + static analysis + full test suite (the quality gate) +make check-full # check + dependency freshness report +make format # auto-format all Dart code +make analyze # flutter analyze only +make test # full test suite only +``` + +Targeted test groups speed up focused work: + +| Target | Covers | +| --- | --- | +| `make test-contracts` | Markdown generation/parsing, save-load round-trips, field migration | +| `make test-preview` | Slide rendering, footers, TLP, inline Markdown, text styles | +| `make test-export` | PDF/PPTX export and project file-save behavior | +| `make test-state` | Providers, undo/redo, search/replace, settings, recovery | +| `make test-services` | Image, caption, and description sidecar services | +| `make test-presenter` | Fullscreen presenter navigation and keyboard shortcuts | + +CI runs `make check` on every push and pull request (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)). + +## Project layout + +``` +lib/ + models/ # Deck, Slide, Settings data models + services/ # Markdown, export, file, image, caption, recovery, rasterizer + state/ # Riverpod providers (deck, editor, settings, tabs, clipboard) + widgets/ # UI: app shell, panels, dialogs, per-type editors, presenter + theme/ # App theming +``` + +State is managed with [Riverpod](https://riverpod.dev/). + +## License + +All rights reserved. _(Update this section if you intend to open-source the project.)_ diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..6c60bfb --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.ocideck" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.ocideck" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6f70275 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/ocideck/MainActivity.kt b/android/app/src/main/kotlin/com/example/ocideck/MainActivity.kt new file mode 100644 index 0000000..8e57c64 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/ocideck/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.ocideck + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/assets/images/de-winter-wittegeheel.png b/assets/images/de-winter-wittegeheel.png new file mode 100644 index 0000000..3d7d30c Binary files /dev/null and b/assets/images/de-winter-wittegeheel.png differ diff --git a/assets/images/logo-icon.png b/assets/images/logo-icon.png new file mode 100644 index 0000000..b18d5ff Binary files /dev/null and b/assets/images/logo-icon.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..164abd3 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/themes/ocideck.css b/assets/themes/ocideck.css new file mode 100644 index 0000000..c25ef15 --- /dev/null +++ b/assets/themes/ocideck.css @@ -0,0 +1,148 @@ +/* @theme ocideck */ + +section { + font-family: Arial, sans-serif; + background: #ffffff; + color: #222222; + padding: 48px; +} + +section ul, +section ol { + font-size: 0.96em; + line-height: 1.25; +} + +section li { + margin: 0.18em 0; + overflow-wrap: anywhere; +} + +section ul ul { + font-size: 0.86em; +} + +section ul ul ul { + font-size: 0.93em; + list-style-type: square; +} + +section ul ul ul ul { + font-size: 0.95em; + list-style-type: circle; +} + +section ul ul ul ul ul { + font-size: 0.95em; + list-style-type: "– "; +} + +section.title { + display: flex; + flex-direction: column; + justify-content: center; + background: #1f2937; + color: #ffffff; +} + +section.title h1 { + font-size: 2.4em; + margin-bottom: 0.2em; +} + +section.title h2 { + font-size: 1.4em; + font-weight: normal; + opacity: 0.82; +} + +section.section { + display: flex; + flex-direction: column; + justify-content: center; + background: #334155; + color: #ffffff; +} + +section.section h1 { + font-size: 2em; +} + +section.split { + --image-width: 40%; + --split-margin: 48px; + --split-text-scale: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--image-width); + gap: var(--split-margin); + padding: 48px 0 48px var(--split-margin); +} + +section.split .split-text { + font-size: calc(1em * var(--split-text-scale)); + min-width: 0; +} + +section.split .split-text h1 { + font-size: 1.45em; + margin-top: 0; + margin-bottom: 0.42em; +} + +section.split .split-text ul { + line-height: 1.16; + padding-left: 1em; +} + +section.split .split-text li { + margin: 0.12em 0; +} + +section.split .split-image { + position: relative; + align-self: stretch; + min-height: calc(100vh - 96px); + margin: -48px 0 -48px 0; +} + +section.split .split-image p { + margin: 0; + height: 100%; +} + +section.split .split-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +section.quote { + display: flex; + flex-direction: column; + justify-content: center; + background: #f8fafc; +} + +section.quote blockquote { + font-size: 1.6em; + font-style: italic; + border-left: 4px solid #334155; + padding-left: 24px; + margin: 0; +} + +.image-caption { + position: absolute; + right: 18px; + bottom: 14px; + max-width: 58%; + padding: 4px 7px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.58); + color: #ffffff; + font-size: 0.38em; + line-height: 1.25; + text-align: right; + z-index: 10; +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9702189 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..4d7193e --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", + "version" : "5.21.7" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", + "version" : "2.8.0" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..c5724ae Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..e76dec8 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..372c779 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..2c5970f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..72a3329 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..f3bbb48 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..92495c5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..372c779 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..84bad53 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..b4dd7da Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..b4dd7da Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e14592b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..d9ee7b2 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..fd3fde5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..d1e3192 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..5b576a0 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Ocideck + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ocideck + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..3ea1e63 --- /dev/null +++ b/lib/app.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'theme/app_theme.dart'; +import 'widgets/app_shell.dart'; + +class OciDeckApp extends StatelessWidget { + const OciDeckApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'OciDeck', + theme: AppTheme.light, + debugShowCheckedModeBanner: false, + home: const AppShell(), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..970944d --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; +import 'app.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + await windowManager.ensureInitialized(); + const options = WindowOptions( + minimumSize: Size(1000, 650), + title: 'OciDeck', + ); + windowManager.waitUntilReadyToShow(options, () async { + await windowManager.show(); + await windowManager.focus(); + await windowManager.setPreventClose(true); + }); + } + + runApp(const ProviderScope(child: OciDeckApp())); +} diff --git a/lib/models/deck.dart b/lib/models/deck.dart new file mode 100644 index 0000000..e5e44ce --- /dev/null +++ b/lib/models/deck.dart @@ -0,0 +1,150 @@ +import 'slide.dart'; +import 'settings.dart'; + +/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie. +enum TlpLevel { none, clear, green, amber, amberStrict, red } + +extension TlpLevelX on TlpLevel { + /// De officiële markering die op de slides verschijnt ('' bij [none]). + String get label { + switch (this) { + case TlpLevel.none: + return ''; + case TlpLevel.clear: + return 'TLP:CLEAR'; + case TlpLevel.green: + return 'TLP:GREEN'; + case TlpLevel.amber: + return 'TLP:AMBER'; + case TlpLevel.amberStrict: + return 'TLP:AMBER+STRICT'; + case TlpLevel.red: + return 'TLP:RED'; + } + } + + /// Tekst voor de keuzelijst. + String get menuLabel => this == TlpLevel.none ? 'Geen' : label; + + /// Stabiele sleutel voor opslag in de front matter. + String get key { + switch (this) { + case TlpLevel.none: + return 'none'; + case TlpLevel.clear: + return 'clear'; + case TlpLevel.green: + return 'green'; + case TlpLevel.amber: + return 'amber'; + case TlpLevel.amberStrict: + return 'amber+strict'; + case TlpLevel.red: + return 'red'; + } + } + + /// Officiële TLP 2.0-voorgrondkleur (ARGB). Achtergrond is altijd zwart. + int get foreground { + switch (this) { + case TlpLevel.none: + return 0x00000000; + case TlpLevel.clear: + return 0xFFFFFFFF; + case TlpLevel.green: + return 0xFF33FF00; + case TlpLevel.amber: + case TlpLevel.amberStrict: + return 0xFFFFC000; + case TlpLevel.red: + return 0xFFFF2B2B; + } + } + + static TlpLevel fromKey(String raw) { + switch (raw.trim().toLowerCase()) { + case 'clear': + return TlpLevel.clear; + case 'green': + return TlpLevel.green; + case 'amber': + return TlpLevel.amber; + case 'amber+strict': + case 'amberstrict': + return TlpLevel.amberStrict; + case 'red': + return TlpLevel.red; + default: + return TlpLevel.none; + } + } +} + +class Deck { + final String title; + final String theme; + final bool paginate; + final List slides; + final String? projectPath; + final ThemeProfile themeProfile; + + // ── General presentation metadata (stored in the markdown front matter) ── + final String author; + final String organization; + final String version; + final String date; + final String description; + final String keywords; + + /// Traffic Light Protocol-classificatie van deze presentatie. + final TlpLevel tlp; + + const Deck({ + required this.title, + this.theme = 'ocideck', + this.paginate = true, + this.slides = const [], + this.projectPath, + this.themeProfile = const ThemeProfile(), + this.author = '', + this.organization = '', + this.version = '', + this.date = '', + this.description = '', + this.keywords = '', + this.tlp = TlpLevel.none, + }); + + Deck copyWith({ + String? title, + String? theme, + bool? paginate, + List? slides, + String? projectPath, + ThemeProfile? themeProfile, + bool clearProjectPath = false, + String? author, + String? organization, + String? version, + String? date, + String? description, + String? keywords, + TlpLevel? tlp, + }) { + return Deck( + title: title ?? this.title, + theme: theme ?? this.theme, + paginate: paginate ?? this.paginate, + slides: slides ?? this.slides, + projectPath: clearProjectPath ? null : (projectPath ?? this.projectPath), + themeProfile: themeProfile ?? this.themeProfile, + author: author ?? this.author, + organization: organization ?? this.organization, + version: version ?? this.version, + date: date ?? this.date, + description: description ?? this.description, + keywords: keywords ?? this.keywords, + tlp: tlp ?? this.tlp, + ); + } +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart new file mode 100644 index 0000000..e5db958 --- /dev/null +++ b/lib/models/settings.dart @@ -0,0 +1,212 @@ +class ThemeProfile { + final String name; + final String slideBackgroundColor; + final String textColor; + final String accentColor; + final String tableTextColor; + final String tableHeaderTextColor; + final String titleBackgroundColor; + final String titleTextColor; + final String sectionBackgroundColor; + final String? logoPath; + final String logoPosition; + final int logoSize; + + /// Lettertype van de presentatie — hoort bij de stijl, niet bij de app. + final String fontFamily; + + /// Vrije footertekst onderaan elke slide. Ondersteunt tokens: {page}, + /// {total}, {date}, {title}. Leeg = geen footertekst. + final String footerText; + + /// Toon "pagina / totaal" rechtsonder op elke slide. + final bool footerShowPageNumbers; + + /// Horizontale positie van de footer: left, center of right. + final String footerPosition; + + const ThemeProfile({ + this.name = 'Standaard', + this.slideBackgroundColor = '#FFFFFF', + this.textColor = '#222222', + this.accentColor = '#2E7D64', + String? tableTextColor, + this.tableHeaderTextColor = '#FFFFFF', + this.titleBackgroundColor = '#1C2B47', + this.titleTextColor = '#FFFFFF', + this.sectionBackgroundColor = '#2E7D64', + this.logoPath, + this.logoPosition = 'bottom-right', + this.logoSize = 96, + this.fontFamily = 'Arial', + this.footerText = '', + this.footerShowPageNumbers = false, + this.footerPosition = 'right', + }) : tableTextColor = tableTextColor ?? textColor; + + static const logoPositions = [ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + ]; + + static const footerPositions = ['left', 'center', 'right']; + + ThemeProfile copyWith({ + String? name, + String? slideBackgroundColor, + String? textColor, + String? accentColor, + String? tableTextColor, + String? tableHeaderTextColor, + String? titleBackgroundColor, + String? titleTextColor, + String? sectionBackgroundColor, + String? logoPath, + String? logoPosition, + int? logoSize, + String? fontFamily, + String? footerText, + bool? footerShowPageNumbers, + String? footerPosition, + bool clearLogo = false, + }) { + return ThemeProfile( + name: name ?? this.name, + slideBackgroundColor: slideBackgroundColor ?? this.slideBackgroundColor, + textColor: textColor ?? this.textColor, + accentColor: accentColor ?? this.accentColor, + tableTextColor: tableTextColor ?? this.tableTextColor, + tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor, + titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor, + titleTextColor: titleTextColor ?? this.titleTextColor, + sectionBackgroundColor: + sectionBackgroundColor ?? this.sectionBackgroundColor, + logoPath: clearLogo ? null : (logoPath ?? this.logoPath), + logoPosition: logoPosition ?? this.logoPosition, + logoSize: logoSize ?? this.logoSize, + fontFamily: fontFamily ?? this.fontFamily, + footerText: footerText ?? this.footerText, + footerShowPageNumbers: + footerShowPageNumbers ?? this.footerShowPageNumbers, + footerPosition: footerPosition ?? this.footerPosition, + ); + } + + Map toJson() { + return { + 'slideBackgroundColor': slideBackgroundColor, + 'name': name, + 'textColor': textColor, + 'accentColor': accentColor, + 'tableTextColor': tableTextColor, + 'tableHeaderTextColor': tableHeaderTextColor, + 'titleBackgroundColor': titleBackgroundColor, + 'titleTextColor': titleTextColor, + 'sectionBackgroundColor': sectionBackgroundColor, + 'logoPath': logoPath, + 'logoPosition': logoPosition, + 'logoSize': logoSize, + 'fontFamily': fontFamily, + 'footerText': footerText, + 'footerShowPageNumbers': footerShowPageNumbers, + 'footerPosition': footerPosition, + }; + } + + factory ThemeProfile.fromJson(Map json) { + return ThemeProfile( + slideBackgroundColor: + json['slideBackgroundColor'] as String? ?? '#FFFFFF', + name: json['name'] as String? ?? 'Standaard', + textColor: json['textColor'] as String? ?? '#222222', + accentColor: json['accentColor'] as String? ?? '#2E7D64', + tableTextColor: + json['tableTextColor'] as String? ?? + json['textColor'] as String? ?? + '#222222', + tableHeaderTextColor: + json['tableHeaderTextColor'] as String? ?? '#FFFFFF', + titleBackgroundColor: + json['titleBackgroundColor'] as String? ?? '#1C2B47', + titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF', + sectionBackgroundColor: + json['sectionBackgroundColor'] as String? ?? '#2E7D64', + logoPath: json['logoPath'] as String?, + logoPosition: json['logoPosition'] as String? ?? 'bottom-right', + logoSize: (json['logoSize'] as num?)?.round() ?? 96, + fontFamily: json['fontFamily'] as String? ?? 'Arial', + footerText: json['footerText'] as String? ?? '', + footerShowPageNumbers: json['footerShowPageNumbers'] as bool? ?? false, + footerPosition: json['footerPosition'] as String? ?? 'right', + ); + } +} + +class AppSettings { + final String? homeDirectory; + final List themeProfiles; + final String selectedThemeProfileName; + final List recentFiles; + + const AppSettings({ + this.homeDirectory, + this.themeProfiles = const [ThemeProfile()], + this.selectedThemeProfileName = 'Standaard', + this.recentFiles = const [], + }); + + ThemeProfile get themeProfile { + return themeProfiles.firstWhere( + (p) => p.name == selectedThemeProfileName, + orElse: () => themeProfiles.first, + ); + } + + static const availableFonts = [ + 'Arial', + 'EB Garamond', + 'Helvetica Neue', + 'Verdana', + 'Trebuchet MS', + 'Georgia', + 'Times New Roman', + 'Gill Sans MT', + 'Calibri', + 'Segoe UI', + 'Courier New', + ]; + + AppSettings copyWith({ + String? homeDirectory, + ThemeProfile? themeProfile, + List? themeProfiles, + String? selectedThemeProfileName, + List? recentFiles, + bool clearHomeDirectory = false, + }) { + final nextProfiles = themeProfiles ?? this.themeProfiles; + return AppSettings( + homeDirectory: clearHomeDirectory + ? null + : (homeDirectory ?? this.homeDirectory), + themeProfiles: themeProfile == null + ? nextProfiles + : [ + for (final profile in nextProfiles) + if (profile.name == themeProfile.name) + themeProfile + else + profile, + if (!nextProfiles.any((p) => p.name == themeProfile.name)) + themeProfile, + ], + selectedThemeProfileName: + selectedThemeProfileName ?? + themeProfile?.name ?? + this.selectedThemeProfileName, + recentFiles: recentFiles ?? this.recentFiles, + ); + } +} diff --git a/lib/models/slide.dart b/lib/models/slide.dart new file mode 100644 index 0000000..2782747 --- /dev/null +++ b/lib/models/slide.dart @@ -0,0 +1,236 @@ +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +enum SlideType { + title, + section, + bullets, + twoBullets, + bulletsImage, + twoImages, + image, + video, + quote, + table, + freeMarkdown, +} + +extension SlideTypeExtension on SlideType { + String get label { + switch (this) { + case SlideType.title: + return 'Titelpagina'; + case SlideType.section: + return 'Tussentitel'; + case SlideType.bullets: + return 'Alleen Bullets'; + case SlideType.twoBullets: + return 'Twee Bulletkolommen'; + case SlideType.bulletsImage: + return 'Bullets + Afbeelding'; + case SlideType.twoImages: + return 'Twee Afbeeldingen'; + case SlideType.image: + return 'Grote Afbeelding'; + case SlideType.video: + return 'Video'; + case SlideType.quote: + return 'Quote'; + case SlideType.table: + return 'Tabel'; + case SlideType.freeMarkdown: + return 'Vrije Markdown'; + } + } + + String get marpClass { + switch (this) { + case SlideType.title: + return 'title'; + case SlideType.section: + return 'section'; + case SlideType.bullets: + return ''; + case SlideType.twoBullets: + return 'two-bullets'; + case SlideType.bulletsImage: + return 'split'; + case SlideType.twoImages: + return ''; + case SlideType.image: + return ''; + case SlideType.video: + return 'video'; + case SlideType.quote: + return 'quote'; + case SlideType.table: + return 'table'; + case SlideType.freeMarkdown: + return ''; + } + } +} + +class Slide { + final String id; + final SlideType type; + final String title; + final String subtitle; + final List bullets; + final List bullets2; + final String imagePath; + final String imagePath2; + final String imageCaption; + final String imageCaption2; + final String videoPath; + final bool videoAutoplay; + final String audioPath; + final bool audioAutoplay; + final String quote; + final String quoteAuthor; + final String customMarkdown; + final String cssClass; + final String notes; + final double advanceDuration; // 0 = no auto-advance + final int imageSize; // 0 = auto; image: bg %, bulletsImage: right panel % + final bool showLogo; // show the profile logo on this slide (default true) + final bool showFooter; // show the profile footer on this slide (default true) + final bool skipped; // skip this slide when presenting and exporting + final List> tableRows; // first row is the header + + const Slide({ + required this.id, + required this.type, + this.title = '', + this.subtitle = '', + this.bullets = const [], + this.bullets2 = const [], + this.imagePath = '', + this.imagePath2 = '', + this.imageCaption = '', + this.imageCaption2 = '', + this.videoPath = '', + this.videoAutoplay = false, + this.audioPath = '', + this.audioAutoplay = false, + this.quote = '', + this.quoteAuthor = '', + this.customMarkdown = '', + this.cssClass = '', + this.notes = '', + this.advanceDuration = 0, + this.imageSize = 0, + this.showLogo = true, + this.showFooter = true, + this.skipped = false, + this.tableRows = const [], + }); + + factory Slide.create(SlideType type) { + return Slide( + id: _uuid.v4(), + type: type, + bullets: + (type == SlideType.bullets || + type == SlideType.twoBullets || + type == SlideType.bulletsImage) + ? const [''] + : const [], + bullets2: type == SlideType.twoBullets ? const [''] : const [], + tableRows: type == SlideType.table + ? const [ + // Lege koppen: de editor toont 'Kolom 1' etc. als hint, zodat de + // gebruiker niets hoeft te verwijderen voordat hij begint. + ['', ''], + ['', ''], + ] + : const [], + ); + } + + factory Slide.duplicate(Slide src) { + return Slide( + id: _uuid.v4(), + type: src.type, + title: src.title, + subtitle: src.subtitle, + bullets: List.from(src.bullets), + bullets2: List.from(src.bullets2), + imagePath: src.imagePath, + imagePath2: src.imagePath2, + imageCaption: src.imageCaption, + imageCaption2: src.imageCaption2, + videoPath: src.videoPath, + videoAutoplay: src.videoAutoplay, + audioPath: src.audioPath, + audioAutoplay: src.audioAutoplay, + quote: src.quote, + quoteAuthor: src.quoteAuthor, + customMarkdown: src.customMarkdown, + cssClass: src.cssClass, + notes: src.notes, + advanceDuration: src.advanceDuration, + imageSize: src.imageSize, + showLogo: src.showLogo, + showFooter: src.showFooter, + skipped: src.skipped, + tableRows: src.tableRows.map((r) => List.from(r)).toList(), + ); + } + + Slide copyWith({ + SlideType? type, + String? title, + String? subtitle, + List? bullets, + List? bullets2, + String? imagePath, + String? imagePath2, + String? imageCaption, + String? imageCaption2, + String? videoPath, + bool? videoAutoplay, + String? audioPath, + bool? audioAutoplay, + String? quote, + String? quoteAuthor, + String? customMarkdown, + String? cssClass, + String? notes, + double? advanceDuration, + int? imageSize, + bool? showLogo, + bool? showFooter, + bool? skipped, + List>? tableRows, + }) { + return Slide( + id: id, + type: type ?? this.type, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + bullets: bullets ?? this.bullets, + bullets2: bullets2 ?? this.bullets2, + imagePath: imagePath ?? this.imagePath, + imagePath2: imagePath2 ?? this.imagePath2, + imageCaption: imageCaption ?? this.imageCaption, + imageCaption2: imageCaption2 ?? this.imageCaption2, + videoPath: videoPath ?? this.videoPath, + videoAutoplay: videoAutoplay ?? this.videoAutoplay, + audioPath: audioPath ?? this.audioPath, + audioAutoplay: audioAutoplay ?? this.audioAutoplay, + quote: quote ?? this.quote, + quoteAuthor: quoteAuthor ?? this.quoteAuthor, + customMarkdown: customMarkdown ?? this.customMarkdown, + cssClass: cssClass ?? this.cssClass, + notes: notes ?? this.notes, + advanceDuration: advanceDuration ?? this.advanceDuration, + imageSize: imageSize ?? this.imageSize, + showLogo: showLogo ?? this.showLogo, + showFooter: showFooter ?? this.showFooter, + skipped: skipped ?? this.skipped, + tableRows: tableRows ?? this.tableRows, + ); + } +} diff --git a/lib/services/caption_service.dart b/lib/services/caption_service.dart new file mode 100644 index 0000000..7433140 --- /dev/null +++ b/lib/services/caption_service.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding. +/// Bestandsnaam: .ocideck_captions.json +class CaptionService { + static const _sidecar = '.ocideck_captions.json'; + + Future getCaption(String imagePath, {String? basePath}) async { + if (imagePath.isEmpty) return null; + final resolvedPath = _resolvePath(imagePath, basePath); + final file = _sidecarFile(resolvedPath); + if (!file.existsSync()) return null; + try { + final data = jsonDecode(await file.readAsString()) as Map; + final caption = data[p.basename(resolvedPath)]; + return caption is String ? caption : null; + } catch (_) { + return null; + } + } + + Future saveCaption( + String imagePath, + String caption, { + String? basePath, + }) async { + if (imagePath.isEmpty) return; + final resolvedPath = _resolvePath(imagePath, basePath); + final file = _sidecarFile(resolvedPath); + Map data = {}; + if (file.existsSync()) { + try { + data = Map.from( + jsonDecode(await file.readAsString()) as Map, + ); + } catch (_) {} + } + final key = p.basename(resolvedPath); + if (caption.trim().isEmpty) { + data.remove(key); + } else { + data[key] = caption.trim(); + } + if (data.isEmpty) { + if (file.existsSync()) await file.delete(); + } else { + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(data), + ); + } + } + + Future copyCaption( + String sourceImagePath, + String destinationImagePath, { + String? sourceBasePath, + String? destinationBasePath, + }) async { + final caption = await getCaption(sourceImagePath, basePath: sourceBasePath); + if (caption == null || caption.trim().isEmpty) return; + await saveCaption( + destinationImagePath, + caption, + basePath: destinationBasePath, + ); + } + + String _resolvePath(String imagePath, String? basePath) { + if (p.isAbsolute(imagePath) || basePath == null || basePath.isEmpty) { + return imagePath; + } + return p.join(basePath, imagePath); + } + + File _sidecarFile(String imagePath) { + return File(p.join(p.dirname(imagePath), _sidecar)); + } +} + +final captionServiceProvider = Provider( + (_) => CaptionService(), +); diff --git a/lib/services/description_service.dart b/lib/services/description_service.dart new file mode 100644 index 0000000..e0e3809 --- /dev/null +++ b/lib/services/description_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +/// Stores short, searchable image descriptions as a JSON sidecar in the image's +/// own directory. File name: .ocideck_descriptions.json, keyed by base name. +/// +/// Kept separate from captions (which are source/credit lines): a description +/// is free-text used to find images by the words in it. +class DescriptionService { + static const _sidecar = '.ocideck_descriptions.json'; + + Future getDescription(String imagePath) async { + if (imagePath.isEmpty) return null; + final file = _sidecarFile(imagePath); + if (!file.existsSync()) return null; + try { + final data = jsonDecode(await file.readAsString()) as Map; + final value = data[p.basename(imagePath)]; + return value is String ? value : null; + } catch (_) { + return null; + } + } + + Future saveDescription(String imagePath, String description) async { + if (imagePath.isEmpty) return; + final file = _sidecarFile(imagePath); + Map data = {}; + if (file.existsSync()) { + try { + data = Map.from( + jsonDecode(await file.readAsString()) as Map, + ); + } catch (_) {} + } + final key = p.basename(imagePath); + if (description.trim().isEmpty) { + data.remove(key); + } else { + data[key] = description.trim(); + } + if (data.isEmpty) { + if (file.existsSync()) await file.delete(); + } else { + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(data), + ); + } + } + + /// Remove the description entry for [imagePath] (used when an image is + /// deleted). Safe to call when no entry exists. + Future removeDescription(String imagePath) => + saveDescription(imagePath, ''); + + /// Load every description stored in the directories that contain [imagePaths]. + /// Returns a map of absolute image path → description. Each sidecar is read + /// once, so this stays cheap even for thousands of images. + Future> loadFor(Iterable imagePaths) async { + final dirs = {for (final path in imagePaths) p.dirname(path)}; + final result = {}; + for (final dir in dirs) { + final file = File(p.join(dir, _sidecar)); + if (!file.existsSync()) continue; + try { + final data = jsonDecode(await file.readAsString()) as Map; + for (final entry in data.entries) { + if (entry.value is String) { + result[p.join(dir, entry.key as String)] = entry.value as String; + } + } + } catch (_) {} + } + return result; + } + + File _sidecarFile(String imagePath) { + return File(p.join(p.dirname(imagePath), _sidecar)); + } +} + +final descriptionServiceProvider = Provider( + (_) => DescriptionService(), +); diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart new file mode 100644 index 0000000..22023f0 --- /dev/null +++ b/lib/services/export_service.dart @@ -0,0 +1,355 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +enum ExportFormat { pdf, pptx } + +extension ExportFormatExtension on ExportFormat { + String get label { + switch (this) { + case ExportFormat.pdf: + return 'PDF'; + case ExportFormat.pptx: + return 'PowerPoint (PPTX)'; + } + } + + String get extension { + switch (this) { + case ExportFormat.pdf: + return '.pdf'; + case ExportFormat.pptx: + return '.pptx'; + } + } +} + +class ExportResult { + final bool success; + final String? outputPath; + final String? error; + + const ExportResult._({required this.success, this.outputPath, this.error}); + + factory ExportResult.ok(String path) => + ExportResult._(success: true, outputPath: path); + factory ExportResult.fail(String error) => + ExportResult._(success: false, error: error); +} + +/// Builds PDF and PPTX files from pre-rendered slide images (WYSIWYG export). +/// Slides are expected to be 16:9 PNG bytes (see [SlideRasterizer]). +class ExportService { + // 16:9 widescreen slide size in EMU (English Metric Units): 13.333" x 7.5". + static const int _slideWidthEmu = 12192000; + static const int _slideHeightEmu = 6858000; + + /// Write [images] to a file derived from [deckPath] (same folder/base name) + /// in the requested [format]. + Future export( + String deckPath, + ExportFormat format, + List images, + ) async { + if (images.isEmpty) { + return ExportResult.fail('Geen slides om te exporteren.'); + } + final outputPath = '${p.withoutExtension(deckPath)}${format.extension}'; + try { + final Uint8List bytes; + switch (format) { + case ExportFormat.pdf: + bytes = await _buildPdf(images); + case ExportFormat.pptx: + bytes = _buildPptx(images); + } + await File(outputPath).writeAsBytes(bytes, flush: true); + return ExportResult.ok(outputPath); + } catch (e) { + return ExportResult.fail('Export fout: $e'); + } + } + + // ── PDF ─────────────────────────────────────────────────────────────────── + + Future _buildPdf(List images) async { + final doc = pw.Document(); + // Page size in points; only the ratio matters for a full-bleed image. + const format = PdfPageFormat(1280, 720, marginAll: 0); + for (final png in images) { + final image = pw.MemoryImage(png); + doc.addPage( + pw.Page( + pageFormat: format, + build: (_) => pw.Image(image, fit: pw.BoxFit.fill), + ), + ); + } + return doc.save(); + } + + // ── PPTX (Office Open XML) ───────────────────────────────────────────────── + + Uint8List _buildPptx(List images) { + final archive = Archive(); + void addText(String name, String content) { + final data = utf8Bytes(content); + archive.add(ArchiveFile(name, data.length, data)); + } + + final slideCount = images.length; + + addText('[Content_Types].xml', _contentTypes(slideCount)); + addText('_rels/.rels', _rootRels()); + addText('ppt/presentation.xml', _presentationXml(slideCount)); + addText('ppt/_rels/presentation.xml.rels', _presentationRels(slideCount)); + addText('ppt/presProps.xml', _presProps()); + addText('ppt/theme/theme1.xml', _theme1()); + addText('ppt/slideMasters/slideMaster1.xml', _slideMaster()); + addText('ppt/slideMasters/_rels/slideMaster1.xml.rels', _slideMasterRels()); + addText('ppt/slideLayouts/slideLayout1.xml', _slideLayout()); + addText('ppt/slideLayouts/_rels/slideLayout1.xml.rels', _slideLayoutRels()); + + for (var i = 0; i < slideCount; i++) { + final n = i + 1; + addText('ppt/slides/slide$n.xml', _slideXml()); + addText('ppt/slides/_rels/slide$n.xml.rels', _slideRels(n)); + final png = images[i]; + archive.add(ArchiveFile('ppt/media/image$n.png', png.length, png)); + } + + return ZipEncoder().encodeBytes(archive); + } + + static List utf8Bytes(String s) => utf8.encode(s); + + String _contentTypes(int count) { + final overrides = StringBuffer(); + for (var i = 1; i <= count; i++) { + overrides.write( + '', + ); + } + return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '$overrides' + ''; + } + + String _rootRels() { + return '' + '' + '' + ''; + } + + String _presentationXml(int count) { + final sldIds = StringBuffer(); + for (var i = 0; i < count; i++) { + // Slide relationship ids start at rId2 (rId1 = master). + sldIds.write(''); + } + return '' + '' + '' + '$sldIds' + '' + '' + ''; + } + + String _presentationRels(int count) { + final rels = StringBuffer(); + rels.write( + '', + ); + for (var i = 0; i < count; i++) { + final n = i + 1; + rels.write( + '', + ); + } + final presPropsId = 'rId${count + 2}'; + final themeId = 'rId${count + 3}'; + rels.write( + '', + ); + rels.write( + '', + ); + return '' + '' + '$rels' + ''; + } + + String _presProps() { + return '' + ''; + } + + String _slideMaster() { + return '' + '' + '' + '' + '${_emptySpTree()}' + '' + '' + '' + '' + ''; + } + + String _slideMasterRels() { + return '' + '' + '' + '' + ''; + } + + String _slideLayout() { + return '' + '' + '${_emptySpTree()}' + '' + ''; + } + + String _slideLayoutRels() { + return '' + '' + '' + ''; + } + + String _slideXml() { + return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; + } + + String _slideRels(int n) { + return '' + '' + '' + '' + ''; + } + + String _emptySpTree() { + return '' + '' + '' + '' + ''; + } + + String _theme1() { + return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; + } +} diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart new file mode 100644 index 0000000..f0550aa --- /dev/null +++ b/lib/services/file_service.dart @@ -0,0 +1,674 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:archive/archive.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:flutter/services.dart' show rootBundle; +import '../models/deck.dart'; +import '../models/settings.dart'; +import '../models/slide.dart'; +import 'caption_service.dart'; +import 'image_service.dart'; +import 'markdown_service.dart'; + +/// A presentation found on disk while scanning a directory. +class ScannedPresentation { + final String path; + final String fileName; + final Deck deck; + + /// The raw markdown source, kept for maximal full-text search. + final String content; + + const ScannedPresentation({ + required this.path, + required this.fileName, + required this.deck, + this.content = '', + }); +} + +class _LogoProjectAsset { + final ThemeProfile profile; + final String? cssUrl; + + const _LogoProjectAsset(this.profile, this.cssUrl); +} + +class FileService { + final MarkdownService _md; + final ImageService _img; + final ThemeProfile Function() _themeProfile; + final CaptionService _captions = CaptionService(); + + FileService(this._md, this._img, this._themeProfile); + + ThemeProfile get currentThemeProfile => _themeProfile(); + + static const _ignoredDirs = { + 'images', + 'logos', + 'themes', + 'node_modules', + 'build', + '.git', + '.dart_tool', + }; + + /// Recursively scan [directory] for Marp markdown presentations and parse + /// them into decks. [excludePath] (typically the currently open file) is + /// skipped. Directories such as images/ and themes/ are ignored, and the + /// walk is bounded by [maxDepth] to keep large home folders responsive. + Future> scanPresentations( + String directory, { + String? excludePath, + int maxDepth = 4, + }) async { + final root = Directory(directory); + if (!await root.exists()) return []; + + final results = []; + Future walk(Directory dir, int depth) async { + List entries; + try { + entries = await dir.list(followLinks: false).toList(); + } catch (_) { + return; + } + for (final entity in entries) { + if (entity is File) { + if (!entity.path.toLowerCase().endsWith('.md')) continue; + if (excludePath != null && p.equals(entity.path, excludePath)) { + continue; + } + String content; + try { + content = await entity.readAsString(); + } catch (_) { + continue; + } + final deck = await openDeck(entity.path, content: content); + if (deck != null && deck.slides.isNotEmpty) { + results.add( + ScannedPresentation( + path: entity.path, + fileName: p.basename(entity.path), + deck: deck, + content: content, + ), + ); + } + } else if (entity is Directory && depth < maxDepth) { + final name = p.basename(entity.path); + if (_ignoredDirs.contains(name) || name.startsWith('.')) continue; + await walk(entity, depth + 1); + } + } + } + + await walk(root, 0); + results.sort( + (a, b) => + a.deck.title.toLowerCase().compareTo(b.deck.title.toLowerCase()), + ); + return results; + } + + Future pickMarkdownFile({String? initialDirectory}) async { + final result = await FilePicker.pickFiles( + dialogTitle: 'Presentatie openen', + type: FileType.custom, + allowedExtensions: ['md'], + initialDirectory: initialDirectory, + ); + return result?.files.single.path; + } + + Future openDeck(String filePath, {String? content}) async { + String raw; + if (content != null) { + raw = content; + } else { + final file = File(filePath); + if (!await file.exists()) return null; + raw = await file.readAsString(); + } + final deck = _md.parseDeck(raw, filePath: filePath); + if (deck == null) return null; + return _hydrateImageCaptions(deck); + } + + Future saveDeckAs(Deck deck, {String? initialDirectory}) async { + final safeName = deck.title + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(' ', '_'); + final result = await FilePicker.saveFile( + dialogTitle: 'Opslaan als', + fileName: '$safeName.md', + initialDirectory: initialDirectory, + ); + if (result == null) return null; + final path = result.endsWith('.md') ? result : '$result.md'; + await _writeProject(deck, path); + return path; + } + + Future saveDeck(Deck deck, String filePath) async { + return _writeProject(deck, filePath); + } + + // ── Draagbaar pakket (uitwisselen / op een ander systeem draaien) ────────── + + static const packageExtension = 'ocideck'; + + String _safeName(String title) { + final cleaned = title + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(RegExp(r'\s+'), '_') + .trim(); + return cleaned.isEmpty ? 'presentatie' : cleaned; + } + + /// Schrijf een zelfstandig pakket (zip): de markdown + álle gebruikte assets + /// (afbeeldingen, media, logo) en de thema-CSS, met onderling relatieve + /// paden. Werkt ongeacht of het deck al is opgeslagen. + Future exportPackage(Deck deck, String destPath) async { + final archive = Archive(); + final added = {}; + + /// Resolve [path] (relatief t.o.v. projectPath of absoluut), voeg het + /// bestand toe onder `/` en geef dat pad terug. + String? addAsset(String path, String subdir) { + if (path.trim().isEmpty) return null; + final abs = p.isAbsolute(path) + ? path + : (deck.projectPath != null ? p.join(deck.projectPath!, path) : path); + final file = File(abs); + if (!file.existsSync()) return null; + final rel = p.posix.join(subdir, p.basename(abs)); + if (!added.contains(rel)) { + final bytes = file.readAsBytesSync(); + archive.add(ArchiveFile(rel, bytes.length, bytes)); + added.add(rel); + } + return rel; + } + + final slides = [ + for (final s in deck.slides) + s.copyWith( + imagePath: addAsset(s.imagePath, 'images') ?? s.imagePath, + imagePath2: addAsset(s.imagePath2, 'images') ?? s.imagePath2, + videoPath: addAsset(s.videoPath, 'media') ?? s.videoPath, + audioPath: addAsset(s.audioPath, 'media') ?? s.audioPath, + ), + ]; + + final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos'); + final profile = logoRel != null + ? deck.themeProfile.copyWith(logoPath: logoRel) + : deck.themeProfile; + + final packDeck = deck.copyWith(slides: slides, themeProfile: profile); + + // Markdown. + final markdown = _md.generateDeck(packDeck); + final mdBytes = utf8.encode(markdown); + archive.add( + ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes), + ); + + // Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is). + final css = await _packageThemeCss(packDeck.theme, profile, logoRel); + if (css != null) { + final cssBytes = utf8.encode(css); + final themeName = packDeck.theme.trim().isEmpty + ? 'ocideck' + : packDeck.theme; + archive.add( + ArchiveFile('themes/$themeName.css', cssBytes.length, cssBytes), + ); + } + + final bytes = ZipEncoder().encodeBytes(archive); + await File(destPath).writeAsBytes(bytes, flush: true); + } + + Future _packageThemeCss( + String themeName, + ThemeProfile profile, + String? logoRel, + ) async { + final safe = themeName.trim().isEmpty ? 'ocideck' : themeName; + try { + final base = (await rootBundle.loadString( + 'assets/themes/ocideck.css', + )).replaceFirst('@theme ocideck', '@theme $safe'); + return _buildThemeCss( + base, + profile, + logoRel == null ? null : '../$logoRel', + ); + } catch (_) { + return null; + } + } + + /// Pak een pakket uit in een nieuwe submap onder [destParentDir]. Geeft het + /// pad naar het uitgepakte markdown-bestand terug (om in een tab te openen). + Future importPackageBytes( + List zipBytes, + String destParentDir, + ) async { + final Archive archive; + try { + archive = ZipDecoder().decodeBytes(zipBytes); + } catch (_) { + return null; + } + + // Kies de markdown met het ondiepste pad (de hoofd-md van het pakket). + ArchiveFile? mdEntry; + for (final f in archive.files) { + if (!f.isFile || !f.name.toLowerCase().endsWith('.md')) continue; + if (mdEntry == null || + '/'.allMatches(f.name).length < '/'.allMatches(mdEntry.name).length) { + mdEntry = f; + } + } + if (mdEntry == null) return null; + + final folderName = p.basenameWithoutExtension(mdEntry.name); + final destDir = _uniqueDir(destParentDir, folderName); + await destDir.create(recursive: true); + + for (final f in archive.files) { + if (!f.isFile) continue; + final out = File(p.join(destDir.path, f.name)); + await out.parent.create(recursive: true); + await out.writeAsBytes(f.content as List, flush: true); + } + + return p.join(destDir.path, mdEntry.name); + } + + Directory _uniqueDir(String parent, String name) { + var dir = Directory(p.join(parent, name)); + var i = 2; + while (dir.existsSync()) { + dir = Directory(p.join(parent, '$name ($i)')); + i++; + } + return dir; + } + + /// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt; + /// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het + /// markdown-bestand terug. + Future importFromUrl(String url, String destParentDir) async { + final uri = Uri.tryParse(url.trim()); + if (uri == null || !uri.hasScheme) return null; + + final List bytes; + try { + final client = HttpClient(); + try { + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != 200) return null; + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + bytes = builder.takeBytes(); + } finally { + client.close(force: true); + } + } catch (_) { + return null; + } + + // Zip-magie 'PK\x03\x04' → pakket; anders als markdown behandelen. + final isZip = + bytes.length >= 4 && + bytes[0] == 0x50 && + bytes[1] == 0x4B && + bytes[2] == 0x03 && + bytes[3] == 0x04; + if (isZip) { + return importPackageBytes(bytes, destParentDir); + } + + // Platte markdown. + final String markdown; + try { + markdown = utf8.decode(bytes); + } catch (_) { + return null; + } + if (!markdown.contains('marp') && !markdown.contains('---')) return null; + + var base = p.basenameWithoutExtension(uri.path); + if (base.isEmpty) base = 'presentatie'; + final destDir = _uniqueDir(destParentDir, base); + await destDir.create(recursive: true); + final mdPath = p.join(destDir.path, '$base.md'); + await File(mdPath).writeAsString(markdown); + return mdPath; + } + + Future pickPackageFile({String? initialDirectory}) async { + final result = await FilePicker.pickFiles( + dialogTitle: 'Pakket importeren', + type: FileType.custom, + allowedExtensions: [packageExtension, 'zip'], + initialDirectory: initialDirectory, + ); + return result?.files.single.path; + } + + Future pickPackageDestination(Deck deck) async { + return FilePicker.saveFile( + dialogTitle: 'Pakket exporteren', + fileName: '${_safeName(deck.title)}.$packageExtension', + ); + } + + Future _writeProject(Deck deck, String filePath) async { + final dir = p.dirname(filePath); + + final imagesDir = Directory(p.join(dir, 'images')); + final logosDir = Directory(p.join(dir, 'logos')); + final themesDir = Directory(p.join(dir, 'themes')); + await imagesDir.create(recursive: true); + await logosDir.create(recursive: true); + await themesDir.create(recursive: true); + + final imageSlides = await _img.copyImagesToProject(deck.slides, dir); + final mediaSlides = await _img.copyMediaToProject(imageSlides, dir); + var updatedDeck = deck.copyWith(slides: mediaSlides, projectPath: dir); + final logoAsset = await _copyLogoToProject(updatedDeck.themeProfile, dir); + updatedDeck = updatedDeck.copyWith(themeProfile: logoAsset.profile); + await _writeImageCaptions(updatedDeck); + + await _writeTheme( + themesDir.path, + updatedDeck.theme, + updatedDeck.themeProfile, + logoAsset.cssUrl, + ); + + final markdown = _md.generateDeck(updatedDeck); + await File(filePath).writeAsString(markdown); + return updatedDeck; + } + + Future _hydrateImageCaptions(Deck deck) async { + final slides = []; + for (final slide in deck.slides) { + var next = slide; + if (slide.imagePath.isNotEmpty) { + final caption = await _captions.getCaption( + slide.imagePath, + basePath: deck.projectPath, + ); + if (caption != null) next = next.copyWith(imageCaption: caption); + } + if (slide.imagePath2.isNotEmpty) { + final caption = await _captions.getCaption( + slide.imagePath2, + basePath: deck.projectPath, + ); + if (caption != null) next = next.copyWith(imageCaption2: caption); + } + slides.add(next); + } + return deck.copyWith(slides: slides); + } + + Future _writeImageCaptions(Deck deck) async { + for (final slide in deck.slides) { + if (slide.imagePath.isNotEmpty && slide.imageCaption.trim().isNotEmpty) { + await _captions.saveCaption( + slide.imagePath, + slide.imageCaption, + basePath: deck.projectPath, + ); + } + if (slide.imagePath2.isNotEmpty && + slide.imageCaption2.trim().isNotEmpty) { + await _captions.saveCaption( + slide.imagePath2, + slide.imageCaption2, + basePath: deck.projectPath, + ); + } + } + } + + Future _writeTheme( + String themesPath, + String themeName, + ThemeProfile profile, + String? logoUrl, + ) async { + final safeThemeName = themeName.trim().isEmpty ? 'ocideck' : themeName; + final dest = File(p.join(themesPath, '$safeThemeName.css')); + try { + final base = (await rootBundle.loadString( + 'assets/themes/ocideck.css', + )).replaceFirst('@theme ocideck', '@theme $safeThemeName'); + await dest.writeAsString(_buildThemeCss(base, profile, logoUrl)); + } catch (_) { + // Asset not bundled in this build context; skip + } + } + + Future<_LogoProjectAsset> _copyLogoToProject( + ThemeProfile profile, + String projectPath, + ) async { + final logoPath = profile.logoPath; + if (logoPath == null || logoPath.trim().isEmpty) { + return _LogoProjectAsset(profile, null); + } + + final normalized = logoPath.replaceAll('\\', '/'); + final relativeLogoPath = p.posix.isRelative(normalized) + ? p.posix.normalize(normalized) + : null; + if (relativeLogoPath != null && relativeLogoPath.startsWith('logos/')) { + return _LogoProjectAsset( + profile.copyWith(logoPath: relativeLogoPath), + '../$relativeLogoPath', + ); + } + + var sourcePath = p.isAbsolute(logoPath) + ? logoPath + : p.normalize(p.join(projectPath, logoPath)); + var src = File(sourcePath); + if (!await src.exists()) { + final fallback = await _findExistingProjectLogo(projectPath, normalized); + if (fallback == null) { + return _LogoProjectAsset(profile, null); + } + sourcePath = fallback; + src = File(sourcePath); + } + + final filename = p.posix.basename(normalized); + if (filename.isEmpty || filename == '.' || filename == '..') { + return _LogoProjectAsset(profile, null); + } + + final relativePath = p.posix.join('logos', filename); + final dest = File(p.join(projectPath, relativePath)); + if (!p.equals(src.path, dest.path)) { + await dest.parent.create(recursive: true); + await src.copy(dest.path); + } + + return _LogoProjectAsset( + profile.copyWith(logoPath: relativePath), + '../$relativePath', + ); + } + + Future _findExistingProjectLogo( + String projectPath, + String normalizedLogoPath, + ) async { + final filename = p.posix.basename(normalizedLogoPath); + if (filename.isEmpty || filename == '.' || filename == '..') return null; + + final candidates = [ + p.join(projectPath, 'logos', filename), + p.join(projectPath, 'images', filename), + p.join(projectPath, 'images', 'logo_$filename'), + ]; + for (final candidate in candidates) { + if (await File(candidate).exists()) return candidate; + } + return null; + } + + String _buildThemeCss(String base, ThemeProfile profile, String? logoUrl) { + final logoCss = logoUrl == null + ? '' + : ''' + +section.logo-safe { + ${_logoSafePaddingCss(profile)} +} + +section.split.logo-safe { + padding: 48px 0 48px var(--split-margin); +} + +${_splitLogoSafeCss(profile)} + +section::before { + content: ""; + position: absolute; + width: ${profile.logoSize}px; + height: ${profile.logoSize}px; + background-image: url("$logoUrl"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + opacity: 0.9; + ${_logoPositionCss(profile.logoPosition)} +} + +section.no-logo::before { + display: none; +} +'''; + + return ''' +$base + +/* OciDeck style profile */ +section { + background: ${profile.slideBackgroundColor}; + color: ${profile.textColor}; + position: relative; +} + +section h1, +section h2, +section h3, +section strong { + color: ${profile.textColor}; +} + +section li::marker { + color: ${profile.accentColor}; +} + +section.title { + background: ${profile.titleBackgroundColor}; + color: ${profile.titleTextColor}; +} + +section.title h1, +section.title h2 { + color: ${profile.titleTextColor}; +} + +section.section { + background: ${profile.sectionBackgroundColor}; + color: ${profile.titleTextColor}; +} + +section.section h1 { + color: ${profile.titleTextColor}; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 0.72em; +} + +th, td { + border: 1px solid ${profile.accentColor}; + padding: 0.22em 0.45em; + text-align: left; + color: ${profile.tableTextColor}; +} + +thead th, tr:first-child th { + background: ${profile.accentColor}; + color: ${profile.tableHeaderTextColor}; +} +$logoCss +'''; + } + + String _logoPositionCss(String position) { + switch (position) { + case 'top-left': + return 'top: 40px;\n left: 28px;'; + case 'top-right': + return 'top: 40px;\n right: 28px;'; + case 'bottom-left': + return 'bottom: 12px;\n left: 28px;'; + case 'bottom-right': + default: + return 'bottom: 12px;\n right: 28px;'; + } + } + + String _logoSafePaddingCss(ThemeProfile profile) { + final reserved = profile.logoSize + 64; + switch (profile.logoPosition) { + case 'top-left': + case 'top-right': + return 'padding-top: ${reserved}px;'; + case 'bottom-left': + case 'bottom-right': + default: + return 'padding-bottom: ${reserved}px;'; + } + } + + String _splitLogoSafeCss(ThemeProfile profile) { + if (profile.logoPosition.endsWith('right')) return ''; + final reserved = profile.logoSize + 24; + if (profile.logoPosition.startsWith('top')) { + return ''' +section.split.logo-safe .split-text { + padding-top: ${reserved}px; +} +'''; + } + return ''' +section.split.logo-safe .split-text { + padding-bottom: ${reserved}px; +} +'''; + } +} diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart new file mode 100644 index 0000000..60ba0aa --- /dev/null +++ b/lib/services/image_service.dart @@ -0,0 +1,171 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; +import 'package:pasteboard/pasteboard.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; +import '../models/slide.dart'; + +class ImageService { + Future pickImage() async { + final result = await FilePicker.pickFiles( + type: FileType.image, + dialogTitle: 'Kies een afbeelding', + ); + return result?.files.single.path; + } + + Future pickVideo() async { + final result = await FilePicker.pickFiles( + type: FileType.video, + dialogTitle: 'Kies een video', + ); + return result?.files.single.path; + } + + Future pickAudio() async { + final result = await FilePicker.pickFiles( + type: FileType.audio, + dialogTitle: 'Kies een audiobestand', + ); + return result?.files.single.path; + } + + /// Schrijf afbeeldings[bytes] naar het systeemklembord. Geeft false terug + /// bij een fout. Gebruikt voor zowel bestanden als een gerasteriseerde slide. + Future copyImageBytesToClipboard(Uint8List bytes) async { + try { + if (bytes.isEmpty) return false; + await Pasteboard.writeImage(bytes); + return true; + } catch (_) { + return false; + } + } + + /// Kopieer de afbeelding op [path] naar het systeemklembord, zodat 'ie + /// elders geplakt kan worden. Geeft false terug bij een fout/ontbrekend + /// bestand. De ruwe bytes volstaan: het OS leest gangbare formaten zelf. + Future copyImageToClipboard(String path) async { + try { + if (path.isEmpty) return false; + final file = File(path); + if (!await file.exists()) return false; + return copyImageBytesToClipboard(await file.readAsBytes()); + } catch (_) { + return false; + } + } + + /// Read an image from the system clipboard and save it to a temp file. + /// Returns the absolute path to the temp file, or null if no image is on + /// the clipboard. + Future pasteImage() async { + try { + final bytes = await Pasteboard.image; + if (bytes == null) return null; + final cacheDir = await getTemporaryDirectory(); + final dir = Directory(p.join(cacheDir.path, 'pasted_images')); + await dir.create(recursive: true); + final file = File( + p.join(dir.path, 'pasted_${DateTime.now().millisecondsSinceEpoch}.png'), + ); + await file.writeAsBytes(bytes, flush: true); + return file.path; + } on FileSystemException { + return null; + } + } + + /// Copy images referenced by absolute path into the project images/ dir + /// and return updated slides with relative paths. + Future> copyImagesToProject( + List slides, + String projectPath, + ) async { + final imagesDir = Directory(p.join(projectPath, 'images')); + await imagesDir.create(recursive: true); + + final updated = []; + for (final slide in slides) { + var next = slide; + final copiedImage = await _copyImageToProject(next.imagePath, imagesDir); + if (copiedImage != null) next = next.copyWith(imagePath: copiedImage); + final copiedImage2 = await _copyImageToProject( + next.imagePath2, + imagesDir, + ); + if (copiedImage2 != null) next = next.copyWith(imagePath2: copiedImage2); + updated.add(next); + } + return updated; + } + + Future> copyMediaToProject( + List slides, + String projectPath, + ) async { + final mediaDir = Directory(p.join(projectPath, 'media')); + await mediaDir.create(recursive: true); + + final updated = []; + for (final slide in slides) { + var next = slide; + if (_shouldCopy(next.videoPath)) { + final copied = await _copyToDir(next.videoPath, mediaDir); + if (copied != null) next = next.copyWith(videoPath: copied); + } + if (_shouldCopy(next.audioPath)) { + final copied = await _copyToDir(next.audioPath, mediaDir); + if (copied != null) next = next.copyWith(audioPath: copied); + } + updated.add(next); + } + return updated; + } + + bool _shouldCopy(String path) { + return path.isNotEmpty && + !path.startsWith('media/') && + !path.startsWith('images/') && + p.isAbsolute(path); + } + + Future _copyToDir(String sourcePath, Directory destDir) async { + final src = File(sourcePath); + if (!await src.exists()) return null; + final filename = p.basename(sourcePath); + final dest = File(p.join(destDir.path, filename)); + if (!await dest.exists()) { + await src.copy(dest.path); + } + return 'media/$filename'; + } + + Future _copyImageToProject( + String sourcePath, + Directory imagesDir, + ) async { + if (sourcePath.isEmpty || + sourcePath.startsWith('images/') || + !p.isAbsolute(sourcePath)) { + return null; + } + final src = File(sourcePath); + if (!await src.exists()) return null; + final filename = p.basename(sourcePath); + final dest = File(p.join(imagesDir.path, filename)); + if (!await dest.exists()) { + await src.copy(dest.path); + } + return 'images/$filename'; + } + + /// Resolve a slide image path to an absolute path for display. + String resolve(String imagePath, String? projectPath) { + if (imagePath.isEmpty) return ''; + if (p.isAbsolute(imagePath)) return imagePath; + if (projectPath != null) return p.join(projectPath, imagePath); + return imagePath; + } +} diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart new file mode 100644 index 0000000..027212c --- /dev/null +++ b/lib/services/markdown_service.dart @@ -0,0 +1,801 @@ +import 'dart:convert'; +import 'package:characters/characters.dart'; +import 'package:uuid/uuid.dart'; +import '../models/deck.dart'; +import '../models/settings.dart'; +import '../models/slide.dart'; + +const _uuid = Uuid(); + +class MarkdownService { + // ── Generation ────────────────────────────────────────────────────────────── + + String generateDeck(Deck deck) { + final buf = StringBuffer(); + buf.writeln('---'); + buf.writeln('marp: true'); + buf.writeln('theme: ${deck.theme}'); + if (deck.paginate) buf.writeln('paginate: true'); + // General presentation metadata (also picked up by Marp where applicable). + if (deck.author.isNotEmpty) { + buf.writeln('author: ${_yamlScalar(deck.author)}'); + } + if (deck.organization.isNotEmpty) { + buf.writeln('organization: ${_yamlScalar(deck.organization)}'); + } + if (deck.version.isNotEmpty) { + buf.writeln('version: ${_yamlScalar(deck.version)}'); + } + if (deck.date.isNotEmpty) { + buf.writeln('date: ${_yamlScalar(deck.date)}'); + } + if (deck.description.isNotEmpty) { + buf.writeln('description: ${_yamlScalar(deck.description)}'); + } + if (deck.keywords.isNotEmpty) { + buf.writeln('keywords: ${_yamlScalar(deck.keywords)}'); + } + if (deck.tlp != TlpLevel.none) { + buf.writeln('tlp: ${deck.tlp.key}'); + } + buf.writeln( + 'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}', + ); + buf.writeln('---'); + buf.writeln(); + + for (int i = 0; i < deck.slides.length; i++) { + if (i > 0) { + buf.writeln('---'); + buf.writeln(); + } + buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile)); + } + return buf.toString(); + } + + /// Render a string as a YAML scalar, quoting/escaping only when needed so the + /// front matter stays readable. + String _yamlScalar(String v) { + final needsQuote = + v.isEmpty || + v != v.trim() || + RegExp(r'[:#"\n]').hasMatch(v) || + RegExp(r'''^[\[\]{}>|*&!%@`,?-]''').hasMatch(v); + if (!needsQuote) return v; + final escaped = v + .replaceAll('\\', r'\\') + .replaceAll('"', r'\"') + .replaceAll('\n', r'\n'); + return '"$escaped"'; + } + + /// Inverse of [_yamlScalar] for the simple line-based front matter parser. + String _parseScalar(String raw) { + final s = raw.trim(); + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + return _unescape(s.substring(1, s.length - 1)); + } + return s; + } + + String _unescape(String s) { + final out = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (s[i] == r'\'[0] && i + 1 < s.length) { + final next = s[i + 1]; + if (next == 'n') { + out.write('\n'); + i++; + } else if (next == '"') { + out.write('"'); + i++; + } else if (next == r'\'[0]) { + out.write(r'\'); + i++; + } else { + out.write(s[i]); + } + } else { + out.write(s[i]); + } + } + return out.toString(); + } + + /// Write [rows] as a GitHub-flavoured markdown table (first row = header). + void _writeTable(StringBuffer buf, List> rows) { + if (rows.isEmpty) return; + final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); + if (colCount == 0) return; + + String cell(List row, int c) { + final v = c < row.length ? row[c] : ''; + return v + .replaceAll('\\', r'\\') + .replaceAll('|', r'\|') + .replaceAll('\n', '
'); + } + + String renderRow(List row) => + '| ${List.generate(colCount, (c) => cell(row, c)).join(' | ')} |'; + + buf.writeln(renderRow(rows.first)); + buf.writeln('| ${List.generate(colCount, (_) => '---').join(' | ')} |'); + for (var i = 1; i < rows.length; i++) { + buf.writeln(renderRow(rows[i])); + } + } + + List _splitTableRow(String line) { + var s = line.trim(); + if (s.startsWith('|')) s = s.substring(1); + if (s.endsWith('|')) s = s.substring(0, s.length - 1); + return s + .split(RegExp(r'(? _unescapeCell(c.trim())) + .toList(); + } + + String _unescapeCell(String s) { + final out = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (s[i] == r'\'[0] && i + 1 < s.length) { + final n = s[i + 1]; + if (n == '|') { + out.write('|'); + i++; + continue; + } + if (n == r'\'[0]) { + out.write(r'\'); + i++; + continue; + } + } + out.write(s[i]); + } + return out.toString().replaceAll('
', '\n'); + } + + String generateSlide(Slide slide, {ThemeProfile? themeProfile}) { + final buf = StringBuffer(); + final cssClass = slide.cssClass.isNotEmpty + ? slide.cssClass + : slide.type.marpClass; + final hasLogo = themeProfile?.logoPath?.isNotEmpty == true; + final classes = [ + if (cssClass.isNotEmpty) cssClass, + // Reserve logo space only when the logo is actually shown on this slide. + if (hasLogo && slide.showLogo) 'logo-safe', + // Mark slides that opt out of the logo so the theme can hide it. + if (hasLogo && !slide.showLogo) 'no-logo', + // Mark slides that opt out of the footer. Older presentations lack this + // token and therefore keep the existing default: footer shown. + if (!slide.showFooter) 'no-footer', + ]; + + if (classes.isNotEmpty) { + buf.writeln(''); + buf.writeln(); + } + + switch (slide.type) { + case SlideType.title: + // Background image before headings so Marp treats it as a bg directive + if (slide.imagePath.isNotEmpty) { + final sizeSpec = slide.imageSize > 0 ? '${slide.imageSize}% ' : ''; + buf.writeln('![bg ${sizeSpec}opacity:.45](${slide.imagePath})'); + _writeImageCaption(buf, slide.imageCaption); + buf.writeln(); + } + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + if (slide.subtitle.isNotEmpty) buf.writeln('## ${slide.subtitle}'); + + case SlideType.section: + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + if (slide.subtitle.isNotEmpty) { + buf.writeln(); + buf.writeln(slide.subtitle); + } + + case SlideType.bullets: + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + buf.writeln(); + for (final b in slide.bullets) { + _writeBullet(buf, b); + } + + case SlideType.twoBullets: + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + buf.writeln(); + _writeTwoBulletColumns(buf, slide.bullets, slide.bullets2); + + case SlideType.bulletsImage: + if (slide.imagePath.isNotEmpty) { + final pct = (slide.imageSize > 0 ? slide.imageSize : 40).clamp( + 20, + 70, + ); + final textScale = _splitTextScale(slide); + buf.writeln( + '', + ); + buf.writeln(); + buf.writeln( + '
', + ); + buf.writeln(); + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + buf.writeln(); + for (final b in slide.bullets) { + _writeBullet(buf, b); + } + buf.writeln(); + buf.writeln('
'); + buf.writeln(); + buf.writeln('
'); + buf.writeln(); + buf.writeln('![](${slide.imagePath})'); + _writeImageCaption(buf, slide.imageCaption); + buf.writeln(); + buf.writeln('
'); + } else { + if (slide.title.isNotEmpty) buf.writeln('# ${slide.title}'); + buf.writeln(); + for (final b in slide.bullets) { + _writeBullet(buf, b); + } + } + + case SlideType.twoImages: + final splitPct = slide.imageSize > 0 ? slide.imageSize : 50; + if (slide.imagePath.isNotEmpty) { + buf.writeln('![bg left:$splitPct%](${slide.imagePath})'); + } + if (slide.imagePath2.isNotEmpty) { + buf.writeln('![bg right:${100 - splitPct}%](${slide.imagePath2})'); + } + _writeImageCaption( + buf, + [ + slide.imageCaption, + slide.imageCaption2, + ].where((caption) => caption.trim().isNotEmpty).join(' | '), + ); + if (slide.title.isNotEmpty) { + buf.writeln(); + buf.writeln('# ${slide.title}'); + } + + case SlideType.image: + if (slide.imagePath.isNotEmpty) { + final sizeSpec = slide.imageSize > 0 ? ' ${slide.imageSize}%' : ''; + buf.writeln('![bg$sizeSpec](${slide.imagePath})'); + _writeImageCaption(buf, slide.imageCaption); + } + if (slide.title.isNotEmpty) { + buf.writeln(); + buf.writeln('# ${slide.title}'); + } + + case SlideType.video: + if (slide.title.isNotEmpty) { + buf.writeln('# ${slide.title}'); + buf.writeln(); + } + if (slide.videoPath.isNotEmpty) { + final autoplay = slide.videoAutoplay ? ' autoplay muted loop' : ''; + buf.writeln( + '', + ); + } + + case SlideType.quote: + if (slide.imagePath.isNotEmpty) { + final sizeSpec = slide.imageSize > 0 ? '${slide.imageSize}% ' : ''; + buf.writeln('![bg ${sizeSpec}opacity:.45](${slide.imagePath})'); + _writeImageCaption(buf, slide.imageCaption); + buf.writeln(); + } + if (slide.quote.isNotEmpty) buf.writeln('> ${slide.quote}'); + if (slide.quoteAuthor.isNotEmpty) { + buf.writeln(); + buf.writeln('— ${slide.quoteAuthor}'); + } + + case SlideType.table: + if (slide.title.isNotEmpty) { + buf.writeln('# ${slide.title}'); + buf.writeln(); + } + _writeTable(buf, slide.tableRows); + + case SlideType.freeMarkdown: + buf.write(slide.customMarkdown); + if (slide.customMarkdown.isNotEmpty && + !slide.customMarkdown.endsWith('\n')) { + buf.writeln(); + } + } + + if (slide.audioPath.isNotEmpty) { + final autoplay = slide.audioAutoplay ? ' autoplay' : ''; + buf.writeln(); + buf.writeln( + '', + ); + } + + if (slide.advanceDuration > 0) { + buf.writeln(); + buf.writeln( + '', + ); + } + + // Slides marked to be skipped during presenting/exporting. Persisted so the + // skip state survives save/load round-trips. + if (slide.skipped) { + buf.writeln(); + buf.writeln(''); + } + + if (slide.notes.isNotEmpty) { + buf.writeln(); + buf.writeln(''); + } + + buf.writeln(); + return buf.toString(); + } + + static void _writeBullet(StringBuffer buf, String bullet) { + int level = 0; + while (level < bullet.length && bullet[level] == '\t') { + level++; + } + final text = bullet.substring(level); + if (text.isNotEmpty) { + buf.writeln('${' ' * level}- $text'); + } + } + + static void _writeTwoBulletColumns( + StringBuffer buf, + List left, + List right, + ) { + buf.writeln(''); + buf.writeln(''); + buf.writeln( + '
', + ); + buf.writeln('
    '); + _writeHtmlBulletItems(buf, left); + buf.writeln('
'); + buf.writeln('
    '); + _writeHtmlBulletItems(buf, right); + buf.writeln('
'); + buf.writeln('
'); + } + + static String _encodeBullets(List bullets) { + return base64Url.encode(utf8.encode(jsonEncode(bullets))); + } + + static List _decodeBullets(String encoded) { + try { + final decoded = utf8.decode(base64Url.decode(encoded.trim())); + final raw = jsonDecode(decoded); + if (raw is List) return raw.map((v) => v.toString()).toList(); + } catch (_) {} + return const []; + } + + static void _writeHtmlBulletItems(StringBuffer buf, List bullets) { + for (final b in bullets) { + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = b.substring(level).trim(); + if (text.isEmpty) continue; + final style = level == 0 ? '' : ' style="margin-left:${level * 1.4}em;"'; + buf.writeln('${_escapeHtml(text)}'); + } + } + + static String _escapeHtml(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + } + + static double _splitTextScale(Slide slide) { + final bullets = slide.bullets + .map((b) => b.trimLeft()) + .where((b) => b.isNotEmpty) + .toList(); + if (bullets.isEmpty) return 1.2; + final maxChars = bullets.fold( + 0, + (max, bullet) => bullet.length > max ? bullet.length : max, + ); + final count = bullets.length; + var scale = 1.0; + if (count <= 4) { + scale = 2.35; + } else if (count <= 5) { + scale = 2.10; + } else if (count <= 7) { + scale = 1.85; + } else if (count <= 9) { + scale = 1.55; + } else if (count <= 12) { + scale = 1.30; + } + if (maxChars > 115) { + scale -= 0.16; + } else if (maxChars > 90) { + scale -= 0.08; + } + return scale.clamp(1.0, 2.45); + } + + static void _writeImageCaption(StringBuffer buf, String caption) { + final text = caption.trim(); + if (text.isEmpty) return; + buf.writeln( + '
${const HtmlEscape().convert(text)}
', + ); + } + + static String _decodeImageCaption(String line) { + return line + .replaceFirst('
', '') + .replaceFirst('
', '') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('&', '&') + .trim(); + } + + // ── Parsing ───────────────────────────────────────────────────────────────── + + /// Best-effort parse of Marp markdown into a Deck. Returns null if the + /// content cannot be parsed at all. + Deck? parseDeck(String markdown, {String? filePath}) { + try { + return _doParse(markdown, filePath: filePath); + } catch (_) { + return null; + } + } + + Deck _doParse(String markdown, {String? filePath}) { + String content = markdown; + String theme = 'ocideck'; + bool paginate = true; + ThemeProfile themeProfile = const ThemeProfile(); + String author = ''; + String organization = ''; + String version = ''; + String date = ''; + String description = ''; + String keywords = ''; + TlpLevel tlp = TlpLevel.none; + + // Strip front matter + if (content.startsWith('---\n')) { + final end = content.indexOf('\n---\n', 4); + if (end != -1) { + final frontMatter = content.substring(4, end); + for (final line in frontMatter.split('\n')) { + if (line.startsWith('theme:')) { + theme = line.substring(6).trim(); + } else if (line.startsWith('paginate:')) { + paginate = line.substring(9).trim() == 'true'; + } else if (line.startsWith('author:')) { + author = _parseScalar(line.substring(7)); + } else if (line.startsWith('organization:')) { + organization = _parseScalar(line.substring(13)); + } else if (line.startsWith('version:')) { + version = _parseScalar(line.substring(8)); + } else if (line.startsWith('date:')) { + date = _parseScalar(line.substring(5)); + } else if (line.startsWith('description:')) { + description = _parseScalar(line.substring(12)); + } else if (line.startsWith('keywords:')) { + keywords = _parseScalar(line.substring(9)); + } else if (line.startsWith('tlp:')) { + tlp = TlpLevelX.fromKey(line.substring(4)); + } else if (line.startsWith('ocideck_style_profile:')) { + final encoded = line.substring(22).trim(); + final decoded = utf8.decode(base64Url.decode(encoded)); + themeProfile = ThemeProfile.fromJson( + Map.from(jsonDecode(decoded) as Map), + ); + } + } + content = content.substring(end + 5).trim(); + } + } + + final blocks = content.split(RegExp(r'\n---\n')); + final slides = []; + for (final block in blocks) { + final slide = _parseBlock(block.trim()); + if (slide != null) slides.add(slide); + } + + String title = 'Presentatie'; + if (slides.isNotEmpty && slides.first.title.isNotEmpty) { + title = slides.first.title; + } + + String? projectPath; + if (filePath != null) { + final sep = filePath.contains('/') ? '/' : '\\'; + final parts = filePath.split(sep); + if (parts.length > 1) { + projectPath = parts.sublist(0, parts.length - 1).join(sep); + } + } + + return Deck( + title: title, + theme: theme, + paginate: paginate, + slides: slides.isEmpty ? [Slide.create(SlideType.title)] : slides, + projectPath: projectPath, + themeProfile: themeProfile, + author: author, + organization: organization, + version: version, + date: date, + description: description, + keywords: keywords, + tlp: tlp, + ); + } + + Slide? _parseBlock(String block) { + if (block.isEmpty) return null; + + String cssClass = ''; + String remaining = block; + + final classMatch = RegExp( + r'', + ).firstMatch(block); + if (classMatch != null) { + cssClass = classMatch.group(1) ?? ''; + remaining = block.replaceFirst(classMatch.group(0)!, '').trim(); + } + + // Extract presenter notes and advance timing from HTML comments + final notesBuffer = StringBuffer(); + double advanceDuration = 0; + bool skipped = false; + final bullets = []; + var bullets2 = []; + // bulletsImage slides store their panel width in ``; capture it before the comment is stripped. + int styleImageWidth = 0; + remaining = remaining.replaceAllMapped( + RegExp(r'', multiLine: true), + (m) { + final content = m.group(1)!.trim(); + if (content.startsWith('advance:')) { + advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0; + } else if (content == 'skip') { + skipped = true; + } else if (content.startsWith('_style:')) { + final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content); + if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0; + } else if (content.startsWith('ocideck_two_bullets_left:')) { + bullets + ..clear() + ..addAll(_decodeBullets(content.substring(25))); + } else if (content.startsWith('ocideck_two_bullets_right:')) { + bullets2 = _decodeBullets(content.substring(26)); + } else if (!content.startsWith('_')) { + notesBuffer.write(notesBuffer.isEmpty ? content : '\n$content'); + } + return ''; + }, + ).trim(); + final notes = notesBuffer.toString().trim(); + + final lines = remaining.split('\n'); + String h1 = ''; + String h2 = ''; + String paragraph = ''; + String imagePath = ''; + String imagePath2 = ''; + String imageCaption = ''; + String imageCaption2 = ''; + int imageSize = 0; + String videoPath = ''; + bool videoAutoplay = false; + String audioPath = ''; + bool audioAutoplay = false; + String quote = ''; + String quoteAuthor = ''; + final tableLines = []; + + for (final line in lines) { + final t = line.trim(); + if (t.startsWith('|')) { + tableLines.add(t); + } else if (t.startsWith('# ')) { + h1 = t.substring(2); + } else if (t.startsWith('## ')) { + h2 = t.substring(3); + } else if (t.startsWith('- ')) { + // Count leading spaces (2 per level) + int spaces = 0; + for (final ch in line.characters) { + if (ch == ' ') { + spaces++; + } else { + break; + } + } + final level = spaces ~/ 2; + bullets.add('\t' * level + t.substring(2)); + } else if (t.startsWith('> ')) { + quote = t.substring(2); + } else if (t.startsWith('— ')) { + quoteAuthor = t.substring(2); + } else if (RegExp(r'!\[bg').hasMatch(t)) { + final m = RegExp(r'!\[bg[^\]]*\]\(([^)]+)\)').firstMatch(t); + if (m != null) { + if (imagePath.isEmpty) { + imagePath = m.group(1) ?? ''; + } else { + imagePath2 = m.group(1) ?? ''; // tweede afbeelding + } + } + // Parse size: ![bg 50%](...) or ![bg left:42%](...) + final sizeMatch = RegExp(r'!\[bg[^\]]*?(\d+)%[^\]]*\]').firstMatch(t); + if (sizeMatch != null && imageSize == 0) { + imageSize = int.tryParse(sizeMatch.group(1)!) ?? 0; + } + } else if (cssClass.split(RegExp(r'\s+')).contains('split') && + RegExp(r'!\[[^\]]*\]\(([^)]+)\)').hasMatch(t)) { + // Plain markdown image, e.g. the `![](path)` used inside a + // bulletsImage `split-image` panel. Restricted to split slides so a + // plain image inside free markdown is not mistaken for an image slide. + final m = RegExp(r'!\[[^\]]*\]\(([^)]+)\)').firstMatch(t); + if (m != null) { + if (imagePath.isEmpty) { + imagePath = m.group(1) ?? ''; + } else { + imagePath2 = m.group(1) ?? ''; + } + } + } else if (t.startsWith('
')) { + final captionParts = _decodeImageCaption(t).split(' | '); + imageCaption = captionParts.isNotEmpty ? captionParts.first : ''; + imageCaption2 = captionParts.length > 1 + ? captionParts.sublist(1).join(' | ') + : ''; + } else if (t.startsWith(' 0) imageSize = styleImageWidth; + + final tableRows = >[]; + for (final line in tableLines) { + final cells = _splitTableRow(line); + // Skip the GFM separator row (e.g. | --- | :---: |). + if (cells.isNotEmpty && + cells.every((c) => RegExp(r'^:?-+:?$').hasMatch(c.trim()))) { + continue; + } + tableRows.add(cells); + } + + SlideType type; + switch (cssClass) { + case final c when c.split(RegExp(r'\s+')).contains('title'): + type = SlideType.title; + case final c when c.split(RegExp(r'\s+')).contains('section'): + type = SlideType.section; + case final c when c.split(RegExp(r'\s+')).contains('two-bullets'): + type = SlideType.twoBullets; + case final c when c.split(RegExp(r'\s+')).contains('split'): + type = SlideType.bulletsImage; + case final c when c.split(RegExp(r'\s+')).contains('quote'): + type = SlideType.quote; + case final c when c.split(RegExp(r'\s+')).contains('video'): + type = SlideType.video; + case final c when c.split(RegExp(r'\s+')).contains('table'): + type = SlideType.table; + default: + if (quote.isNotEmpty) { + type = SlideType.quote; + } else if (imagePath.isNotEmpty && imagePath2.isNotEmpty) { + type = SlideType.twoImages; + } else if (bullets.isNotEmpty && imagePath.isNotEmpty) { + type = SlideType.bulletsImage; + } else if (bullets.isNotEmpty) { + type = SlideType.bullets; + } else if (videoPath.isNotEmpty) { + type = SlideType.video; + } else if (imagePath.isNotEmpty) { + type = SlideType.image; + } else if (tableRows.isNotEmpty && + bullets.isEmpty && + h2.isEmpty && + paragraph.isEmpty) { + type = SlideType.table; + } else if (h1.isEmpty && h2.isEmpty && bullets.isEmpty) { + type = SlideType.freeMarkdown; + } else { + type = SlideType.bullets; + } + } + + final classTokens = cssClass.split(RegExp(r'\s+')); + final showLogo = !classTokens.contains('no-logo'); + final showFooter = !classTokens.contains('no-footer'); + + final effectiveClass = classTokens + .where( + (c) => + c.isNotEmpty && + c != type.marpClass && + c != 'logo-safe' && + c != 'no-logo' && + c != 'no-footer', + ) + .join(' '); + + return Slide( + id: _uuid.v4(), + type: type, + title: h1, + subtitle: type == SlideType.section ? paragraph : h2, + bullets: bullets, + bullets2: bullets2, + imagePath: imagePath, + imagePath2: imagePath2, + imageCaption: imageCaption, + imageCaption2: imageCaption2, + imageSize: imageSize, + videoPath: videoPath, + videoAutoplay: videoAutoplay, + audioPath: audioPath, + audioAutoplay: audioAutoplay, + quote: quote, + quoteAuthor: quoteAuthor, + customMarkdown: type == SlideType.freeMarkdown ? remaining : '', + cssClass: effectiveClass, + notes: notes, + advanceDuration: advanceDuration, + showLogo: showLogo, + showFooter: showFooter, + skipped: skipped, + tableRows: type == SlideType.table ? tableRows : const [], + ); + } +} diff --git a/lib/services/recovery_service.dart b/lib/services/recovery_service.dart new file mode 100644 index 0000000..af5fceb --- /dev/null +++ b/lib/services/recovery_service.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck. +class RecoverySnapshot { + final String id; + final DateTime savedAt; + final String? filePath; + final String label; + final String markdown; + + const RecoverySnapshot({ + required this.id, + required this.savedAt, + required this.filePath, + required this.label, + required this.markdown, + }); + + Map toJson() => { + 'id': id, + 'savedAt': savedAt.toIso8601String(), + 'filePath': filePath, + 'label': label, + 'markdown': markdown, + }; + + static RecoverySnapshot fromJson(Map json) { + return RecoverySnapshot( + id: json['id'] as String, + savedAt: + DateTime.tryParse(json['savedAt'] as String? ?? '') ?? DateTime.now(), + filePath: json['filePath'] as String?, + label: (json['label'] as String?) ?? 'Presentatie', + markdown: (json['markdown'] as String?) ?? '', + ); + } +} + +/// Schrijft en leest autosave-herstelbestanden. Elk dirty tabblad krijgt een +/// eigen `.json` in de herstelmap; bij opslaan/sluiten wordt het gewist, +/// zodat resterende bestanden bij de volgende start op niet-opgeslagen werk +/// (een crash) wijzen. +class RecoveryService { + /// Tests injecteren een tijdelijke map; in productie wordt de app-support-map + /// gebruikt (path_provider). + final Future Function() _resolveDir; + + RecoveryService({Directory? baseDir}) + : _resolveDir = baseDir != null ? (() async => baseDir) : _defaultDir; + + static Future _defaultDir() async { + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, 'recovery')); + } + + Future _dir() async { + final dir = await _resolveDir(); + if (!dir.existsSync()) await dir.create(recursive: true); + return dir; + } + + File _file(Directory dir, String id) => File(p.join(dir.path, '$id.json')); + + Future save(RecoverySnapshot snapshot) async { + try { + final dir = await _dir(); + await _file( + dir, + snapshot.id, + ).writeAsString(jsonEncode(snapshot.toJson()), flush: true); + } catch (_) { + // Autosave mag nooit de app verstoren. + } + } + + Future discard(String id) async { + try { + final file = _file(await _dir(), id); + if (file.existsSync()) await file.delete(); + } catch (_) {} + } + + Future> loadAll() async { + try { + final dir = await _dir(); + final out = []; + for (final entry in dir.listSync()) { + if (entry is File && entry.path.endsWith('.json')) { + try { + final data = jsonDecode(await entry.readAsString()); + out.add(RecoverySnapshot.fromJson(Map.from(data))); + } catch (_) {} + } + } + out.sort((a, b) => b.savedAt.compareTo(a.savedAt)); + return out; + } catch (_) { + return const []; + } + } + + Future clearAll() async { + try { + final dir = await _dir(); + for (final entry in dir.listSync()) { + if (entry is File && entry.path.endsWith('.json')) { + try { + await entry.delete(); + } catch (_) {} + } + } + } catch (_) {} + } +} + +final recoveryServiceProvider = Provider( + (_) => RecoveryService(), +); diff --git a/lib/services/slide_rasterizer.dart b/lib/services/slide_rasterizer.dart new file mode 100644 index 0000000..3752cb7 --- /dev/null +++ b/lib/services/slide_rasterizer.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:path/path.dart' as p; + +import '../models/deck.dart'; +import '../models/settings.dart'; +import '../models/slide.dart'; +import '../widgets/slides/slide_preview.dart'; + +/// Renders the exact on-screen slide previews to PNG images so exports look +/// identical to what the user sees (WYSIWYG). +/// +/// Each slide is mounted in an [Overlay] inside a keyed [RepaintBoundary] that +/// is positioned off-screen. A RepaintBoundary records its subtree into its +/// own layer regardless of where it sits on screen, so `toImage` captures the +/// full slide even though nothing is visible to the user. +class SlideRasterizer { + /// Logical size used while laying out a slide. The real output resolution is + /// [logicalSize] * [pixelRatio]; because the preview is fully proportional + /// the logical size only affects sampling quality. + static const Size logicalSize = Size(1280, 720); + + /// Render [slides] to PNG bytes at [targetWidth] x (targetWidth * 9/16). + static Future> rasterize({ + required BuildContext context, + required List slides, + required ThemeProfile themeProfile, + required String? projectPath, + TlpLevel tlp = TlpLevel.none, + int targetWidth = 1920, + void Function(int done, int total)? onProgress, + }) async { + final overlay = Overlay.of(context, rootOverlay: true); + final pixelRatio = targetWidth / logicalSize.width; + + await _preloadFont(themeProfile.fontFamily); + if (!context.mounted) return const []; + + // The global image cache has a modest default budget (~100 MB / 1000 + // entries). A deck with several full-resolution photos blows past that, so + // images decoded for earlier slides get evicted before later slides are + // captured — which made every image vanish after the first handful of + // slides. Raise the budget for the duration of the export, then restore it. + final imageCache = PaintingBinding.instance.imageCache; + final prevMaxSize = imageCache.maximumSize; + final prevMaxBytes = imageCache.maximumSizeBytes; + imageCache.maximumSize = (slides.length * 4) + 64; + imageCache.maximumSizeBytes = 1024 * 1024 * 1024; // 1 GB + + final logo = _resolve(themeProfile.logoPath ?? '', projectPath); + + final results = []; + try { + for (var i = 0; i < slides.length; i++) { + // Warm this slide's images immediately before capturing it. Doing it + // per slide (instead of once up front) guarantees the bitmap is decoded + // and resident in the cache at capture time, no matter how many images + // the whole deck contains. + if (!context.mounted) break; + await _precachePaths(context, [ + _resolve(slides[i].imagePath, projectPath), + _resolve(slides[i].imagePath2, projectPath), + logo, + ]); + if (!context.mounted) break; + + final key = GlobalKey(); + final entry = OverlayEntry( + builder: (_) => Positioned( + left: -logicalSize.width - 100, + top: -logicalSize.height - 100, + child: RepaintBoundary( + key: key, + child: SizedBox( + width: logicalSize.width, + height: logicalSize.height, + child: SlidePreviewWidget( + slide: slides[i], + projectPath: projectPath, + themeProfile: themeProfile, + slideNumber: i + 1, + slideCount: slides.length, + tlp: tlp, + ), + ), + ), + ), + ); + + overlay.insert(entry); + try { + final png = await _capture(key, pixelRatio); + results.add(png); + } finally { + entry.remove(); + } + onProgress?.call(i + 1, slides.length); + } + } finally { + imageCache.maximumSize = prevMaxSize; + imageCache.maximumSizeBytes = prevMaxBytes; + } + return results; + } + + static Future _capture(GlobalKey key, double pixelRatio) async { + // Allow build + layout + paint to settle before capturing. Wait for a + // couple of frames first so LayoutBuilder-driven subtrees (e.g. zoomed + // images) have a chance to lay out and paint their decoded bitmap. + await WidgetsBinding.instance.endOfFrame; + await WidgetsBinding.instance.endOfFrame; + RenderRepaintBoundary? boundary; + for (var attempt = 0; attempt < 30; attempt++) { + final obj = key.currentContext?.findRenderObject(); + if (obj is RenderRepaintBoundary && !obj.debugNeedsPaint) { + boundary = obj; + break; + } + await Future.delayed(const Duration(milliseconds: 16)); + } + boundary ??= + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); + try { + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData!.buffer.asUint8List(); + } finally { + image.dispose(); + } + } + + static Future _preloadFont(String fontFamily) async { + // Only the bundled Google font needs awaiting; system fonts resolve + // synchronously. + if (fontFamily == 'EB Garamond') { + GoogleFonts.ebGaramond(); + await GoogleFonts.pendingFonts(); + } + } + + /// Decode and cache the given (already resolved) image paths, awaiting all of + /// them. Nulls and duplicates are ignored; decode errors are swallowed so a + /// single missing file never aborts the whole export. + static Future _precachePaths( + BuildContext context, + List paths, + ) async { + final unique = paths.whereType().toSet(); + final futures = >[]; + for (final path in unique) { + if (!context.mounted) return; + futures.add( + precacheImage(FileImage(File(path)), context, onError: (_, _) {}), + ); + } + await Future.wait(futures); + } + + static String? _resolve(String imagePath, String? projectPath) { + if (imagePath.isEmpty) return null; + if (p.isAbsolute(imagePath)) return imagePath; + if (projectPath != null) return p.join(projectPath, imagePath); + return imagePath; + } +} diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart new file mode 100644 index 0000000..3333454 --- /dev/null +++ b/lib/state/deck_provider.dart @@ -0,0 +1,514 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import '../models/deck.dart'; +import '../models/settings.dart'; +import '../models/slide.dart'; +import '../services/file_service.dart'; +import '../services/image_service.dart'; +import '../services/markdown_service.dart'; +import 'settings_provider.dart'; + +// ── Service providers ──────────────────────────────────────────────────────── + +final markdownServiceProvider = Provider( + (_) => MarkdownService(), +); +final imageServiceProvider = Provider((_) => ImageService()); +final fileServiceProvider = Provider((ref) { + return FileService( + ref.read(markdownServiceProvider), + ref.read(imageServiceProvider), + () => ref.read(settingsProvider).themeProfile, + ); +}); + +// ── Deck state ─────────────────────────────────────────────────────────────── + +class DeckState { + final Deck? deck; + final bool isDirty; + final String? filePath; + final String? error; + + /// Of er een ongedaan-maken- resp. opnieuw-uitvoeren-stap beschikbaar is. + /// Onderdeel van de state zodat de toolbarknoppen vanzelf mee-enabelen. + final bool canUndo; + final bool canRedo; + + /// Telt alleen op bij undo/redo. De editor gebruikt dit om zijn + /// tekstvelden te verversen wanneer een wijziging op dezelfde slide wordt + /// teruggedraaid (de velden synchroniseren anders alleen op slide-id). + final int revision; + + const DeckState({ + this.deck, + this.isDirty = false, + this.filePath, + this.error, + this.canUndo = false, + this.canRedo = false, + this.revision = 0, + }); + + bool get hasUnsavedChanges => isDirty; + bool get isOpen => deck != null; + + DeckState copyWith({ + Deck? deck, + bool? isDirty, + String? filePath, + String? error, + bool? canUndo, + bool? canRedo, + int? revision, + bool clearError = false, + bool clearFilePath = false, + }) { + return DeckState( + deck: deck ?? this.deck, + isDirty: isDirty ?? this.isDirty, + filePath: clearFilePath ? null : (filePath ?? this.filePath), + error: clearError ? null : (error ?? this.error), + canUndo: canUndo ?? this.canUndo, + canRedo: canRedo ?? this.canRedo, + revision: revision ?? this.revision, + ); + } +} + +// ── DeckNotifier ───────────────────────────────────────────────────────────── + +class DeckNotifier extends StateNotifier { + final MarkdownService _md; + final FileService _file; + + /// Snapshots van eerdere/latere deck-versies voor ongedaan maken/opnieuw. + /// Decks zijn immutable (copyWith), dus dit zijn goedkope referenties. + final List _undoStack = []; + final List _redoStack = []; + static const _maxHistory = 80; + + /// Snelle, opeenvolgende bewerkingen (zoals typen) worden samengevoegd tot + /// één ongedaan-maken-stap zolang ze dezelfde [_lastCoalesceKey] delen en + /// binnen dit tijdvenster vallen. + static const _coalesceWindow = Duration(milliseconds: 700); + DateTime? _lastMutationAt; + String? _lastCoalesceKey; + + DeckNotifier(this._md, this._file) : super(const DeckState()); + + DeckState get currentState => state; + + bool get canUndo => _undoStack.isNotEmpty; + bool get canRedo => _redoStack.isNotEmpty; + + void _clearHistory() { + _undoStack.clear(); + _redoStack.clear(); + _lastMutationAt = null; + _lastCoalesceKey = null; + } + + void newDeck(String title, {String theme = 'ocideck'}) { + final deck = Deck( + title: title, + theme: theme, + themeProfile: _file.currentThemeProfile, + slides: [Slide.create(SlideType.title).copyWith(title: title)], + ); + _clearHistory(); + state = DeckState(deck: deck, isDirty: true); + } + + /// Load a deck that was already parsed (used by the tab manager). + void loadDeck(Deck deck, {String? filePath}) { + _clearHistory(); + state = DeckState(deck: deck, filePath: filePath, isDirty: false); + } + + Future openDeck({String? initialDirectory}) async { + final path = await _file.pickMarkdownFile( + initialDirectory: initialDirectory, + ); + if (path == null) return; + final deck = await _file.openDeck(path); + if (deck == null) { + state = state.copyWith(error: 'Kon presentatie niet openen:\n$path'); + return; + } + _clearHistory(); + state = DeckState(deck: deck, filePath: path, isDirty: false); + } + + Future save({String? initialDirectory}) async { + if (state.filePath != null) { + return _saveToPath(state.filePath!); + } else { + return saveAs(initialDirectory: initialDirectory); + } + } + + Future saveAs({String? initialDirectory}) async { + final deck = state.deck; + if (deck == null) return false; + final path = await _file.saveDeckAs( + deck, + initialDirectory: initialDirectory, + ); + if (path != null) { + final savedDeck = await _file.openDeck(path) ?? deck; + state = state.copyWith(deck: savedDeck, filePath: path, isDirty: false); + return true; + } + return false; + } + + Future _saveToPath(String path) async { + final deck = state.deck; + if (deck == null) return false; + final savedDeck = await _file.saveDeck(deck, path); + state = state.copyWith(deck: savedDeck, isDirty: false); + return true; + } + + void closeDeck() { + _clearHistory(); + state = const DeckState(); + } + + // ── Slide operations ─────────────────────────────────────────────────────── + + void addSlide(SlideType type, {int? afterIndex}) { + final deck = state.deck; + if (deck == null) return; + final slides = List.from(deck.slides); + final at = afterIndex != null ? afterIndex + 1 : slides.length; + slides.insert(at, Slide.create(type)); + _mutate(deck.copyWith(slides: slides)); + } + + void removeSlide(int index) { + final deck = state.deck; + if (deck == null || deck.slides.length <= 1) return; + final slides = List.from(deck.slides)..removeAt(index); + _mutate(deck.copyWith(slides: slides)); + } + + /// Verwijder meerdere slides tegelijk (bulk-actie). Houdt altijd minstens één + /// slide over. + void removeSlides(Set indices) { + final deck = state.deck; + if (deck == null || indices.isEmpty) return; + final keep = [ + for (var i = 0; i < deck.slides.length; i++) + if (!indices.contains(i)) deck.slides[i], + ]; + if (keep.isEmpty || keep.length == deck.slides.length) return; + _mutate(deck.copyWith(slides: keep)); + } + + /// Zet de "overslaan"-status van meerdere slides ineens (bulk-actie). + void setSkippedForSlides(Set indices, bool skipped) { + final deck = state.deck; + if (deck == null || indices.isEmpty) return; + final changed = indices.any( + (i) => + i >= 0 && i < deck.slides.length && deck.slides[i].skipped != skipped, + ); + if (!changed) return; + final slides = [ + for (var i = 0; i < deck.slides.length; i++) + indices.contains(i) + ? deck.slides[i].copyWith(skipped: skipped) + : deck.slides[i], + ]; + _mutate(deck.copyWith(slides: slides)); + } + + /// Insert [newSlides] after [afterIndex] (or at the end when null). + /// Each slide is duplicated so it gets a fresh id and is fully detached + /// from its source presentation. Returns the index of the first inserted + /// slide, or -1 when nothing was inserted. + int insertSlides(List newSlides, {int? afterIndex}) { + final deck = state.deck; + if (deck == null || newSlides.isEmpty) return -1; + final slides = List.from(deck.slides); + final at = afterIndex != null ? afterIndex + 1 : slides.length; + final clamped = at.clamp(0, slides.length); + slides.insertAll(clamped, newSlides.map(Slide.duplicate)); + _mutate(deck.copyWith(slides: slides)); + return clamped; + } + + void duplicateSlide(int index) { + final deck = state.deck; + if (deck == null) return; + final slides = List.from(deck.slides); + slides.insert(index + 1, Slide.duplicate(slides[index])); + _mutate(deck.copyWith(slides: slides)); + } + + // newIndex from onReorderItem is pre-adjusted (no -1 needed) + void reorderSlides(int oldIndex, int newIndex) { + final deck = state.deck; + if (deck == null) return; + final slides = List.from(deck.slides); + final slide = slides.removeAt(oldIndex); + slides.insert(newIndex, slide); + _mutate(deck.copyWith(slides: slides)); + } + + void updateSlide(int index, Slide updated) { + final deck = state.deck; + if (deck == null) return; + final slides = List.from(deck.slides); + slides[index] = updated; + // Snel typen op dezelfde slide telt als één ongedaan-maken-stap. + _mutate(deck.copyWith(slides: slides), coalesceKey: 'slide:$index'); + } + + /// Zet de "overslaan"-status van een slide aan/uit. Overgeslagen slides + /// worden weggelaten bij presenteren en exporteren. + void toggleSkip(int index) { + final deck = state.deck; + if (deck == null || index < 0 || index >= deck.slides.length) return; + final slides = List.from(deck.slides); + slides[index] = slides[index].copyWith(skipped: !slides[index].skipped); + _mutate(deck.copyWith(slides: slides)); + } + + /// Hoeveel slides momenteel overgeslagen worden. + int get skippedCount => + state.deck?.slides.where((s) => s.skipped).length ?? 0; + + /// Zet in één keer alle "overslaan"-markeringen uit (bijv. nadat je de + /// presentatie hebt gegeven). No-op wanneer er niets overgeslagen wordt. + void clearAllSkips() { + final deck = state.deck; + if (deck == null || !deck.slides.any((s) => s.skipped)) return; + final slides = [ + for (final s in deck.slides) s.skipped ? s.copyWith(skipped: false) : s, + ]; + _mutate(deck.copyWith(slides: slides)); + } + + // ── Zoeken & vervangen ───────────────────────────────────────────────────── + + /// Tel hoe vaak [query] in alle tekstvelden van de presentatie voorkomt. + int countMatches(String query, {bool caseSensitive = false}) { + final deck = state.deck; + if (deck == null || query.isEmpty) return 0; + final pattern = _searchPattern(query, caseSensitive); + var total = 0; + for (final slide in deck.slides) { + for (final field in _searchableFields(slide)) { + total += pattern.allMatches(field).length; + } + } + return total; + } + + /// Vervang alle voorkomens van [query] door [replacement] in elke slide. + /// Geeft het aantal vervangingen terug; één ongedaan-maken-stap. + int replaceAll( + String query, + String replacement, { + bool caseSensitive = false, + }) { + final deck = state.deck; + if (deck == null || query.isEmpty) return 0; + final pattern = _searchPattern(query, caseSensitive); + + var total = 0; + String sub(String s) { + if (s.isEmpty) return s; + total += pattern.allMatches(s).length; + return s.replaceAll(pattern, replacement); + } + + final slides = [ + for (final s in deck.slides) + s.copyWith( + title: sub(s.title), + subtitle: sub(s.subtitle), + bullets: [for (final b in s.bullets) sub(b)], + quote: sub(s.quote), + quoteAuthor: sub(s.quoteAuthor), + customMarkdown: sub(s.customMarkdown), + imageCaption: sub(s.imageCaption), + imageCaption2: sub(s.imageCaption2), + notes: sub(s.notes), + tableRows: [ + for (final row in s.tableRows) [for (final c in row) sub(c)], + ], + ), + ]; + + if (total > 0) _mutate(deck.copyWith(slides: slides)); + return total; + } + + /// Alle doorzoekbare tekstvelden van een slide. + Iterable _searchableFields(Slide s) => [ + s.title, + s.subtitle, + ...s.bullets, + s.quote, + s.quoteAuthor, + s.customMarkdown, + s.imageCaption, + s.imageCaption2, + s.notes, + for (final row in s.tableRows) ...row, + ]; + + /// Zoekpatroon dat letterlijke tekst matcht (hoofdletter-(on)gevoelig). + Pattern _searchPattern(String query, bool caseSensitive) { + return caseSensitive + ? query + : RegExp(RegExp.escape(query), caseSensitive: false); + } + + void updateMeta({String? title, String? theme, bool? paginate}) { + final deck = state.deck; + if (deck == null) return; + _mutate( + deck.copyWith(title: title, theme: theme, paginate: paginate), + coalesceKey: 'meta', + ); + } + + void updateInfo({ + String? author, + String? organization, + String? version, + String? date, + String? description, + String? keywords, + TlpLevel? tlp, + }) { + final deck = state.deck; + if (deck == null) return; + _mutate( + deck.copyWith( + author: author, + organization: organization, + version: version, + date: date, + description: description, + keywords: keywords, + tlp: tlp, + ), + coalesceKey: 'info', + ); + } + + void updateThemeProfile(ThemeProfile profile) { + final deck = state.deck; + if (deck == null) return; + _mutate(deck.copyWith(themeProfile: profile)); + } + + // ── Markdown mode ────────────────────────────────────────────────────────── + + String generateMarkdown() { + final deck = state.deck; + return deck != null ? _md.generateDeck(deck) : ''; + } + + /// Returns false if parsing fails (content is preserved). + bool applyMarkdown(String markdown) { + final deck = _md.parseDeck(markdown, filePath: state.filePath); + if (deck == null) return false; + _mutate(deck); // discrete stap → ook ongedaan te maken + return true; + } + + void clearError() => state = state.copyWith(clearError: true); + + /// Markeer de huidige deck als gewijzigd (gebruikt bij herstel na een crash: + /// het teruggehaalde werk is nog niet opgeslagen). + void markDirty() { + if (state.deck != null && !state.isDirty) { + state = state.copyWith(isDirty: true); + } + } + + // ── Ongedaan maken / opnieuw uitvoeren ─────────────────────────────────── + + /// Draai de laatste wijziging terug. + void undo() { + final deck = state.deck; + if (deck == null || _undoStack.isEmpty) return; + _redoStack.add(deck); + final previous = _undoStack.removeLast(); + // Volgende bewerking begint een verse ongedaan-maken-stap. + _lastCoalesceKey = null; + _lastMutationAt = null; + state = state.copyWith( + deck: previous, + isDirty: true, + canUndo: _undoStack.isNotEmpty, + canRedo: _redoStack.isNotEmpty, + revision: state.revision + 1, + ); + } + + /// Voer een teruggedraaide wijziging opnieuw uit. + void redo() { + final deck = state.deck; + if (deck == null || _redoStack.isEmpty) return; + _undoStack.add(deck); + final next = _redoStack.removeLast(); + _lastCoalesceKey = null; + _lastMutationAt = null; + state = state.copyWith( + deck: next, + isDirty: true, + canUndo: _undoStack.isNotEmpty, + canRedo: _redoStack.isNotEmpty, + revision: state.revision + 1, + ); + } + + /// Pas een nieuwe deck-versie toe en bewaar de vorige in de ongedaan-stapel. + /// + /// Wanneer [coalesceKey] gelijk is aan die van de vorige bewerking en deze + /// binnen [_coalesceWindow] valt, wordt geen nieuwe ongedaan-stap aangemaakt + /// (zodat typen niet per teken een aparte stap oplevert). Een [coalesceKey] + /// van null markeert een losse, discrete stap. + void _mutate(Deck deck, {String? coalesceKey}) { + final previous = state.deck; + if (previous != null) { + final now = DateTime.now(); + final canCoalesce = + coalesceKey != null && + coalesceKey == _lastCoalesceKey && + _lastMutationAt != null && + now.difference(_lastMutationAt!) < _coalesceWindow && + _undoStack.isNotEmpty; + if (!canCoalesce) { + _undoStack.add(previous); + if (_undoStack.length > _maxHistory) _undoStack.removeAt(0); + } + _lastMutationAt = now; + _lastCoalesceKey = coalesceKey; + } + _redoStack.clear(); + state = state.copyWith( + deck: deck, + isDirty: true, + canUndo: _undoStack.isNotEmpty, + canRedo: false, + ); + } +} + +// ── Provider ───────────────────────────────────────────────────────────────── + +final deckProvider = StateNotifierProvider((ref) { + return DeckNotifier( + ref.read(markdownServiceProvider), + ref.read(fileServiceProvider), + ); +}); diff --git a/lib/state/editor_provider.dart b/lib/state/editor_provider.dart new file mode 100644 index 0000000..522b2cf --- /dev/null +++ b/lib/state/editor_provider.dart @@ -0,0 +1,122 @@ +import 'package:flutter_riverpod/legacy.dart'; + +enum EditorMode { visual, markdown } + +class EditorState { + /// The active slide (shown in the editor/preview). Always part of [selection]. + final int selectedIndex; + + /// All currently selected slide indices (for bulk actions). Never empty. + final Set selection; + final EditorMode mode; + final String markdownBuffer; + final bool parseError; + + const EditorState({ + this.selectedIndex = 0, + this.selection = const {0}, + this.mode = EditorMode.visual, + this.markdownBuffer = '', + this.parseError = false, + }); + + bool get hasMultiSelection => selection.length > 1; + + EditorState copyWith({ + int? selectedIndex, + Set? selection, + EditorMode? mode, + String? markdownBuffer, + bool? parseError, + }) { + return EditorState( + selectedIndex: selectedIndex ?? this.selectedIndex, + selection: selection ?? this.selection, + mode: mode ?? this.mode, + markdownBuffer: markdownBuffer ?? this.markdownBuffer, + parseError: parseError ?? this.parseError, + ); + } +} + +class EditorNotifier extends StateNotifier { + EditorNotifier() : super(const EditorState()); + + EditorState get currentState => state; + + /// Single-select [index] (clears any multi-selection). + void select(int index) { + if (index == state.selectedIndex && state.selection.length == 1) return; + state = state.copyWith( + selectedIndex: index, + selection: {index}, + parseError: false, + ); + } + + /// Ctrl/Cmd-click: voeg [index] toe of haal 'm uit de selectie. + void toggleSelect(int index) { + final next = Set.from(state.selection); + if (next.contains(index) && next.length > 1) { + next.remove(index); + final active = index == state.selectedIndex + ? next.reduce((a, b) => a < b ? a : b) + : state.selectedIndex; + state = state.copyWith(selection: next, selectedIndex: active); + } else { + next.add(index); + state = state.copyWith(selection: next, selectedIndex: index); + } + } + + /// Selecteer alle [count] slides (Ctrl/Cmd+A). + void selectAll(int count) { + if (count <= 0) return; + state = state.copyWith( + selection: {for (var i = 0; i < count; i++) i}, + selectedIndex: state.selectedIndex.clamp(0, count - 1), + ); + } + + /// Shift-click: selecteer het bereik van de actieve slide tot [index]. + void selectRange(int index) { + final anchor = state.selectedIndex; + final lo = anchor < index ? anchor : index; + final hi = anchor < index ? index : anchor; + state = state.copyWith( + selection: {for (var i = lo; i <= hi; i++) i}, + selectedIndex: index, + ); + } + + void setMode(EditorMode mode, {String? initialMarkdown}) { + state = state.copyWith( + mode: mode, + markdownBuffer: initialMarkdown ?? state.markdownBuffer, + parseError: false, + ); + } + + void updateMarkdown(String content) { + state = state.copyWith(markdownBuffer: content); + } + + void setParseError(bool value) { + state = state.copyWith(parseError: value); + } + + /// Clamp/normaliseer de selectie nadat slides zijn verwijderd. + void clampIndex(int maxIndex) { + final max = maxIndex < 0 ? 0 : maxIndex; + final pruned = state.selection.where((i) => i <= max).toSet(); + final index = state.selectedIndex > max ? max : state.selectedIndex; + state = state.copyWith( + selectedIndex: index, + selection: pruned.isEmpty ? {index} : pruned, + ); + } +} + +final editorProvider = StateNotifierProvider( + (_) => EditorNotifier(), +); diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart new file mode 100644 index 0000000..edf2d8c --- /dev/null +++ b/lib/state/settings_provider.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/settings.dart'; + +class SettingsNotifier extends StateNotifier { + SettingsNotifier() : super(const AppSettings()) { + _load(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final themeJson = prefs.getString('themeProfile'); + final profilesJson = prefs.getString('themeProfiles'); + final loadedProfiles = profilesJson == null + ? [ + themeJson == null + ? const ThemeProfile() + : ThemeProfile.fromJson( + Map.from(jsonDecode(themeJson) as Map), + ), + ] + : (jsonDecode(profilesJson) as List) + .map( + (item) => ThemeProfile.fromJson( + Map.from(item as Map), + ), + ) + .toList(); + final profiles = _uniqueProfiles(loadedProfiles); + state = AppSettings( + homeDirectory: prefs.getString('homeDirectory'), + themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles, + selectedThemeProfileName: + prefs.getString('selectedThemeProfileName') ?? profiles.first.name, + recentFiles: prefs.getStringList('recentFiles') ?? [], + ); + } + + Future addRecentFile(String path) async { + final updated = [ + path, + ...state.recentFiles.where((f) => f != path), + ].take(10).toList(); + state = state.copyWith(recentFiles: updated); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList('recentFiles', updated); + } + + Future setHomeDirectory(String? path) async { + state = path == null + ? state.copyWith(clearHomeDirectory: true) + : state.copyWith(homeDirectory: path); + final prefs = await SharedPreferences.getInstance(); + if (path == null) { + await prefs.remove('homeDirectory'); + } else { + await prefs.setString('homeDirectory', path); + } + } + + /// Persist edits to the profile currently identified by [previousName], + /// renaming it in place when the name changed. When no profile matches + /// [previousName] (e.g. a freshly created one) the profile is added. The + /// edited profile is selected afterwards. + Future saveThemeProfile( + ThemeProfile profile, { + required String previousName, + }) async { + final profileName = _uniqueName(profile.name, exceptName: previousName); + final renamed = profile.copyWith(name: profileName); + final exists = state.themeProfiles.any((p) => p.name == previousName); + final profiles = exists + ? [ + for (final p in state.themeProfiles) + if (p.name == previousName) renamed else p, + ] + : [...state.themeProfiles, renamed]; + state = state.copyWith( + themeProfiles: profiles, + selectedThemeProfileName: profileName, + ); + await _saveProfiles(); + } + + Future selectThemeProfile(String name) async { + state = state.copyWith(selectedThemeProfileName: name); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('selectedThemeProfileName', name); + } + + /// Create a brand-new profile (optionally based on [base]), add it to the + /// list, select it and persist. Returns the created profile (its name may + /// have been made unique). + Future createThemeProfile({ThemeProfile? base}) async { + final source = base ?? state.themeProfile; + final name = _uniqueName('Nieuw profiel'); + final created = source.copyWith(name: name); + state = state.copyWith( + themeProfiles: [...state.themeProfiles, created], + selectedThemeProfileName: name, + ); + await _saveProfiles(); + return created; + } + + Future deleteThemeProfile(String name) async { + if (state.themeProfiles.length <= 1) return; + final profiles = state.themeProfiles.where((p) => p.name != name).toList(); + state = state.copyWith( + themeProfiles: profiles, + selectedThemeProfileName: profiles.first.name, + ); + await _saveProfiles(); + } + + Future _saveProfiles() async { + state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles)); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'themeProfiles', + jsonEncode(state.themeProfiles.map((p) => p.toJson()).toList()), + ); + await prefs.setString( + 'selectedThemeProfileName', + state.selectedThemeProfileName, + ); + await prefs.setString( + 'themeProfile', + jsonEncode(state.themeProfile.toJson()), + ); + } + + List _uniqueProfiles(List profiles) { + final result = []; + for (final profile in profiles) { + result.add( + profile.copyWith(name: _uniqueName(profile.name, profiles: result)), + ); + } + return result.isEmpty ? const [ThemeProfile()] : result; + } + + String _uniqueName( + String rawName, { + List? profiles, + String? exceptName, + }) { + final existingProfiles = profiles ?? state.themeProfiles; + final base = rawName.trim().isEmpty ? 'Stijlprofiel' : rawName.trim(); + final used = existingProfiles + .map((p) => p.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(), +); diff --git a/lib/state/slide_clipboard_provider.dart b/lib/state/slide_clipboard_provider.dart new file mode 100644 index 0000000..c694c99 --- /dev/null +++ b/lib/state/slide_clipboard_provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter_riverpod/legacy.dart'; +import '../models/slide.dart'; + +/// Global clipboard for a single copied slide. +/// Lives in the root ProviderScope so it is accessible from any tab. +final slideClipboardProvider = StateProvider((ref) => null); diff --git a/lib/state/tabs_provider.dart b/lib/state/tabs_provider.dart new file mode 100644 index 0000000..cb73b19 --- /dev/null +++ b/lib/state/tabs_provider.dart @@ -0,0 +1,301 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/legacy.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; +import '../services/file_service.dart'; +import '../services/markdown_service.dart'; +import '../services/recovery_service.dart'; +import 'deck_provider.dart'; +import 'editor_provider.dart'; +import 'settings_provider.dart'; + +const _uuid = Uuid(); + +// ── Per-tab data ────────────────────────────────────────────────────────────── + +class TabInfo { + final int id; + + /// Stabiele sleutel voor het autosave-herstelbestand van dit tabblad. + final String recoveryId; + final DeckNotifier deckNotifier; + final EditorNotifier editorNotifier; + + const TabInfo({ + required this.id, + required this.recoveryId, + required this.deckNotifier, + required this.editorNotifier, + }); + + String get label { + final st = deckNotifier.currentState; + // A saved deck is identified by its file name — that is what the user + // recognises, not the parsed first-slide title (which falls back to the + // generic 'Presentatie'). + final path = st.filePath; + if (path != null && path.isNotEmpty) { + final name = p.basenameWithoutExtension(path); + if (name.isNotEmpty) return name; + } + final deck = st.deck; + return deck?.title.isNotEmpty == true ? deck!.title : 'Nieuw'; + } + + bool get isDirty => deckNotifier.currentState.isDirty; + bool get isOpen => deckNotifier.currentState.isOpen; +} + +// ── Tabs state ──────────────────────────────────────────────────────────────── + +class TabsState { + final List tabs; + final int selectedIndex; + + const TabsState({required this.tabs, this.selectedIndex = 0}); + + int get clampedIndex => selectedIndex.clamp( + 0, + (tabs.length - 1).clamp(0, double.maxFinite.toInt()), + ); + + TabInfo? get current => tabs.isEmpty ? null : tabs[clampedIndex]; + + bool get anyDirty => tabs.any((t) => t.isDirty); + + TabsState copyWith({List? tabs, int? selectedIndex}) { + return TabsState( + tabs: tabs ?? this.tabs, + selectedIndex: selectedIndex ?? this.selectedIndex, + ); + } +} + +// ── Tabs notifier ───────────────────────────────────────────────────────────── + +class TabsNotifier extends StateNotifier { + final MarkdownService _md; + final FileService _file; + final SettingsNotifier _settings; + final RecoveryService _recovery; + final Map> _subs = {}; + Timer? _autosaveTimer; + int _nextId = 0; + + /// Hoe vaak niet-opgeslagen tabbladen naar een herstelbestand worden bewaard. + static const _autosaveInterval = Duration(seconds: 25); + + TabsNotifier(this._md, this._file, this._settings, this._recovery) + : super(const TabsState(tabs: [])) { + // Start with one empty tab + final tab = _createTab(); + state = TabsState(tabs: [tab], selectedIndex: 0); + _autosaveTimer = Timer.periodic(_autosaveInterval, (_) => _autosaveTick()); + } + + @override + void dispose() { + _autosaveTimer?.cancel(); + for (final sub in _subs.values) { + sub.cancel(); + } + super.dispose(); + } + + TabInfo _createTab() { + final id = _nextId++; + final recoveryId = _uuid.v4(); + final deckNotifier = DeckNotifier(_md, _file); + final tab = TabInfo( + id: id, + recoveryId: recoveryId, + deckNotifier: deckNotifier, + editorNotifier: EditorNotifier(), + ); + _subs[id] = deckNotifier.stream.listen((st) { + if (!mounted) return; + // Zodra een tabblad is opgeslagen (schoon), het herstelbestand wissen. + // Schrijven gebeurt gebufferd door de periodieke autosave-tick. + if (!(st.isOpen && st.isDirty)) { + _recovery.discard(recoveryId); + } + state = state.copyWith(tabs: List.from(state.tabs)); + }); + return tab; + } + + /// Bewaar elk niet-opgeslagen tabblad naar zijn herstelbestand. + void _autosaveTick() { + if (!mounted) return; + for (final tab in state.tabs) { + final st = tab.deckNotifier.currentState; + if (st.isOpen && st.isDirty) { + _recovery.save( + RecoverySnapshot( + id: tab.recoveryId, + savedAt: DateTime.now(), + filePath: st.filePath, + label: tab.label, + markdown: tab.deckNotifier.generateMarkdown(), + ), + ); + } + } + } + + /// Open elke teruggehaalde snapshot als (gewijzigd) tabblad en ruim de oude + /// herstelbestanden op. Aangeroepen vanuit het herstel-dialoog bij opstart. + void restoreRecovered(List snapshots) { + final restored = []; + for (final snap in snapshots) { + final deck = _md.parseDeck(snap.markdown, filePath: snap.filePath); + _recovery.discard(snap.id); // oude sleutel; tab krijgt een nieuwe + if (deck == null) continue; + final tab = _createTab(); + tab.deckNotifier.loadDeck(deck, filePath: snap.filePath); + tab.deckNotifier.markDirty(); // herstelde inhoud is nog niet opgeslagen + restored.add(tab); + } + if (restored.isEmpty) return; + + // Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen. + final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen; + if (replaceEmpty) { + _subs.remove(state.tabs.first.id)?.cancel(); + state = state.copyWith(tabs: restored, selectedIndex: 0); + } else { + final tabs = [...state.tabs, ...restored]; + state = state.copyWith(tabs: tabs, selectedIndex: state.tabs.length); + } + } + + void newEmptyTab() { + final tab = _createTab(); + final newTabs = [...state.tabs, tab]; + state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1); + } + + void newDeckInCurrentTab(String title) { + final tab = state.current; + if (tab == null) return; + tab.deckNotifier.newDeck(title); + tab.editorNotifier.select(0); + // Force rebuild by copying state (label may have changed) + state = state.copyWith(tabs: List.from(state.tabs)); + } + + void newDeckInNewTab(String title) { + final tab = _createTab(); + tab.deckNotifier.newDeck(title); + tab.editorNotifier.select(0); + final newTabs = [...state.tabs, tab]; + state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1); + } + + /// Open a file picker and load the chosen deck. + /// If the current tab is empty, replaces it; otherwise opens a new tab. + Future openFile({String? initialDirectory}) async { + final path = await _file.pickMarkdownFile( + initialDirectory: initialDirectory, + ); + if (path == null) return; + final deck = await _file.openDeck(path); + if (deck == null) return; + + final current = state.current; + if (current != null && !current.isOpen) { + // Replace the empty current tab + current.deckNotifier.loadDeck(deck, filePath: path); + current.editorNotifier.select(0); + state = state.copyWith(tabs: List.from(state.tabs)); + } else { + // Open in a new tab + final tab = _createTab(); + tab.deckNotifier.loadDeck(deck, filePath: path); + final newTabs = [...state.tabs, tab]; + state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1); + } + await _settings.addRecentFile(path); + } + + Future openFileByPath(String path, {int? selectIndex}) async { + final deck = await _file.openDeck(path); + if (deck == null) return; + final index = (selectIndex ?? 0).clamp(0, deck.slides.length - 1); + final current = state.current; + if (current != null && !current.isOpen) { + current.deckNotifier.loadDeck(deck, filePath: path); + current.editorNotifier.select(index); + state = state.copyWith(tabs: List.from(state.tabs)); + } else { + final tab = _createTab(); + tab.deckNotifier.loadDeck(deck, filePath: path); + tab.editorNotifier.select(index); + final newTabs = [...state.tabs, tab]; + state = state.copyWith(tabs: newTabs, selectedIndex: newTabs.length - 1); + } + await _settings.addRecentFile(path); + } + + /// Map waarin geïmporteerde pakketten worden uitgepakt. + Future _importDestDir(String? homeDir) async { + if (homeDir != null && homeDir.trim().isNotEmpty) return homeDir; + return (await getApplicationDocumentsDirectory()).path; + } + + /// Importeer een `.ocideck`-pakket (zip) en open het in een tab. + Future importPackageFile(String zipPath, {String? homeDir}) async { + final dest = await _importDestDir(homeDir); + final bytes = await File(zipPath).readAsBytes(); + final mdPath = await _file.importPackageBytes(bytes, dest); + if (mdPath == null) return false; + await openFileByPath(mdPath); + return true; + } + + /// Haal een presentatie op via een URL (pakket of platte markdown) en open + /// het in een tab. + Future importFromUrl(String url, {String? homeDir}) async { + final dest = await _importDestDir(homeDir); + final mdPath = await _file.importFromUrl(url, dest); + if (mdPath == null) return false; + await openFileByPath(mdPath); + return true; + } + + void selectTab(int index) { + if (index >= 0 && index < state.tabs.length) { + state = state.copyWith(selectedIndex: index); + } + } + + /// Close the tab at [index]. + /// If it is the only tab, just clears the deck (welcome screen remains). + void closeTab(int index) { + if (state.tabs.length == 1) { + _recovery.discard(state.tabs.first.recoveryId); + state.tabs.first.deckNotifier.closeDeck(); + state = state.copyWith(tabs: List.from(state.tabs)); + return; + } + final tab = state.tabs[index]; + _recovery.discard(tab.recoveryId); + _subs.remove(tab.id)?.cancel(); + final newTabs = List.from(state.tabs)..removeAt(index); + final newSelected = index >= newTabs.length ? newTabs.length - 1 : index; + state = state.copyWith(tabs: newTabs, selectedIndex: newSelected); + } +} + +// ── Provider ────────────────────────────────────────────────────────────────── + +final tabsProvider = StateNotifierProvider((ref) { + return TabsNotifier( + ref.read(markdownServiceProvider), + ref.read(fileServiceProvider), + ref.read(settingsProvider.notifier), + ref.read(recoveryServiceProvider), + ); +}); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..621b602 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Brand colours + static const navy = Color(0xFF1C2B47); + static const teal = Color(0xFF2E7D64); + static const accent = Color(0xFF2563EB); + static const surface = Color(0xFFF8F9FA); + static const panelBg = Color(0xFF1E2028); + static const panelFg = Color(0xFFE2E8F0); + + static ThemeData get light { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: navy, + brightness: Brightness.light, + ), + scaffoldBackgroundColor: surface, + appBarTheme: const AppBarTheme( + backgroundColor: navy, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + dividerTheme: const DividerThemeData( + color: Color(0xFFE2E8F0), + thickness: 1, + space: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFCBD5E1)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFCBD5E1)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: accent, width: 1.5), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: accent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + ); + } +} diff --git a/lib/utils/url_launcher_util.dart b/lib/utils/url_launcher_util.dart new file mode 100644 index 0000000..252ca3f --- /dev/null +++ b/lib/utils/url_launcher_util.dart @@ -0,0 +1,21 @@ +import 'package:url_launcher/url_launcher.dart'; + +/// Open een link uit slide-tekst in de externe browser. Kale domeinen +/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige +/// of niet-openbare URLs. +Future openExternalUrl(String url) async { + var u = url.trim(); + if (u.isEmpty) return; + if (!u.contains('://') && !u.startsWith('mailto:')) { + u = u.contains('@') ? 'mailto:$u' : 'https://$u'; + } + final uri = Uri.tryParse(u); + if (uri == null) return; + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (_) { + // Nooit de presentatie laten crashen op een kapotte link. + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart new file mode 100644 index 0000000..6f0d3b3 --- /dev/null +++ b/lib/widgets/app_shell.dart @@ -0,0 +1,1458 @@ +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:window_manager/window_manager.dart'; +import '../models/deck.dart'; +import '../models/slide.dart'; +import '../services/caption_service.dart'; +import '../services/description_service.dart'; +import '../services/export_service.dart'; +import '../services/recovery_service.dart'; +import '../state/deck_provider.dart'; +import '../state/editor_provider.dart'; +import '../state/settings_provider.dart'; +import '../state/tabs_provider.dart'; +import '../theme/app_theme.dart'; +import 'dialogs/export_dialog.dart'; +import 'dialogs/find_replace_dialog.dart'; +import 'dialogs/image_carousel_picker.dart'; +import 'dialogs/new_deck_dialog.dart'; +import 'dialogs/open_presentation_dialog.dart'; +import 'dialogs/presentation_info_dialog.dart'; +import 'dialogs/settings_dialog.dart'; +import 'panels/editor_panel.dart'; +import 'panels/preview_panel.dart'; +import 'panels/slide_list_panel.dart'; +import 'presentation/fullscreen_presenter.dart'; + +// ── Shared helpers ────────────────────────────────────────────────────────── + +/// Open the search-based presentation picker and load the chosen file +/// (optionally jumping to a matched slide). +Future _openWithSearch( + BuildContext context, + WidgetRef ref, + String? initialDirectory, +) async { + final settings = ref.read(settingsProvider); + final result = await OpenPresentationDialog.show( + context, + fileService: ref.read(fileServiceProvider), + initialDirectory: initialDirectory ?? settings.homeDirectory, + ); + if (result == null) return; + await ref + .read(tabsProvider.notifier) + .openFileByPath(result.path, selectIndex: result.slideIndex); +} + +/// Vraag een URL op om een presentatie (pakket of markdown) op te halen. +Future _showUrlDialog(BuildContext context) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Importeren via URL'), + content: SizedBox( + width: 460, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', + style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + hintText: 'https://…', + prefixIcon: Icon(Icons.link, size: 18), + isDense: true, + border: OutlineInputBorder(), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuleren'), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, controller.text), + icon: const Icon(Icons.download, size: 16), + label: const Text('Ophalen'), + ), + ], + ), + ); +} + +List _imageSearchPaths(String? projectPath, String? homeDirectory) { + final projectImagesPath = projectPath == null + ? null + : p.join(projectPath, 'images'); + return [?projectImagesPath, ?projectPath, ?homeDirectory]; +} + +String? _resolveImagePath(String path, String? projectPath) { + if (path.isEmpty) return null; + if (p.isAbsolute(path) || projectPath == null) return path; + return p.join(projectPath, path); +} + +List _imageUsages(WidgetRef ref, String absolutePath) { + final target = p.normalize(absolutePath); + final usages = []; + for (final tab in ref.read(tabsProvider).tabs) { + final deck = tab.deckNotifier.currentState.deck; + if (deck == null) continue; + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + for (final candidate in [slide.imagePath, slide.imagePath2]) { + if (candidate.isEmpty) continue; + final resolved = p.normalize( + p.isAbsolute(candidate) + ? candidate + : p.join(deck.projectPath ?? '', candidate), + ); + if (resolved == target) { + usages.add('${tab.label} · slide ${i + 1}'); + break; + } + } + } + } + return usages; +} + +// ── App shell ───────────────────────────────────────────────────────────────── + +class AppShell extends ConsumerStatefulWidget { + const AppShell({super.key}); + + @override + ConsumerState createState() => _AppShellState(); +} + +class _AppShellState extends ConsumerState with WindowListener { + @override + void initState() { + super.initState(); + windowManager.addListener(this); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeRestore()); + } + + /// Bij opstart: zijn er herstelbestanden van een vorige (gecrashte) sessie? + Future _maybeRestore() async { + final recovery = ref.read(recoveryServiceProvider); + final snapshots = await recovery.loadAll(); + if (snapshots.isEmpty || !mounted) return; + + final restore = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Niet-opgeslagen werk herstellen?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + snapshots.length == 1 + ? 'Er is een presentatie met niet-opgeslagen wijzigingen ' + 'gevonden van een vorige sessie:' + : 'Er zijn ${snapshots.length} presentaties met ' + 'niet-opgeslagen wijzigingen gevonden van een vorige ' + 'sessie:', + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 10), + for (final s in snapshots) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + '• ${s.label} · ${_formatWhen(s.savedAt)}', + style: const TextStyle( + fontSize: 12.5, + color: Color(0xFF475569), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Verwijderen'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Herstellen'), + ), + ], + ), + ); + + if (restore == true) { + ref.read(tabsProvider.notifier).restoreRecovered(snapshots); + } else { + await recovery.clearAll(); + } + } + + String _formatWhen(DateTime t) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(t.day)}-${two(t.month)} ${two(t.hour)}:${two(t.minute)}'; + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + void onWindowClose() async { + if (ref.read(tabsProvider).anyDirty) { + final shouldSave = await _confirmSaveBeforeClose( + 'Er zijn presentaties met niet-opgeslagen wijzigingen. ' + 'Sla ze op voordat de app sluit.', + ); + if (!shouldSave) return; + final saved = await _saveAllDirtyTabs(); + if (saved) await _destroy(); + } else { + await _destroy(); + } + } + + /// Nette afsluiting: herstelbestanden opruimen (alles is opgeslagen) en sluiten. + Future _destroy() async { + await ref.read(recoveryServiceProvider).clearAll(); + await windowManager.destroy(); + } + + Future _confirmSaveBeforeClose(String message) async { + if (!mounted) return false; + return await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Niet-opgeslagen wijzigingen'), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuleren'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Opslaan en sluiten'), + ), + ], + ), + ) ?? + false; + } + + Future _saveAllDirtyTabs() async { + final homeDir = ref.read(settingsProvider).homeDirectory; + for (final tab in ref.read(tabsProvider).tabs) { + if (!tab.isDirty) continue; + final saved = await tab.deckNotifier.save(initialDirectory: homeDir); + if (!saved) return false; + } + return true; + } + + Future _onCloseTab(int index) async { + final tab = ref.read(tabsProvider).tabs[index]; + if (tab.isDirty) { + final shouldSave = await _confirmSaveBeforeClose( + 'Deze presentatie heeft niet-opgeslagen wijzigingen. ' + 'Sla de presentatie op voordat het tabblad sluit.', + ); + if (!shouldSave) return; + final saved = await tab.deckNotifier.save( + initialDirectory: ref.read(settingsProvider).homeDirectory, + ); + if (!saved) return; + } + ref.read(tabsProvider.notifier).closeTab(index); + } + + /// Sla het actieve tabblad op. App-breed zodat Ctrl/Cmd+S altijd werkt, + /// ongeacht waar de focus zit. + void _saveActive() { + final tab = ref.read(tabsProvider).current; + tab?.deckNotifier.save( + initialDirectory: ref.read(settingsProvider).homeDirectory, + ); + } + + bool _dragging = false; + + static const _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.tiff', + '.tif', + }; + + /// Verwerk gesleepte bestanden: presentaties/pakketten openen, afbeeldingen + /// als nieuwe slide(s) toevoegen aan het actieve deck. + Future _onFilesDropped(List paths) async { + final homeDir = ref.read(settingsProvider).homeDirectory; + final tabs = ref.read(tabsProvider.notifier); + final images = []; + for (final path in paths) { + final ext = p.extension(path).toLowerCase(); + if (ext == '.md') { + await tabs.openFileByPath(path); + } else if (ext == '.ocideck' || ext == '.zip') { + await tabs.importPackageFile(path, homeDir: homeDir); + } else if (_imageExtensions.contains(ext)) { + images.add(path); + } + } + if (images.isNotEmpty) _addImagesToActiveDeck(images); + } + + void _addImagesToActiveDeck(List paths) { + final tab = ref.read(tabsProvider).current; + if (tab == null || !tab.isOpen) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Open eerst een presentatie om afbeeldingen toe te ' + 'voegen.', + ), + ), + ); + } + return; + } + final deckN = tab.deckNotifier; + final editorN = tab.editorNotifier; + var idx = editorN.currentState.selectedIndex; + for (final path in paths) { + deckN.addSlide(SlideType.image, afterIndex: idx); + idx += 1; + deckN.updateSlide( + idx, + Slide.create(SlideType.image).copyWith(imagePath: path), + ); + } + editorN.select(idx); + } + + @override + Widget build(BuildContext context) { + final tabsState = ref.watch(tabsProvider); + + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyS, control: true): + _saveActive, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true): _saveActive, + }, + child: FocusScope( + autofocus: true, + child: DropTarget( + onDragEntered: (_) => setState(() => _dragging = true), + onDragExited: (_) => setState(() => _dragging = false), + onDragDone: (detail) { + setState(() => _dragging = false); + _onFilesDropped(detail.files.map((f) => f.path).toList()); + }, + child: Material( + child: Stack( + children: [ + Column( + children: [ + _AppTabBar( + tabsState: tabsState, + onSelect: (i) => + ref.read(tabsProvider.notifier).selectTab(i), + onClose: _onCloseTab, + onAdd: () => + ref.read(tabsProvider.notifier).newEmptyTab(), + ), + Expanded( + child: IndexedStack( + index: tabsState.clampedIndex, + children: [ + for (final tab in tabsState.tabs) + ProviderScope( + key: ValueKey(tab.id), + overrides: [ + deckProvider.overrideWith( + (ref) => tab.deckNotifier, + ), + editorProvider.overrideWith( + (ref) => tab.editorNotifier, + ), + ], + child: const _TabContent(), + ), + ], + ), + ), + ], + ), + if (_dragging) const _DropOverlay(), + ], + ), + ), + ), + ), + ); + } +} + +/// Visuele hint terwijl bestanden boven het venster zweven. +class _DropOverlay extends StatelessWidget { + const _DropOverlay(); + + @override + Widget build(BuildContext context) { + return Positioned.fill( + child: IgnorePointer( + child: Container( + color: const Color(0xFF1C2B47).withValues(alpha: 0.55), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF60A5FA), width: 2), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.file_download_outlined, + size: 40, + color: Color(0xFF2563EB), + ), + SizedBox(height: 10), + Text( + 'Laat los om toe te voegen', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + SizedBox(height: 4), + Text( + 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen', + style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), + ), + ], + ), + ), + ), + ), + ); + } +} + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +class _AppTabBar extends StatelessWidget { + final TabsState tabsState; + final ValueChanged onSelect; + final ValueChanged onClose; + final VoidCallback onAdd; + + const _AppTabBar({ + required this.tabsState, + required this.onSelect, + required this.onClose, + required this.onAdd, + }); + + static const _bgColor = Color(0xFF1E293B); + + @override + Widget build(BuildContext context) { + return Container( + height: 36, + color: _bgColor, + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < tabsState.tabs.length; i++) + _TabChip( + tab: tabsState.tabs[i], + isActive: i == tabsState.clampedIndex, + showClose: tabsState.tabs.length > 1, + onTap: () => onSelect(i), + onClose: () => onClose(i), + ), + ], + ), + ), + ), + Tooltip( + message: 'Nieuw tabblad', + child: InkWell( + onTap: onAdd, + child: const SizedBox( + width: 36, + height: 36, + child: Icon(Icons.add, size: 16, color: Colors.white54), + ), + ), + ), + ], + ), + ); + } +} + +class _TabChip extends StatelessWidget { + final TabInfo tab; + final bool isActive; + final bool showClose; + final VoidCallback onTap; + final VoidCallback onClose; + + const _TabChip({ + required this.tab, + required this.isActive, + required this.showClose, + required this.onTap, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), + height: 36, + decoration: BoxDecoration( + color: isActive ? const Color(0xFF334155) : Colors.transparent, + border: Border( + bottom: BorderSide( + color: isActive ? const Color(0xFF60A5FA) : Colors.transparent, + width: 2, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (tab.isDirty) + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 5), + decoration: const BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + ), + ), + Flexible( + child: Text( + tab.label, + style: TextStyle( + fontSize: 12, + color: isActive ? Colors.white : Colors.white70, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (showClose) ...[ + const SizedBox(width: 4), + InkWell( + onTap: onClose, + borderRadius: BorderRadius.circular(3), + child: const Padding( + padding: EdgeInsets.all(2), + child: Icon(Icons.close, size: 12, color: Colors.white54), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ── Per-tab content ─────────────────────────────────────────────────────────── + +class _TabContent extends ConsumerWidget { + const _TabContent(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOpen = ref.watch(deckProvider.select((s) => s.isOpen)); + if (!isOpen) return const _WelcomeScreen(); + return _MainLayout(exportService: ExportService()); + } +} + +// ── Welcome screen ──────────────────────────────────────────────────────────── + +class _WelcomeScreen extends ConsumerWidget { + const _WelcomeScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); + final recentFiles = ref.watch( + settingsProvider.select((s) => s.recentFiles), + ); + + return Scaffold( + backgroundColor: Colors.white, + body: Row( + children: [ + // ── Midden: logo + knoppen ───────────────────────────────────── + Expanded( + child: Align( + alignment: const Alignment(-0.15, 0.12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: 'De Winter Information Solutions', + image: true, + child: Image.asset( + 'assets/images/de-winter-wittegeheel.png', + width: 320, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: 220, + child: ElevatedButton.icon( + onPressed: () => _newDeck(context, ref), + icon: const Icon(Icons.add, size: 18), + label: const Text('Nieuwe presentatie'), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: 220, + child: OutlinedButton.icon( + onPressed: () => _openWithSearch(context, ref, homeDir), + icon: const Icon(Icons.folder_open_outlined, size: 18), + label: const Text('Openen...'), + ), + ), + ], + ), + ), + ), + // ── Rechts: recente bestanden ────────────────────────────────── + if (recentFiles.isNotEmpty) + Container( + width: 280, + decoration: const BoxDecoration( + color: Color(0xFFF8FAFC), + border: Border(left: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 20, 16, 10), + child: Text( + 'Recente presentaties', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF94A3B8), + letterSpacing: 0.8, + ), + ), + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 16), + itemCount: recentFiles.length, + itemBuilder: (_, i) { + final path = recentFiles[i]; + final name = path.split('/').last.replaceAll('.md', ''); + return InkWell( + onTap: () => ref + .read(tabsProvider.notifier) + .openFileByPath(path), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + const Icon( + Icons.slideshow_outlined, + size: 16, + color: Color(0xFF64748B), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1E293B), + ), + overflow: TextOverflow.ellipsis, + ), + Text( + path, + style: const TextStyle( + fontSize: 10, + color: Color(0xFF94A3B8), + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _newDeck(BuildContext context, WidgetRef ref) async { + final title = await NewDeckDialog.show(context); + if (title != null) { + ref.read(tabsProvider.notifier).newDeckInCurrentTab(title); + } + } +} + +// ── Main 2-panel layout ─────────────────────────────────────────────────────── + +class _MainLayout extends ConsumerStatefulWidget { + final ExportService exportService; + + const _MainLayout({required this.exportService}); + + @override + ConsumerState<_MainLayout> createState() => _MainLayoutState(); +} + +class _MainLayoutState extends ConsumerState<_MainLayout> { + static const _minSlideRailWidth = 210.0; + static const _defaultSlideRailWidth = 320.0; + static const _minEditorWidth = 420.0; + + double _slideRailWidth = _defaultSlideRailWidth; + + @override + Widget build(BuildContext context) { + final deckState = ref.watch(deckProvider); + final deck = deckState.deck!; + final editor = ref.watch(editorProvider); + final settings = ref.watch(settingsProvider); + final deckNotifier = ref.read(deckProvider.notifier); + final editorNotifier = ref.read(editorProvider.notifier); + + final isMarkdownMode = editor.mode == EditorMode.markdown; + + Future saveDeck() async { + await deckNotifier.save(initialDirectory: settings.homeDirectory); + } + + void openFindReplace() { + FindReplaceDialog.show( + context, + countMatches: (q, cs) => + deckNotifier.countMatches(q, caseSensitive: cs), + replaceAll: (q, r, cs) => + deckNotifier.replaceAll(q, r, caseSensitive: cs), + ); + } + + Future openImageCarousel() async { + final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1); + final slide = deck.slides[idx]; + final initialPath = _resolveImagePath(slide.imagePath, deck.projectPath); + final result = await ImageCarouselPicker.show( + context, + searchPaths: _imageSearchPaths( + deck.projectPath, + settings.homeDirectory, + ), + initialPath: initialPath, + captionService: ref.read(captionServiceProvider), + descriptionService: ref.read(descriptionServiceProvider), + usageOf: (absolutePath) => _imageUsages(ref, absolutePath), + ); + if (result == null) return; + + final updated = switch (slide.type) { + SlideType.title || + SlideType.image || + SlideType.quote || + SlideType.bulletsImage => slide.copyWith( + imagePath: result.path, + imageCaption: result.caption, + ), + SlideType.twoImages => slide.copyWith( + imagePath: slide.imagePath.isEmpty ? result.path : slide.imagePath, + imagePath2: slide.imagePath.isEmpty ? slide.imagePath2 : result.path, + imageCaption: slide.imagePath.isEmpty + ? result.caption + : slide.imageCaption, + imageCaption2: slide.imagePath.isEmpty + ? slide.imageCaption2 + : result.caption, + ), + SlideType.bullets => slide.copyWith( + type: SlideType.bulletsImage, + imagePath: result.path, + imageCaption: result.caption, + imageSize: slide.imageSize > 0 ? slide.imageSize : 40, + ), + _ => null, + }; + + if (updated == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.', + ), + ), + ); + return; + } + + deckNotifier.updateSlide(idx, updated); + } + + void presentDeck() { + // Overgeslagen slides weglaten en de selectie naar de eerstvolgende + // zichtbare slide vertalen. + final visible = [ + for (var i = 0; i < deck.slides.length; i++) + if (!deck.slides[i].skipped) i, + ]; + if (visible.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Alle slides zijn overgeslagen — niets om te tonen.'), + ), + ); + return; + } + var initial = visible.indexWhere((i) => i >= editor.selectedIndex); + if (initial < 0) initial = visible.length - 1; + FullscreenPresenter.show( + context, + slides: [for (final i in visible) deck.slides[i]], + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + initialIndex: initial, + tlp: deck.tlp, + ); + } + + void exportDeck() { + final slides = deck.slides.where((s) => !s.skipped).toList(); + if (slides.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Alle slides zijn overgeslagen — niets om te exporteren.', + ), + ), + ); + return; + } + ExportDialog.show( + context, + deckPath: deckState.filePath!, + slides: slides, + themeProfile: deck.themeProfile, + projectPath: deck.projectPath, + exportService: widget.exportService, + tlp: deck.tlp, + ); + } + + void toggleMarkdownMode() { + if (isMarkdownMode) { + editorNotifier.setMode(EditorMode.visual); + } else { + editorNotifier.setMode( + EditorMode.markdown, + initialMarkdown: deckNotifier.generateMarkdown(), + ); + } + } + + void openFullDeckPreview() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + FullDeckPreview(deck: deck, themeProfile: deck.themeProfile), + ), + ); + } + + Future newInTab() async { + final title = await NewDeckDialog.show(context); + if (title != null) { + ref.read(tabsProvider.notifier).newDeckInNewTab(title); + } + } + + Future openProperties() async { + final info = await PresentationInfoDialog.show(context, deck); + if (info == null) return; + deckNotifier.updateInfo( + author: info.author, + organization: info.organization, + version: info.version, + date: info.date, + description: info.description, + keywords: info.keywords, + ); + } + + Future exportPackage() async { + final fileService = ref.read(fileServiceProvider); + final dest = await fileService.pickPackageDestination(deck); + if (dest == null) return; + try { + await fileService.exportPackage(deck, dest); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Pakket geëxporteerd naar:\n$dest')), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Export mislukt: $e'))); + } + } + + Future importPackage() async { + final fileService = ref.read(fileServiceProvider); + final path = await fileService.pickPackageFile( + initialDirectory: settings.homeDirectory, + ); + if (path == null) return; + final ok = await ref + .read(tabsProvider.notifier) + .importPackageFile(path, homeDir: settings.homeDirectory); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Kon dit pakket niet importeren.')), + ); + } + } + + Future importUrl() async { + final url = await _showUrlDialog(context); + if (url == null || url.trim().isEmpty) return; + final ok = await ref + .read(tabsProvider.notifier) + .importFromUrl(url, homeDir: settings.homeDirectory); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Kon van deze URL geen presentatie ophalen.'), + ), + ); + } + } + + PopupMenuItem menuItem(String value, IconData icon, String label) { + return PopupMenuItem( + value: value, + child: Row( + children: [ + Icon(icon, size: 16, color: const Color(0xFF475569)), + const SizedBox(width: 10), + Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), + ], + ), + ); + } + + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.keyS && + (HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed)) { + saveDeck(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyS, control: true): + saveDeck, + const SingleActivator(LogicalKeyboardKey.keyS, meta: true): saveDeck, + // Ongedaan maken / opnieuw. Vuren alleen wanneer de focus niet in een + // tekstveld zit (dat handelt z'n eigen undo af), dus geen conflict. + const SingleActivator(LogicalKeyboardKey.keyZ, control: true): + deckNotifier.undo, + const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): + deckNotifier.undo, + const SingleActivator( + LogicalKeyboardKey.keyZ, + control: true, + shift: true, + ): deckNotifier.redo, + const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + shift: true, + ): deckNotifier.redo, + const SingleActivator(LogicalKeyboardKey.keyY, control: true): + deckNotifier.redo, + const SingleActivator(LogicalKeyboardKey.keyH, control: true): + openFindReplace, + const SingleActivator(LogicalKeyboardKey.keyH, meta: true): + openFindReplace, + }, + child: Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Icon(Icons.slideshow_outlined, size: 22), + const SizedBox(width: 10), + Flexible( + child: Text(deck.title, overflow: TextOverflow.ellipsis), + ), + if (deckState.isDirty) ...[ + const SizedBox(width: 6), + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + ), + ), + ], + const SizedBox(width: 16), + _TlpChip( + tlp: deck.tlp, + onSelected: (level) => deckNotifier.updateInfo(tlp: level), + ), + ], + ), + actions: [ + // ── Bewerken ──────────────────────────────────────────────── + Tooltip( + message: 'Ongedaan maken (Ctrl/Cmd+Z)', + child: IconButton( + icon: const Icon(Icons.undo, size: 18), + onPressed: deckState.canUndo ? deckNotifier.undo : null, + ), + ), + Tooltip( + message: 'Opnieuw uitvoeren (Ctrl/Cmd+Shift+Z)', + child: IconButton( + icon: const Icon(Icons.redo, size: 18), + onPressed: deckState.canRedo ? deckNotifier.redo : null, + ), + ), + const _ActionsDivider(), + // ── Inhoud ────────────────────────────────────────────────── + Tooltip( + message: 'Afbeeldingenbibliotheek', + child: IconButton( + icon: const Icon(Icons.photo_library_outlined, size: 18), + onPressed: openImageCarousel, + ), + ), + const _ActionsDivider(), + // ── Presenteren & uitvoer ─────────────────────────────────── + Tooltip( + message: + 'Presenteren (volledig scherm) · P voor presenter view', + child: IconButton( + icon: const Icon(Icons.play_circle_outline, size: 20), + onPressed: presentDeck, + ), + ), + Tooltip( + message: isMarkdownMode ? 'Visuele modus' : 'Markdown modus', + child: IconButton( + icon: Icon( + isMarkdownMode ? Icons.view_quilt : Icons.code, + size: 18, + ), + onPressed: toggleMarkdownMode, + ), + ), + Tooltip( + message: 'Opslaan (Ctrl/Cmd+S)', + child: IconButton( + icon: const Icon(Icons.save_outlined, size: 18), + onPressed: saveDeck, + ), + ), + Tooltip( + message: 'Exporteren (PDF/PPTX)', + child: IconButton( + icon: const Icon(Icons.upload_file_outlined, size: 18), + onPressed: (deckState.filePath != null && !deckState.isDirty) + ? exportDeck + : null, + ), + ), + const _ActionsDivider(), + // ── Overig (minder vaak gebruikt) ─────────────────────────── + PopupMenuButton( + tooltip: 'Meer', + icon: const Icon(Icons.more_vert, size: 20), + position: PopupMenuPosition.under, + onSelected: (v) { + switch (v) { + case 'new_tab': + newInTab(); + case 'open': + _openWithSearch(context, ref, settings.homeDirectory); + case 'export_package': + exportPackage(); + case 'import_package': + importPackage(); + case 'import_url': + importUrl(); + case 'find': + openFindReplace(); + case 'full_preview': + openFullDeckPreview(); + case 'properties': + openProperties(); + case 'settings': + SettingsDialog.show(context); + default: + if (v.startsWith('style:')) { + final name = v.substring(6); + final profile = settings.themeProfiles.firstWhere( + (p) => p.name == name, + orElse: () => settings.themeProfile, + ); + deckNotifier.updateThemeProfile(profile); + } + } + }, + itemBuilder: (_) => [ + menuItem( + 'new_tab', + Icons.add_circle_outline, + 'Nieuwe presentatie (tab)', + ), + menuItem('open', Icons.folder_open_outlined, 'Openen…'), + const PopupMenuDivider(), + menuItem( + 'export_package', + Icons.inventory_2_outlined, + 'Pakket exporteren…', + ), + menuItem( + 'import_package', + Icons.unarchive_outlined, + 'Pakket importeren…', + ), + menuItem('import_url', Icons.link, 'Importeren via URL…'), + const PopupMenuDivider(), + menuItem('find', Icons.find_replace, 'Zoeken en vervangen'), + menuItem( + 'full_preview', + Icons.preview_outlined, + 'Volledig deck bekijken', + ), + const PopupMenuDivider(), + for (final profile in settings.themeProfiles) + PopupMenuItem( + value: 'style:${profile.name}', + child: Row( + children: [ + Icon( + profile.name == deck.themeProfile.name + ? Icons.check + : Icons.palette_outlined, + size: 16, + color: const Color(0xFF475569), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + 'Stijl: ${profile.name}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const PopupMenuDivider(), + menuItem( + 'properties', + Icons.info_outline, + 'Presentatie-eigenschappen', + ), + menuItem('settings', Icons.settings_outlined, 'Instellingen'), + ], + ), + const SizedBox(width: 8), + ], + ), + body: Builder( + builder: (ctx) { + if (deckState.error != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: Text(deckState.error!), + backgroundColor: Colors.red[700], + action: SnackBarAction( + label: 'OK', + textColor: Colors.white, + onPressed: () => + ref.read(deckProvider.notifier).clearError(), + ), + ), + ); + ref.read(deckProvider.notifier).clearError(); + }); + } + + return LayoutBuilder( + builder: (context, constraints) { + final maxRailWidth = (constraints.maxWidth - _minEditorWidth) + .clamp(_minSlideRailWidth, constraints.maxWidth) + .toDouble(); + final railWidth = _slideRailWidth + .clamp(_minSlideRailWidth, maxRailWidth) + .toDouble(); + if (railWidth != _slideRailWidth) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _slideRailWidth = railWidth); + }); + } + + return Row( + children: [ + SizedBox(width: railWidth, child: const SlideListPanel()), + _ResizableDivider( + onDrag: (delta) { + setState(() { + _slideRailWidth = (_slideRailWidth + delta) + .clamp(_minSlideRailWidth, maxRailWidth) + .toDouble(); + }); + }, + ), + const Expanded(child: EditorPanel()), + ], + ); + }, + ); + }, + ), + ), + ), + ); + } +} + +// ── AppBar helpers ──────────────────────────────────────────────────────────── + +/// Dunne verticale scheiding tussen groepen AppBar-knoppen. +class _ActionsDivider extends StatelessWidget { + const _ActionsDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Colors.white24, + ); + } +} + +class _ResizableDivider extends StatefulWidget { + final ValueChanged onDrag; + + const _ResizableDivider({required this.onDrag}); + + @override + State<_ResizableDivider> createState() => _ResizableDividerState(); +} + +class _ResizableDividerState extends State<_ResizableDivider> { + bool _hovered = false; + bool _dragging = false; + + @override + Widget build(BuildContext context) { + final active = _hovered || _dragging; + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (_) => setState(() => _dragging = true), + onHorizontalDragEnd: (_) => setState(() => _dragging = false), + onHorizontalDragCancel: () => setState(() => _dragging = false), + onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx), + child: Tooltip( + message: 'Sleep om de slide-preview breder of smaller te maken', + child: SizedBox( + width: 9, + child: Center( + child: AnimatedContainer( + duration: const Duration(milliseconds: 90), + width: active ? 3 : 1, + color: active ? AppTheme.accent : const Color(0xFFE2E8F0), + ), + ), + ), + ), + ), + ); + } +} + +/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de +/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent +/// bij klikken een keuzelijst met alle niveaus (incl. "Geen"). +class _TlpChip extends StatelessWidget { + final TlpLevel tlp; + final ValueChanged onSelected; + + const _TlpChip({required this.tlp, required this.onSelected}); + + @override + Widget build(BuildContext context) { + final isSet = tlp != TlpLevel.none; + final fg = Color(tlp.foreground); + + final child = Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: isSet ? Colors.black : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isSet) + const Icon(Icons.shield_outlined, size: 14, color: Colors.white70), + if (!isSet) const SizedBox(width: 5), + Text( + isSet ? tlp.label : 'TLP', + style: TextStyle( + color: isSet ? fg : Colors.white70, + fontSize: 11.5, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + letterSpacing: 0.3, + ), + ), + Icon( + Icons.arrow_drop_down, + size: 16, + color: isSet ? fg : Colors.white54, + ), + ], + ), + ); + + return PopupMenuButton( + tooltip: 'TLP-classificatie (Traffic Light Protocol)', + position: PopupMenuPosition.under, + onSelected: onSelected, + itemBuilder: (_) => [ + for (final level in TlpLevel.values) + PopupMenuItem( + value: level, + child: Row( + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: level == TlpLevel.none + ? Colors.transparent + : Color(level.foreground), + border: Border.all(color: const Color(0xFF94A3B8)), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 10), + Text(level.menuLabel), + if (level == tlp) ...[ + const SizedBox(width: 12), + const Spacer(), + const Icon(Icons.check, size: 16, color: Color(0xFF475569)), + ], + ], + ), + ), + ], + child: child, + ); + } +} diff --git a/lib/widgets/dialogs/add_slide_dialog.dart b/lib/widgets/dialogs/add_slide_dialog.dart new file mode 100644 index 0000000..ff66dcd --- /dev/null +++ b/lib/widgets/dialogs/add_slide_dialog.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/slide.dart'; +import '../../theme/app_theme.dart'; + +class AddSlideDialog extends StatelessWidget { + const AddSlideDialog({super.key}); + + static Future show(BuildContext context) { + return showDialog( + context: context, + builder: (_) => const AddSlideDialog(), + ); + } + + static const _types = [ + (SlideType.title, Icons.title, 'Titelpagina'), + (SlideType.section, Icons.bookmark_outline, 'Tussentitel'), + (SlideType.bullets, Icons.format_list_bulleted, 'Alleen Bullets'), + (SlideType.twoBullets, Icons.view_column_outlined, 'Twee Bulletkolommen'), + ( + SlideType.bulletsImage, + Icons.view_agenda_outlined, + 'Bullets + Afbeelding', + ), + (SlideType.twoImages, Icons.auto_stories_outlined, 'Twee Afbeeldingen'), + (SlideType.image, Icons.image_outlined, 'Grote Afbeelding'), + (SlideType.video, Icons.movie_outlined, 'Video'), + (SlideType.quote, Icons.format_quote_outlined, 'Quote'), + (SlideType.table, Icons.table_chart_outlined, 'Tabel'), + (SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'), + ]; + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + Navigator.pop(context), + }, + child: Focus( + autofocus: true, + child: AlertDialog( + title: const Text('Slide type kiezen'), + content: SizedBox( + width: 400, + child: Wrap( + spacing: 10, + runSpacing: 10, + children: _types.map((entry) { + final (type, icon, label) = entry; + return _TypeCard( + icon: icon, + label: label, + onTap: () => Navigator.pop(context, type), + ); + }).toList(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ], + ), + ), + ); + } +} + +class _TypeCard extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _TypeCard({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 110, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 28, color: AppTheme.navy), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 11), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart new file mode 100644 index 0000000..e4e1955 --- /dev/null +++ b/lib/widgets/dialogs/export_dialog.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../services/export_service.dart'; +import '../../services/slide_rasterizer.dart'; + +/// Exports the deck by rendering the on-screen slide previews to images and +/// packing them into a PDF or PPTX (WYSIWYG — the export matches the preview). +class ExportDialog extends StatefulWidget { + final String deckPath; + final List slides; + final ThemeProfile themeProfile; + final String? projectPath; + final ExportService exportService; + final TlpLevel tlp; + + const ExportDialog({ + super.key, + required this.deckPath, + required this.slides, + required this.themeProfile, + required this.projectPath, + required this.exportService, + this.tlp = TlpLevel.none, + }); + + static Future show( + BuildContext context, { + required String deckPath, + required List slides, + required ThemeProfile themeProfile, + required String? projectPath, + required ExportService exportService, + TlpLevel tlp = TlpLevel.none, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ExportDialog( + deckPath: deckPath, + slides: slides, + themeProfile: themeProfile, + projectPath: projectPath, + exportService: exportService, + tlp: tlp, + ), + ); + } + + @override + State createState() => _ExportDialogState(); +} + +class _ExportDialogState extends State { + bool _loading = false; + String? _result; + bool _success = false; + String _phase = ''; + int _done = 0; + int _total = 0; + + Future _export(ExportFormat format) async { + setState(() { + _loading = true; + _result = null; + _phase = 'Slides renderen…'; + _done = 0; + _total = widget.slides.length; + }); + + final images = await SlideRasterizer.rasterize( + context: context, + slides: widget.slides, + themeProfile: widget.themeProfile, + projectPath: widget.projectPath, + tlp: widget.tlp, + onProgress: (done, total) { + if (mounted) setState(() => _done = done); + }, + ); + + if (!mounted) return; + setState(() => _phase = '${format.label} samenstellen…'); + + final r = await widget.exportService.export( + widget.deckPath, + format, + images, + ); + + if (!mounted) return; + setState(() { + _loading = false; + _success = r.success; + _result = r.success ? 'Geëxporteerd naar:\n${r.outputPath}' : r.error; + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Exporteren'), + content: SizedBox(width: 380, child: _content()), + actions: [ + if (_result != null && _success) + TextButton( + onPressed: () => setState(() => _result = null), + child: const Text('Nogmaals exporteren'), + ), + TextButton( + onPressed: _loading ? null : () => Navigator.pop(context), + child: const Text('Sluiten'), + ), + ], + ); + } + + Widget _content() { + if (_loading) { + final fraction = _total == 0 ? null : _done / _total; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _phase, + style: const TextStyle(fontSize: 13, color: Color(0xFF334155)), + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator(value: fraction, minHeight: 6), + ), + const SizedBox(height: 8), + Text( + _total == 0 ? '' : 'Slide $_done van $_total', + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ], + ); + } + + if (_result != null) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _success ? Icons.check_circle : Icons.error_outline, + color: _success ? Colors.green : Colors.red, + size: 36, + ), + const SizedBox(height: 12), + Text( + _result!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: _success ? const Color(0xFF166534) : Colors.red[800], + ), + ), + ], + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'De export gebruikt exact de weergave uit de editor, inclusief je ' + 'stijlprofiel.', + style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), + ), + ), + ...ExportFormat.values.map((f) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: OutlinedButton.icon( + onPressed: () => _export(f), + icon: Icon(_formatIcon(f)), + label: Text('Exporteer als ${f.label}'), + ), + ); + }), + ], + ); + } + + IconData _formatIcon(ExportFormat f) { + switch (f) { + case ExportFormat.pdf: + return Icons.picture_as_pdf_outlined; + case ExportFormat.pptx: + return Icons.slideshow_outlined; + } + } +} diff --git a/lib/widgets/dialogs/find_replace_dialog.dart b/lib/widgets/dialogs/find_replace_dialog.dart new file mode 100644 index 0000000..f0af86a --- /dev/null +++ b/lib/widgets/dialogs/find_replace_dialog.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; + +/// Telt hoe vaak [query] voorkomt in de hele presentatie. +typedef MatchCounter = int Function(String query, bool caseSensitive); + +/// Vervangt alle voorkomens en geeft het aantal vervangingen terug. +typedef ReplaceRunner = + int Function(String query, String replacement, bool caseSensitive); + +/// Zoek-en-vervang over alle slides van de huidige presentatie. +class FindReplaceDialog extends StatefulWidget { + final MatchCounter countMatches; + final ReplaceRunner replaceAll; + + const FindReplaceDialog({ + super.key, + required this.countMatches, + required this.replaceAll, + }); + + static Future show( + BuildContext context, { + required MatchCounter countMatches, + required ReplaceRunner replaceAll, + }) { + return showDialog( + context: context, + builder: (_) => + FindReplaceDialog(countMatches: countMatches, replaceAll: replaceAll), + ); + } + + @override + State createState() => _FindReplaceDialogState(); +} + +class _FindReplaceDialogState extends State { + final _find = TextEditingController(); + final _replace = TextEditingController(); + final _findFocus = FocusNode(); + bool _caseSensitive = false; + int _matches = 0; + int? _replaced; // aantal van de laatste vervang-actie (feedback) + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _findFocus.requestFocus(), + ); + } + + @override + void dispose() { + _find.dispose(); + _replace.dispose(); + _findFocus.dispose(); + super.dispose(); + } + + void _recount() { + setState(() { + _matches = widget.countMatches(_find.text, _caseSensitive); + _replaced = null; + }); + } + + void _runReplace() { + final query = _find.text; + if (query.isEmpty) return; + final n = widget.replaceAll(query, _replace.text, _caseSensitive); + setState(() { + _replaced = n; + // Hertel: meestal 0, tenzij de vervangtekst de zoekterm bevat. + _matches = widget.countMatches(query, _caseSensitive); + }); + } + + @override + Widget build(BuildContext context) { + final hasQuery = _find.text.isNotEmpty; + return AlertDialog( + title: const Text('Zoeken en vervangen'), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _find, + focusNode: _findFocus, + onChanged: (_) => _recount(), + decoration: const InputDecoration( + labelText: 'Zoeken naar', + prefixIcon: Icon(Icons.search, size: 18), + isDense: true, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _replace, + onChanged: (_) => setState(() => _replaced = null), + decoration: const InputDecoration( + labelText: 'Vervangen door', + prefixIcon: Icon(Icons.edit_outlined, size: 18), + isDense: true, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _caseSensitive, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) { + _caseSensitive = v ?? false; + _recount(); + }, + ), + const Text( + 'Hoofdlettergevoelig', + style: TextStyle(fontSize: 13), + ), + const Spacer(), + _statusText(hasQuery), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Sluiten'), + ), + FilledButton.icon( + onPressed: (hasQuery && _matches > 0) ? _runReplace : null, + icon: const Icon(Icons.find_replace, size: 16), + label: const Text('Vervang alles'), + ), + ], + ); + } + + Widget _statusText(bool hasQuery) { + if (_replaced != null) { + return Text( + _replaced == 0 + ? 'Niets vervangen' + : _replaced == 1 + ? '1 vervangen' + : '$_replaced vervangen', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF15803D), + fontWeight: FontWeight.w600, + ), + ); + } + if (!hasQuery) return const SizedBox.shrink(); + return Text( + _matches == 0 + ? 'Geen resultaten' + : _matches == 1 + ? '1 resultaat' + : '$_matches resultaten', + style: TextStyle( + fontSize: 12, + color: _matches == 0 + ? const Color(0xFF94A3B8) + : const Color(0xFF2563EB), + fontWeight: FontWeight.w600, + ), + ); + } +} diff --git a/lib/widgets/dialogs/image_carousel_picker.dart b/lib/widgets/dialogs/image_carousel_picker.dart new file mode 100644 index 0000000..9d7a437 --- /dev/null +++ b/lib/widgets/dialogs/image_carousel_picker.dart @@ -0,0 +1,1619 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:path/path.dart' as p; +import '../../services/caption_service.dart'; +import '../../services/description_service.dart'; +import '../../services/image_service.dart'; + +/// Resultaat van de afbeeldingencarousel. +class ImagePickResult { + final String path; + final String caption; + const ImagePickResult(this.path, this.caption); +} + +/// Geeft per absoluut afbeeldingspad terug waar het in gebruik is +/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden. +typedef ImageUsageLookup = List Function(String absolutePath); + +/// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de +/// header gewisseld worden. +enum _ViewMode { + /// Compact raster — veel afbeeldingen tegelijk in beeld. + grid, + + /// Spectaculaire coverflow — één grote centrale afbeelding met de buren + /// die schuin en geschaald opzij wegvloeien. + cover, +} + +/// Spectaculaire afbeeldingencarousel. +/// Toont alle afbeeldingen uit de opgegeven mappen in een mooi grid. +class ImageCarouselPicker extends StatefulWidget { + final List searchPaths; + final String? initialPath; + final CaptionService captionService; + final DescriptionService descriptionService; + final ImageUsageLookup? usageOf; + + const ImageCarouselPicker({ + super.key, + required this.searchPaths, + required this.captionService, + required this.descriptionService, + this.initialPath, + this.usageOf, + }); + + static Future show( + BuildContext context, { + List searchPaths = const [], + String? initialPath, + CaptionService? captionService, + DescriptionService? descriptionService, + ImageUsageLookup? usageOf, + }) { + return showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.88), + builder: (_) => ImageCarouselPicker( + searchPaths: searchPaths, + initialPath: initialPath, + captionService: captionService ?? CaptionService(), + descriptionService: descriptionService ?? DescriptionService(), + usageOf: usageOf, + ), + ); + } + + @override + State createState() => _ImageCarouselPickerState(); +} + +class _ImageCarouselPickerState extends State { + static const _exts = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + '.heic', + '.tiff', + '.tif', + }; + + /// All discovered images (newest first). + List _images = []; + + /// Images matching the current search query (subset of [_images]). + List _filtered = []; + + /// Absolute image path → searchable description. + Map _descriptions = {}; + + String? _selected; + String _caption = ''; + String _query = ''; + String? _descEditing; // path the description field currently edits + bool _loading = true; + bool _justCopied = false; // korte feedback na kopiëren naar klembord + int _hoveredIndex = -1; + _ViewMode _viewMode = _ViewMode.grid; + + /// Alleen actief in coverflow-modus; bestuurt de horizontale "flow". + PageController? _pageController; + + final _gridScrollController = ScrollController(); + final _captionController = TextEditingController(); + final _descriptionController = TextEditingController(); + final _searchController = TextEditingController(); + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _loadImages(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + } + + @override + void dispose() { + _pageController?.dispose(); + _gridScrollController.dispose(); + _captionController.dispose(); + _descriptionController.dispose(); + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + Future _loadImages() async { + final found = {}; + + for (final path in widget.searchPaths) { + if (path.isEmpty) continue; + final dir = Directory(path); + if (!dir.existsSync()) continue; + try { + await for (final e in dir.list(recursive: true, followLinks: false)) { + if (e is File) { + final ext = p.extension(e.path).toLowerCase(); + if (_exts.contains(ext)) found.add(e.path); + } + } + } catch (_) {} + } + + // Stat each file exactly once (instead of repeatedly inside the sort + // comparator) so large libraries stay responsive. + final withTimes = <(String, DateTime)>[]; + for (final path in found) { + DateTime modified; + try { + modified = File(path).statSync().modified; + } catch (_) { + modified = DateTime.fromMillisecondsSinceEpoch(0); + } + withTimes.add((path, modified)); + } + withTimes.sort((a, b) => b.$2.compareTo(a.$2)); + final sorted = [for (final e in withTimes) e.$1]; + + final descriptions = await widget.descriptionService.loadFor(sorted); + + if (!mounted) return; + setState(() { + _images = sorted; + _descriptions = descriptions; + _loading = false; + _selected = + widget.initialPath ?? (sorted.isNotEmpty ? sorted.first : null); + _applyFilter(); + }); + await _loadCaptionForSelection(); + _loadDescriptionForSelection(); + } + + /// Recompute [_filtered] from [_images] and the current query. Matches on + /// file name and stored description (case-insensitive, all terms must hit) + /// and ranks the hits on relevance so dat een korte zoekterm als "kl" de + /// KLM-afbeelding meteen bovenaan toont in plaats van verzopen tussen alle + /// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van + /// [_images] (nieuwste eerst) behouden. + void _applyFilter() { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) { + _filtered = _images; + return; + } + final terms = q + .split(RegExp(r'\s+')) + .where((t) => t.isNotEmpty) + .toList(growable: false); + + final hits = <({String path, int score, int order})>[]; + for (var i = 0; i < _images.length; i++) { + final score = _relevance(_images[i], terms); + if (score > 0) hits.add((path: _images[i], score: score, order: i)); + } + hits.sort((a, b) { + final byScore = b.score.compareTo(a.score); + return byScore != 0 ? byScore : a.order.compareTo(b.order); + }); + _filtered = [for (final h in hits) h.path]; + } + + /// Relevantiescore voor één afbeelding tegen alle zoektermen. Geeft 0 terug + /// zodra één term nergens voorkomt (dan valt de afbeelding uit het filter). + /// Hoger = relevanter; per term telt de sterkste match mee. + int _relevance(String path, List terms) { + final name = p.basenameWithoutExtension(path).toLowerCase(); + final desc = (_descriptions[path] ?? '').toLowerCase(); + final splitter = RegExp(r'[^a-z0-9]+'); + final nameWords = name.split(splitter).where((w) => w.isNotEmpty); + final descWords = desc.split(splitter).where((w) => w.isNotEmpty); + + var total = 0; + for (final t in terms) { + var best = 0; + if (name == t) { + best = 1000; // bestandsnaam is exact de zoekterm + } else if (nameWords.contains(t)) { + best = 600; // heel woord in de naam ("klm") + } else if (nameWords.any((w) => w.startsWith(t))) { + best = 400; // woord in de naam begint met de term ("kl" → "klm") + } else if (name.contains(t)) { + best = 200; // term zit ergens in de naam + } + if (best < 600) { + if (descWords.contains(t)) { + best = best < 500 ? 500 : best; // heel woord in de beschrijving + } else if (descWords.any((w) => w.startsWith(t))) { + best = best < 300 ? 300 : best; // woord-prefix in de beschrijving + } else if (desc.contains(t)) { + best = best < 100 ? 100 : best; // substring in de beschrijving + } + } + if (best == 0) return 0; // deze term matcht nergens → wegfilteren + total += best; + } + return total; + } + + void _onSearchChanged(String value) { + setState(() { + _query = value; + _applyFilter(); + }); + // De indexen zijn verschoven; coverflow opnieuw uitlijnen na de rebuild. + WidgetsBinding.instance.addPostFrameCallback( + (_) => _syncCoverToSelection(), + ); + } + + Future _confirm() async { + if (_selected == null) return; + await _persistDescription(); + await widget.captionService.saveCaption(_selected!, _caption); + if (mounted) { + Navigator.pop(context, ImagePickResult(_selected!, _caption.trim())); + } + } + + /// Persist the description currently in the editor, then close the dialog. + Future _close([ImagePickResult? result]) async { + await _persistDescription(); + if (mounted) Navigator.pop(context, result); + } + + Future _persistDescription() async { + final path = _descEditing; + if (path == null) return; + final text = _descriptionController.text.trim(); + _descriptions[path] = text; + await widget.descriptionService.saveDescription(path, text); + } + + void _loadDescriptionForSelection() { + final path = _selected; + _descEditing = path; + _descriptionController.text = path == null + ? '' + : (_descriptions[path] ?? ''); + } + + Future _browse() async { + final result = await FilePicker.pickFiles( + type: FileType.image, + dialogTitle: 'Kies een afbeelding', + ); + if (result?.files.single.path != null && mounted) { + final path = result!.files.single.path!; + final caption = await widget.captionService.getCaption(path) ?? ''; + await _close(ImagePickResult(path, caption)); + } + } + + Future _select(String path) async { + await _persistDescription(); + setState(() => _selected = path); + await _loadCaptionForSelection(); + _loadDescriptionForSelection(); + } + + Future _loadCaptionForSelection() async { + final path = _selected; + final caption = path == null + ? '' + : (await widget.captionService.getCaption(path) ?? ''); + if (!mounted || path != _selected) return; + setState(() { + _caption = caption; + _captionController.text = caption; + }); + } + + void _moveSelection(int delta) { + if (_filtered.isEmpty) return; + final current = _selected == null ? -1 : _filtered.indexOf(_selected!); + final next = (current + delta).clamp(0, _filtered.length - 1); + if (_viewMode == _ViewMode.cover && _pageController?.hasClients == true) { + // De PageView is leidend: animeren triggert onPageChanged → _select. + _pageController!.animateToPage( + next, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + return; + } + _select(_filtered[next]); + _scrollToIndex(next); + } + + /// Wissel tussen raster- en coverflow-weergave. Maakt (of ruimt) de + /// PageController op en zet de flow op de huidige selectie. + void _setViewMode(_ViewMode mode) { + if (mode == _viewMode) return; + setState(() { + _viewMode = mode; + _pageController?.dispose(); + if (mode == _ViewMode.cover) { + final idx = _selected == null ? 0 : _filtered.indexOf(_selected!); + _pageController = PageController( + initialPage: idx < 0 ? 0 : idx, + viewportFraction: 0.62, + ); + } else { + _pageController = null; + } + }); + } + + /// Zet de coverflow zonder animatie op de huidige selectie. Nodig nadat het + /// filter de lijst (en dus de indexen) heeft veranderd. + void _syncCoverToSelection() { + if (_viewMode != _ViewMode.cover) return; + final controller = _pageController; + if (controller == null || !controller.hasClients) return; + final idx = _selected == null ? 0 : _filtered.indexOf(_selected!); + controller.jumpToPage(idx < 0 ? 0 : idx); + } + + /// Kopieer de geselecteerde afbeelding naar het klembord (om elders te + /// plakken) met korte "Gekopieerd"-feedback op de knop. + Future _copySelectedToClipboard() async { + final path = _selected; + if (path == null) return; + final ok = await ImageService().copyImageToClipboard(path); + if (!mounted) return; + if (ok) { + setState(() => _justCopied = true); + Future.delayed(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _justCopied = false); + }); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Kopiëren naar klembord mislukt.')), + ); + } + } + + Future _deleteSelected() async { + final path = _selected; + if (path == null) return; + final usages = widget.usageOf?.call(path) ?? const []; + final confirmed = await _showDeleteDialog(path, usages); + if (confirmed != true) return; + + try { + final file = File(path); + if (file.existsSync()) await file.delete(); + } catch (_) {} + // Drop the sidecar metadata too. + await widget.captionService.saveCaption(path, ''); + await widget.descriptionService.removeDescription(path); + + if (!mounted) return; + final idx = _images.indexOf(path); + setState(() { + _images = List.of(_images)..remove(path); + _descriptions.remove(path); + _descEditing = null; + if (_selected == path) { + _selected = _images.isEmpty + ? null + : _images[idx.clamp(0, _images.length - 1)]; + } + _applyFilter(); + }); + await _loadCaptionForSelection(); + _loadDescriptionForSelection(); + } + + Future _showDeleteDialog(String path, List usages) { + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: const Color(0xFF161B22), + title: Row( + children: [ + Icon( + usages.isEmpty + ? Icons.delete_outline + : Icons.warning_amber_rounded, + color: usages.isEmpty + ? const Color(0xFFE5534B) + : const Color(0xFFF0B429), + size: 20, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Afbeelding verwijderen?', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.basename(path), + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + if (usages.isEmpty) + const Text( + 'Het bestand wordt permanent van schijf verwijderd. ' + 'Deze actie kan niet ongedaan worden gemaakt.', + style: TextStyle(color: Color(0xFF8B949E), fontSize: 13), + ) + else ...[ + Text( + 'Let op: deze afbeelding wordt nog gebruikt in ' + '${usages.length} ${usages.length == 1 ? "slide" : "slides"}:', + style: const TextStyle( + color: Color(0xFFF0B429), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final u in usages) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + '• $u', + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 12.5, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 10), + const Text( + 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan ' + 'worden gemaakt.', + style: TextStyle(color: Color(0xFF8B949E), fontSize: 13), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF8B949E), + ), + child: const Text('Annuleren'), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('Verwijderen'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB62324), + foregroundColor: Colors.white, + ), + ), + ], + ), + ); + } + + void _scrollToIndex(int index) { + // Approximate thumbnail height for 3-column grid + const cols = 3; + const thumbH = 160.0; + const spacing = 12.0; + final row = index ~/ cols; + final offset = row * (thumbH + spacing); + _gridScrollController.animateTo( + offset.clamp(0.0, _gridScrollController.position.maxScrollExtent), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + // ── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => _close(), + const SingleActivator(LogicalKeyboardKey.enter): () => _confirm(), + const SingleActivator(LogicalKeyboardKey.arrowRight): () => + _moveSelection(1), + const SingleActivator(LogicalKeyboardKey.arrowLeft): () => + _moveSelection(-1), + const SingleActivator(LogicalKeyboardKey.arrowDown): () => + _moveSelection(3), + const SingleActivator(LogicalKeyboardKey.arrowUp): () => + _moveSelection(-3), + }, + child: Focus( + focusNode: _focusNode, + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.all(32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1160, maxHeight: 780), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFF0D1117), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFF21262D), width: 1), + ), + child: Column( + children: [ + _buildHeader(), + Expanded( + child: _loading + ? _buildLoading() + : Row( + children: [ + _viewMode == _ViewMode.cover + ? _buildCover() + : _buildGrid(), + _buildPreview(), + ], + ), + ), + _buildFooter(), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildLoading() { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Color(0xFF3B82F6)), + SizedBox(height: 16), + Text( + 'Afbeeldingen laden…', + style: TextStyle(color: Color(0xFF8B949E), fontSize: 14), + ), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Color(0xFF21262D))), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF1D2433), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.photo_library_outlined, + color: Color(0xFF60A5FA), + size: 18, + ), + ), + const SizedBox(width: 14), + const Text( + 'Afbeelding kiezen', + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF21262D), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _query.trim().isEmpty + ? '${_images.length}' + : '${_filtered.length} / ${_images.length}', + style: const TextStyle( + color: Color(0xFF8B949E), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 16), + Expanded(child: _buildSearchField()), + const SizedBox(width: 12), + _buildViewToggle(), + const SizedBox(width: 12), + IconButton( + icon: const Icon(Icons.close, color: Color(0xFF6E7681), size: 20), + onPressed: () => _close(), + tooltip: 'Sluiten (Esc)', + ), + ], + ), + ); + } + + Widget _buildSearchField() { + return SizedBox( + height: 36, + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + style: const TextStyle(color: Color(0xFFCDD9E5), fontSize: 13), + decoration: InputDecoration( + hintText: 'Zoek op naam of beschrijving…', + hintStyle: const TextStyle(color: Color(0xFF6E7681), fontSize: 13), + prefixIcon: const Icon( + Icons.search, + color: Color(0xFF6E7681), + size: 18, + ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon( + Icons.clear, + color: Color(0xFF6E7681), + size: 16, + ), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ), + isDense: true, + filled: true, + fillColor: const Color(0xFF0D1117), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF30363D)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF30363D)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF3B82F6)), + ), + ), + ), + ); + } + + /// Segmented control om tussen raster- en coverflow-weergave te wisselen. + Widget _buildViewToggle() { + Widget seg(_ViewMode mode, IconData icon, String tip) { + final active = _viewMode == mode; + return Tooltip( + message: tip, + child: GestureDetector( + onTap: () => _setViewMode(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: active ? const Color(0xFF1D2433) : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + icon, + size: 17, + color: active ? const Color(0xFF60A5FA) : const Color(0xFF6E7681), + ), + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: const Color(0xFF0D1117), + borderRadius: BorderRadius.circular(9), + border: Border.all(color: const Color(0xFF30363D)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + seg(_ViewMode.grid, Icons.grid_view_rounded, 'Raster'), + const SizedBox(width: 3), + seg(_ViewMode.cover, Icons.view_carousel_rounded, 'Coverflow'), + ], + ), + ); + } + + /// Lege staat — gedeeld door raster- en coverflow-weergave. + Widget _buildEmptyState() { + final filtering = _query.trim().isNotEmpty; + return Expanded( + flex: 13, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.image_search_outlined, + size: 56, + color: Color(0xFF30363D), + ), + ), + const SizedBox(height: 20), + Text( + filtering + ? 'Geen resultaten voor "${_query.trim()}"' + : 'Geen afbeeldingen gevonden', + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + filtering + ? 'Pas je zoekterm aan of voeg een beschrijving toe.' + : 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.', + style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13), + ), + ], + ), + ), + ); + } + + Widget _buildGrid() { + if (_filtered.isEmpty) return _buildEmptyState(); + + return Expanded( + flex: 13, + child: Container( + decoration: const BoxDecoration( + border: Border(right: BorderSide(color: Color(0xFF21262D))), + ), + child: GridView.builder( + controller: _gridScrollController, + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 4 / 3, + ), + itemCount: _filtered.length, + itemBuilder: (_, i) => _buildThumbnail(i), + ), + ), + ); + } + + Widget _buildThumbnail(int index) { + final path = _filtered[index]; + final isSelected = path == _selected; + final isHovered = index == _hoveredIndex; + final name = p.basenameWithoutExtension(path); + + return MouseRegion( + onEnter: (_) => setState(() => _hoveredIndex = index), + onExit: (_) => setState(() => _hoveredIndex = -1), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => _select(path), + onDoubleTap: () async { + await _select(path); + await _confirm(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + transform: Matrix4.identity() + ..scaleByDouble( + isHovered && !isSelected ? 1.03 : 1.0, + isHovered && !isSelected ? 1.03 : 1.0, + 1, + 1, + ), + transformAlignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected + ? const Color(0xFF3B82F6) + : isHovered + ? const Color(0xFF58A6FF) + : const Color(0xFF21262D), + width: isSelected + ? 2.5 + : isHovered + ? 1.5 + : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color(0xFF3B82F6).withValues(alpha: 0.35), + blurRadius: 16, + spreadRadius: 1, + ), + ] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8.5), + child: Stack( + fit: StackFit.expand, + children: [ + // Thumbnail + Image.file( + File(path), + fit: BoxFit.cover, + cacheWidth: 360, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFF161B22), + child: const Icon( + Icons.broken_image_outlined, + color: Color(0xFF30363D), + size: 32, + ), + ), + ), + // Hover-glans overlay + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: isHovered && !isSelected ? 0.12 : 0, + child: Container(color: Colors.white), + ), + // Naam onderaan + Positioned( + bottom: 0, + left: 0, + right: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: isHovered || isSelected ? 1 : 0, + child: Container( + padding: const EdgeInsets.fromLTRB(8, 18, 8, 7), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.82), + ], + ), + ), + child: Text( + name, + style: const TextStyle( + color: Colors.white, + fontSize: 10.5, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + // Selectie-vinkje + if (isSelected) + Positioned( + top: 8, + right: 8, + child: Container( + width: 22, + height: 22, + decoration: const BoxDecoration( + color: Color(0xFF3B82F6), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Color(0xFF1D4ED8), blurRadius: 6), + ], + ), + child: const Icon( + Icons.check, + size: 13, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + // ── Coverflow ─────────────────────────────────────────────────────────── + + Widget _buildCover() { + if (_filtered.isEmpty) return _buildEmptyState(); + + final controller = _pageController; + final selectedIndex = _selected == null + ? -1 + : _filtered.indexOf(_selected!); + + return Expanded( + flex: 13, + child: Container( + decoration: const BoxDecoration( + // Subtiele verticale gloed voor de "podium"-look. + gradient: RadialGradient( + center: Alignment(0, -0.15), + radius: 1.1, + colors: [Color(0xFF161D2B), Color(0xFF0B0F16)], + ), + border: Border(right: BorderSide(color: Color(0xFF21262D))), + ), + child: Column( + children: [ + Expanded( + child: Stack( + alignment: Alignment.center, + children: [ + if (controller != null) + PageView.builder( + controller: controller, + itemCount: _filtered.length, + onPageChanged: (i) { + if (i >= 0 && i < _filtered.length) { + _select(_filtered[i]); + } + }, + itemBuilder: (_, i) => _buildCoverCard(i, controller), + ), + // Navigatiepijlen links/rechts. + Positioned( + left: 12, + child: _coverArrow( + Icons.chevron_left_rounded, + selectedIndex > 0, + () => _moveSelection(-1), + ), + ), + Positioned( + right: 12, + child: _coverArrow( + Icons.chevron_right_rounded, + selectedIndex >= 0 && + selectedIndex < _filtered.length - 1, + () => _moveSelection(1), + ), + ), + ], + ), + ), + _buildCoverStrip(selectedIndex), + ], + ), + ), + ); + } + + /// Eén kaart in de flow. De schaal, perspectiefdraaiing en transparantie + /// hangen af van de afstand tot het midden van de viewport. + Widget _buildCoverCard(int index, PageController controller) { + final path = _filtered[index]; + final isSelected = path == _selected; + final name = p.basenameWithoutExtension(path); + + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + // Hoever staat deze kaart van het midden? (0 = gecentreerd) + double page; + if (controller.hasClients && controller.position.haveDimensions) { + page = controller.page ?? controller.initialPage.toDouble(); + } else { + page = controller.initialPage.toDouble(); + } + final delta = (page - index).clamp(-1.5, 1.5); + final dist = delta.abs(); + final centered = (1 - dist.clamp(0.0, 1.0)); + + final scale = 0.74 + 0.26 * centered; + final opacity = 0.35 + 0.65 * centered; + final rotateY = delta * 0.55; // radialen, perspectief + + return Center( + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.0014) // perspectief + ..rotateY(-rotateY) + ..scaleByDouble(scale, scale, 1, 1), + child: child, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 8), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + // Klik op een buur centreert die; klik op het midden bevestigt. + onTap: () { + if (isSelected) { + _confirm(); + } else { + final target = _filtered.indexOf(path); + if (target >= 0 && controller.hasClients) { + controller.animateToPage( + target, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } + } + }, + onDoubleTap: () async { + await _select(path); + await _confirm(); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? const Color(0xFF3B82F6) + : const Color(0xFF21262D), + width: isSelected ? 2.5 : 1, + ), + boxShadow: [ + BoxShadow( + color: isSelected + ? const Color(0xFF3B82F6).withValues(alpha: 0.45) + : Colors.black.withValues(alpha: 0.55), + blurRadius: isSelected ? 40 : 24, + spreadRadius: isSelected ? 2 : 0, + offset: const Offset(0, 16), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Stack( + fit: StackFit.expand, + children: [ + Image.file( + File(path), + fit: BoxFit.cover, + cacheWidth: 1000, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFF161B22), + child: const Icon( + Icons.broken_image_outlined, + color: Color(0xFF30363D), + size: 48, + ), + ), + ), + // Naamlabel onderaan de centrale kaart. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: isSelected ? 1 : 0, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 30, 16, 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.78), + ], + ), + ), + child: Text( + name, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _coverArrow(IconData icon, bool enabled, VoidCallback onTap) { + return AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: enabled ? 1 : 0.0, + child: IgnorePointer( + ignoring: !enabled, + child: Material( + color: const Color(0xFF161B22).withValues(alpha: 0.85), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF30363D)), + ), + child: Icon(icon, color: const Color(0xFFCDD9E5), size: 24), + ), + ), + ), + ), + ); + } + + /// Positie-indicator onder de flow ("3 / 28") plus een dunne voortgangsbalk. + Widget _buildCoverStrip(int selectedIndex) { + final total = _filtered.length; + final pos = selectedIndex < 0 ? 0 : selectedIndex; + final progress = total <= 1 ? 1.0 : pos / (total - 1); + + return Padding( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Stack( + children: [ + Container(height: 3, color: const Color(0xFF21262D)), + FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + height: 3, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF3B82F6), Color(0xFF60A5FA)], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + '${pos + 1} / $total', + style: const TextStyle( + color: Color(0xFF8B949E), + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + ], + ), + ); + } + + Widget _buildPreview() { + return SizedBox( + width: 300, + child: Container( + color: const Color(0xFF080D14), + child: _selected == null + ? const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.touch_app_outlined, + size: 40, + color: Color(0xFF30363D), + ), + SizedBox(height: 12), + Text( + 'Selecteer een\nafbeelding', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF6E7681), + fontSize: 13, + height: 1.5, + ), + ), + ], + ), + ) + : Column( + children: [ + // Grote preview + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(_selected!), + fit: BoxFit.contain, + // Cap decode resolution: the preview pane is narrow, + // so full-resolution decodes would waste memory. + cacheWidth: 720, + errorBuilder: (context, error, stackTrace) => + const Center( + child: Icon( + Icons.broken_image, + color: Color(0xFF30363D), + size: 48, + ), + ), + ), + ), + ), + ), + // Bestandsinfo + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF161B22), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFF21262D), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + p.basename(_selected!), + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 13, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + _formatPath(_selected!), + style: const TextStyle( + color: Color(0xFF6E7681), + fontSize: 10.5, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + _FileSize(path: _selected!), + const SizedBox(height: 12), + TextField( + controller: _captionController, + minLines: 1, + maxLines: 3, + onChanged: (value) => _caption = value, + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 12, + ), + decoration: InputDecoration( + hintText: 'Caption / bronvermelding', + hintStyle: const TextStyle( + color: Color(0xFF6E7681), + fontSize: 12, + ), + prefixIcon: const Icon( + Icons.copyright_outlined, + color: Color(0xFF6E7681), + size: 16, + ), + isDense: true, + filled: true, + fillColor: const Color(0xFF0D1117), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF30363D), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF30363D), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + ), + ), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _descriptionController, + minLines: 1, + maxLines: 3, + onChanged: (value) => + _descriptions[_selected!] = value.trim(), + style: const TextStyle( + color: Color(0xFFCDD9E5), + fontSize: 12, + ), + decoration: InputDecoration( + hintText: 'Beschrijving (doorzoekbaar)', + hintStyle: const TextStyle( + color: Color(0xFF6E7681), + fontSize: 12, + ), + prefixIcon: const Icon( + Icons.sell_outlined, + color: Color(0xFF6E7681), + size: 16, + ), + isDense: true, + filled: true, + fillColor: const Color(0xFF0D1117), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF30363D), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF30363D), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + TextButton.icon( + onPressed: _justCopied + ? null + : _copySelectedToClipboard, + icon: Icon( + _justCopied + ? Icons.check + : Icons.content_copy_outlined, + size: 16, + ), + label: Text( + _justCopied ? 'Gekopieerd' : 'Kopiëren', + ), + style: TextButton.styleFrom( + foregroundColor: _justCopied + ? const Color(0xFF22C55E) + : const Color(0xFF8B949E), + disabledForegroundColor: const Color( + 0xFF22C55E, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + ), + ), + const Spacer(), + TextButton.icon( + onPressed: _deleteSelected, + icon: const Icon( + Icons.delete_outline, + size: 16, + ), + label: const Text('Verwijderen'), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFE5746E), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFooter() { + return Container( + height: 64, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Color(0xFF21262D))), + ), + child: Row( + children: [ + // Bladeren knop + OutlinedButton.icon( + onPressed: _browse, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: const Text('Bladeren…'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF8B949E), + side: const BorderSide(color: Color(0xFF30363D)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ), + const SizedBox(width: 8), + // Hint + const Text( + '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert', + style: TextStyle(color: Color(0xFF484F58), fontSize: 11), + ), + const Spacer(), + // Annuleren + TextButton( + onPressed: () => _close(), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF8B949E), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + child: const Text('Annuleren'), + ), + const SizedBox(width: 10), + // Kiezen + ElevatedButton.icon( + onPressed: _selected != null ? () => _confirm() : null, + icon: const Icon(Icons.check_circle_outline, size: 17), + label: const Text('Kiezen'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF238636), + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFF21262D), + disabledForegroundColor: const Color(0xFF484F58), + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 10), + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } + + String _formatPath(String path) { + final home = Platform.environment['HOME'] ?? ''; + if (home.isNotEmpty && path.startsWith(home)) { + return '~${path.substring(home.length)}'; + } + return path; + } +} + +// ── Bestandsgrootte widget ──────────────────────────────────────────────────── + +class _FileSize extends StatefulWidget { + final String path; + const _FileSize({required this.path}); + + @override + State<_FileSize> createState() => _FileSizeState(); +} + +class _FileSizeState extends State<_FileSize> { + String _size = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(_FileSize old) { + super.didUpdateWidget(old); + if (old.path != widget.path) _load(); + } + + Future _load() async { + try { + final stat = await File(widget.path).stat(); + final bytes = stat.size; + final kb = bytes / 1024; + final mb = kb / 1024; + final label = mb >= 1 + ? '${mb.toStringAsFixed(1)} MB' + : '${kb.toStringAsFixed(0)} KB'; + if (mounted) setState(() => _size = label); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + if (_size.isEmpty) return const SizedBox.shrink(); + return Text( + _size, + style: const TextStyle( + color: Color(0xFF3B82F6), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ); + } +} diff --git a/lib/widgets/dialogs/import_slides_dialog.dart b/lib/widgets/dialogs/import_slides_dialog.dart new file mode 100644 index 0000000..f7b86d7 --- /dev/null +++ b/lib/widgets/dialogs/import_slides_dialog.dart @@ -0,0 +1,466 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../services/file_service.dart'; +import '../../theme/app_theme.dart'; +import '../slides/slide_preview.dart'; + +/// Dialog that scans a directory for other Marp presentations, lets the user +/// search across them and pick individual slides to import. Returns the +/// selected slides (with image paths resolved to absolute paths) or null when +/// the dialog was cancelled. +class ImportSlidesDialog extends StatefulWidget { + final FileService fileService; + final String? initialDirectory; + final String? excludePath; + + const ImportSlidesDialog({ + super.key, + required this.fileService, + required this.initialDirectory, + this.excludePath, + }); + + static Future?> show( + BuildContext context, { + required FileService fileService, + required String? initialDirectory, + String? excludePath, + }) { + return showDialog>( + context: context, + builder: (_) => ImportSlidesDialog( + fileService: fileService, + initialDirectory: initialDirectory, + excludePath: excludePath, + ), + ); + } + + @override + State createState() => _ImportSlidesDialogState(); +} + +class _ImportSlidesDialogState extends State { + String? _directory; + bool _loading = false; + List _presentations = const []; + final Set _selectedIds = {}; + String _query = ''; + + @override + void initState() { + super.initState(); + _directory = widget.initialDirectory; + if (_directory != null) _scan(); + } + + Future _scan() async { + final dir = _directory; + if (dir == null) return; + setState(() => _loading = true); + final results = await widget.fileService.scanPresentations( + dir, + excludePath: widget.excludePath, + ); + if (!mounted) return; + setState(() { + _presentations = results; + _loading = false; + }); + } + + Future _pickDirectory() async { + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Map met presentaties kiezen', + initialDirectory: _directory, + ); + if (result != null) { + setState(() { + _directory = result; + _selectedIds.clear(); + }); + await _scan(); + } + } + + String _searchText(Slide slide) { + return [ + slide.title, + slide.subtitle, + ...slide.bullets, + slide.quote, + slide.quoteAuthor, + slide.customMarkdown, + slide.type.label, + ].join(' ').toLowerCase(); + } + + /// Returns, per presentation, the slides that should be shown for the + /// current query (preserving document order). + List<(ScannedPresentation, List)> _visible() { + final q = _query.trim().toLowerCase(); + final out = <(ScannedPresentation, List)>[]; + for (final pres in _presentations) { + if (q.isEmpty) { + out.add((pres, pres.deck.slides)); + continue; + } + final nameMatch = + pres.deck.title.toLowerCase().contains(q) || + pres.fileName.toLowerCase().contains(q); + if (nameMatch) { + out.add((pres, pres.deck.slides)); + continue; + } + final matching = pres.deck.slides + .where((s) => _searchText(s).contains(q)) + .toList(); + if (matching.isNotEmpty) out.add((pres, matching)); + } + return out; + } + + List _collectSelected() { + final result = []; + for (final pres in _presentations) { + for (final slide in pres.deck.slides) { + if (!_selectedIds.contains(slide.id)) continue; + final resolved = _resolveImage(slide.imagePath, pres.deck.projectPath); + result.add( + resolved == slide.imagePath + ? slide + : slide.copyWith(imagePath: resolved), + ); + } + } + return result; + } + + String _resolveImage(String imagePath, String? projectPath) { + if (imagePath.isEmpty) return imagePath; + if (p.isAbsolute(imagePath)) return imagePath; + if (projectPath != null) return p.join(projectPath, imagePath); + return imagePath; + } + + @override + Widget build(BuildContext context) { + final visible = _visible(); + final selectedCount = _selectedIds.length; + + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.library_add_outlined, size: 20), + const SizedBox(width: 8), + const Text('Slides importeren'), + const Spacer(), + if (selectedCount > 0) + Text( + '$selectedCount geselecteerd', + style: const TextStyle( + fontSize: 12, + color: AppTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + content: SizedBox( + width: 760, + height: 560, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _toolbar(), + const SizedBox(height: 12), + Expanded(child: _body(visible)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ElevatedButton.icon( + onPressed: selectedCount == 0 + ? null + : () => Navigator.pop(context, _collectSelected()), + icon: const Icon(Icons.download_done, size: 16), + label: Text( + selectedCount == 0 ? 'Importeren' : 'Importeren ($selectedCount)', + ), + ), + ], + ); + } + + Widget _toolbar() { + return Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + decoration: const InputDecoration( + isDense: true, + prefixIcon: Icon(Icons.search, size: 18), + hintText: 'Zoek op presentatie, titel of tekst…', + ), + onChanged: (v) => setState(() => _query = v), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: _directory ?? 'Geen map gekozen', + child: OutlinedButton.icon( + onPressed: _pickDirectory, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: Text( + _directory == null ? 'Map kiezen' : p.basename(_directory!), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _body(List<(ScannedPresentation, List)> visible) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_directory == null) { + return _empty( + Icons.folder_off_outlined, + 'Kies een map met presentaties om te beginnen.', + ); + } + if (_presentations.isEmpty) { + return _empty( + Icons.search_off_outlined, + 'Geen andere presentaties (.md) in deze map gevonden.', + ); + } + if (visible.isEmpty) { + return _empty( + Icons.search_off_outlined, + 'Geen slides gevonden voor "$_query".', + ); + } + + return ListView.builder( + itemCount: visible.length, + itemBuilder: (_, i) { + final (pres, slides) = visible[i]; + return _PresentationSection( + presentation: pres, + slides: slides, + selectedIds: _selectedIds, + onToggle: (slide) => setState(() { + if (!_selectedIds.remove(slide.id)) _selectedIds.add(slide.id); + }), + onToggleAll: (sel) => setState(() { + for (final s in slides) { + if (sel) { + _selectedIds.add(s.id); + } else { + _selectedIds.remove(s.id); + } + } + }), + ); + }, + ); + } + + Widget _empty(IconData icon, String message) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: const Color(0xFF94A3B8)), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B), fontSize: 13), + ), + ], + ), + ); + } +} + +// ── One presentation with its (filtered) slides ────────────────────────────── + +class _PresentationSection extends StatelessWidget { + final ScannedPresentation presentation; + final List slides; + final Set selectedIds; + final ValueChanged onToggle; + final ValueChanged onToggleAll; + + const _PresentationSection({ + required this.presentation, + required this.slides, + required this.selectedIds, + required this.onToggle, + required this.onToggleAll, + }); + + @override + Widget build(BuildContext context) { + final allSelected = + slides.isNotEmpty && slides.every((s) => selectedIds.contains(s.id)); + final deck = presentation.deck; + + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.slideshow_outlined, + size: 16, + color: AppTheme.navy, + ), + const SizedBox(width: 6), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: deck.title.isEmpty + ? presentation.fileName + : deck.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + ), + TextSpan( + text: ' ${presentation.fileName}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + ), + ), + ], + ), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: () => onToggleAll(!allSelected), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + child: Text( + allSelected ? 'Deselecteer alles' : 'Selecteer alles', + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final slide in slides) + _SlideCard( + slide: slide, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + selected: selectedIds.contains(slide.id), + onTap: () => onToggle(slide), + ), + ], + ), + ], + ), + ); + } +} + +// ── Selectable slide thumbnail ─────────────────────────────────────────────── + +class _SlideCard extends StatelessWidget { + final Slide slide; + final String? projectPath; + final ThemeProfile themeProfile; + final bool selected; + final VoidCallback onTap; + + const _SlideCard({ + required this.slide, + required this.projectPath, + required this.themeProfile, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: SizedBox( + width: 168, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected ? AppTheme.accent : const Color(0xFFCBD5E1), + width: selected ? 2.5 : 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: AspectRatio( + aspectRatio: 16 / 9, + child: SlidePreviewWidget( + slide: slide, + projectPath: projectPath, + themeProfile: themeProfile, + ), + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: AnimatedOpacity( + opacity: selected ? 1 : 0.55, + duration: const Duration(milliseconds: 120), + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: selected ? AppTheme.accent : Colors.black38, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + child: Icon( + selected ? Icons.check : Icons.add, + size: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/new_deck_dialog.dart b/lib/widgets/dialogs/new_deck_dialog.dart new file mode 100644 index 0000000..ab4f2aa --- /dev/null +++ b/lib/widgets/dialogs/new_deck_dialog.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class NewDeckDialog extends StatefulWidget { + const NewDeckDialog({super.key}); + + static Future show(BuildContext context) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const NewDeckDialog(), + ); + } + + @override + State createState() => _NewDeckDialogState(); +} + +class _NewDeckDialogState extends State { + final _ctrl = TextEditingController(); + final _formKey = GlobalKey(); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + Navigator.pop(context), + }, + child: AlertDialog( + title: const Text('Nieuwe presentatie'), + content: Form( + key: _formKey, + child: SizedBox( + width: 380, + child: TextFormField( + controller: _ctrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Titel', + hintText: 'Bijv. Kwartaalupdate Q4', + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Vul een titel in' : null, + onFieldSubmitted: (_) => _submit(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ElevatedButton(onPressed: _submit, child: const Text('Aanmaken')), + ], + ), + ); + } + + void _submit() { + if (_formKey.currentState!.validate()) { + Navigator.pop(context, _ctrl.text.trim()); + } + } +} diff --git a/lib/widgets/dialogs/open_presentation_dialog.dart b/lib/widgets/dialogs/open_presentation_dialog.dart new file mode 100644 index 0000000..f70c1ac --- /dev/null +++ b/lib/widgets/dialogs/open_presentation_dialog.dart @@ -0,0 +1,422 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import '../../models/slide.dart'; +import '../../services/file_service.dart'; +import '../../theme/app_theme.dart'; + +/// What the open dialog returns: a presentation path and, optionally, the +/// index of a slide to jump to (when the user picked a search hit). +class OpenSearchResult { + final String path; + final int? slideIndex; + const OpenSearchResult(this.path, {this.slideIndex}); +} + +/// Dialog that scans a directory for Marp presentations and lets the user +/// full-text search across the `.md` files (file name, title and slide text) +/// before opening one. "Bladeren…" falls back to the native file picker. +class OpenPresentationDialog extends StatefulWidget { + final FileService fileService; + final String? initialDirectory; + + const OpenPresentationDialog({ + super.key, + required this.fileService, + required this.initialDirectory, + }); + + static Future show( + BuildContext context, { + required FileService fileService, + required String? initialDirectory, + }) { + return showDialog( + context: context, + builder: (_) => OpenPresentationDialog( + fileService: fileService, + initialDirectory: initialDirectory, + ), + ); + } + + @override + State createState() => _OpenPresentationDialogState(); +} + +class _OpenPresentationDialogState extends State { + String? _directory; + bool _loading = false; + List _presentations = const []; + String _query = ''; + + @override + void initState() { + super.initState(); + _directory = widget.initialDirectory; + if (_directory != null) _scan(); + } + + Future _scan() async { + final dir = _directory; + if (dir == null) return; + setState(() => _loading = true); + final results = await widget.fileService.scanPresentations(dir); + if (!mounted) return; + setState(() { + _presentations = results; + _loading = false; + }); + } + + Future _pickDirectory() async { + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Map met presentaties kiezen', + initialDirectory: _directory, + ); + if (result != null) { + setState(() => _directory = result); + await _scan(); + } + } + + Future _browse() async { + final path = await widget.fileService.pickMarkdownFile( + initialDirectory: _directory, + ); + if (path != null && mounted) { + Navigator.pop(context, OpenSearchResult(path)); + } + } + + String _slideText(Slide slide) { + return [ + slide.title, + slide.subtitle, + ...slide.bullets, + slide.quote, + slide.quoteAuthor, + slide.customMarkdown, + slide.imageCaption, + slide.imageCaption2, + slide.imagePath, + slide.imagePath2, + slide.videoPath, + slide.audioPath, + slide.notes, + ].where((s) => s.isNotEmpty).join(' · '); + } + + /// A short excerpt of [text] centred on the first occurrence of [query]. + String _snippet(String text, String query) { + final lower = text.toLowerCase(); + final idx = lower.indexOf(query); + if (idx < 0) return text.length <= 80 ? text : '${text.substring(0, 80)}…'; + final start = (idx - 24).clamp(0, text.length); + final end = (idx + query.length + 48).clamp(0, text.length); + final prefix = start > 0 ? '…' : ''; + final suffix = end < text.length ? '…' : ''; + return '$prefix${text.substring(start, end).trim()}$suffix'; + } + + /// Per visible presentation: the matching slide hits for the current query + /// (empty when the match was on the file name / title, or no query). + List<(ScannedPresentation, List<_SlideHit>)> _visible() { + final q = _query.trim().toLowerCase(); + final out = <(ScannedPresentation, List<_SlideHit>)>[]; + if (q.isEmpty) { + for (final pres in _presentations) { + out.add((pres, const [])); + } + return out; + } + // Multi-word AND: every term must appear somewhere, not necessarily + // adjacent — maximises what you can find. + final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + final first = terms.first; + bool matchesAll(String hay) => terms.every(hay.contains); + + for (final pres in _presentations) { + // A file qualifies on its name/title or anywhere in the raw markdown + // (front matter, comments, image paths, …) — maximal reach. + final fileHay = + '${pres.fileName.toLowerCase()} ' + '${pres.deck.title.toLowerCase()} ' + '${pres.content.toLowerCase()}'; + final fileMatch = matchesAll(fileHay); + final hits = <_SlideHit>[]; + for (var i = 0; i < pres.deck.slides.length; i++) { + final text = _slideText(pres.deck.slides[i]); + if (matchesAll(text.toLowerCase())) { + hits.add(_SlideHit(i, _snippet(text, first))); + } + } + if (fileMatch || hits.isNotEmpty) out.add((pres, hits)); + } + return out; + } + + @override + Widget build(BuildContext context) { + final visible = _visible(); + + return AlertDialog( + title: Row( + children: const [ + Icon(Icons.folder_open_outlined, size: 20), + SizedBox(width: 8), + Text('Presentatie openen'), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + content: SizedBox( + width: 760, + height: 560, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _toolbar(), + const SizedBox(height: 12), + Expanded(child: _body(visible)), + ], + ), + ), + actions: [ + OutlinedButton.icon( + onPressed: _browse, + icon: const Icon(Icons.insert_drive_file_outlined, size: 16), + label: const Text('Bladeren…'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ], + // Knoppen uit elkaar: Bladeren links, Annuleren rechts. (Geen Spacer in + // de actions — die gaan in een OverflowBar en accepteren geen Expanded.) + actionsAlignment: MainAxisAlignment.spaceBetween, + ); + } + + Widget _toolbar() { + return Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + decoration: const InputDecoration( + isDense: true, + prefixIcon: Icon(Icons.search, size: 18), + hintText: 'Zoek op bestandsnaam, titel of tekst in de slides…', + ), + onChanged: (v) => setState(() => _query = v), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: _directory ?? 'Geen map gekozen', + child: OutlinedButton.icon( + onPressed: _pickDirectory, + icon: const Icon(Icons.folder_outlined, size: 16), + label: Text( + _directory == null ? 'Map kiezen' : p.basename(_directory!), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _body(List<(ScannedPresentation, List<_SlideHit>)> visible) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_directory == null) { + return _empty( + Icons.folder_off_outlined, + 'Kies een map met presentaties om te beginnen.', + ); + } + if (_presentations.isEmpty) { + return _empty( + Icons.search_off_outlined, + 'Geen presentaties (.md) in deze map gevonden.', + ); + } + if (visible.isEmpty) { + return _empty( + Icons.search_off_outlined, + 'Geen presentaties gevonden voor "$_query".', + ); + } + + return ListView.separated( + itemCount: visible.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final (pres, hits) = visible[i]; + return _PresentationRow( + presentation: pres, + hits: hits, + onOpen: () => Navigator.pop(context, OpenSearchResult(pres.path)), + onOpenAt: (index) => Navigator.pop( + context, + OpenSearchResult(pres.path, slideIndex: index), + ), + ); + }, + ); + } + + Widget _empty(IconData icon, String message) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: const Color(0xFF94A3B8)), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B), fontSize: 13), + ), + ], + ), + ); + } +} + +class _SlideHit { + final int index; + final String snippet; + const _SlideHit(this.index, this.snippet); +} + +class _PresentationRow extends StatelessWidget { + final ScannedPresentation presentation; + final List<_SlideHit> hits; + final VoidCallback onOpen; + final ValueChanged onOpenAt; + + const _PresentationRow({ + required this.presentation, + required this.hits, + required this.onOpen, + required this.onOpenAt, + }); + + @override + Widget build(BuildContext context) { + final deck = presentation.deck; + final title = deck.title.isEmpty ? presentation.fileName : deck.title; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onOpen, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + child: Row( + children: [ + const Icon( + Icons.slideshow_outlined, + size: 18, + color: AppTheme.navy, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1E293B), + ), + overflow: TextOverflow.ellipsis, + ), + Text( + '${presentation.fileName} · ${deck.slides.length} slides', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Icon(Icons.north_east, size: 16, color: Colors.grey.shade500), + ], + ), + ), + ), + if (hits.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 34, top: 2, bottom: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final hit in hits.take(4)) + InkWell( + onTap: () => onOpenAt(hit.index), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 3, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Slide ${hit.index + 1}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + hit.snippet, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF475569), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + if (hits.length > 4) + Padding( + padding: const EdgeInsets.only(left: 4, top: 2), + child: Text( + '+ ${hits.length - 4} meer treffer(s)', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dialogs/presentation_info_dialog.dart b/lib/widgets/dialogs/presentation_info_dialog.dart new file mode 100644 index 0000000..1a9b604 --- /dev/null +++ b/lib/widgets/dialogs/presentation_info_dialog.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/deck.dart'; + +/// The editable general metadata of a presentation. +class PresentationInfo { + final String author; + final String organization; + final String version; + final String date; + final String description; + final String keywords; + + const PresentationInfo({ + required this.author, + required this.organization, + required this.version, + required this.date, + required this.description, + required this.keywords, + }); +} + +/// Dialog to view and edit a presentation's general metadata (author, version, +/// organization, date, description, keywords). These are stored in the +/// markdown front matter and are therefore also full-text searchable. +class PresentationInfoDialog extends StatefulWidget { + final Deck deck; + + const PresentationInfoDialog({super.key, required this.deck}); + + static Future show(BuildContext context, Deck deck) { + return showDialog( + context: context, + builder: (_) => PresentationInfoDialog(deck: deck), + ); + } + + @override + State createState() => _PresentationInfoDialogState(); +} + +class _PresentationInfoDialogState extends State { + late final TextEditingController _author; + late final TextEditingController _organization; + late final TextEditingController _version; + late final TextEditingController _date; + late final TextEditingController _description; + late final TextEditingController _keywords; + + @override + void initState() { + super.initState(); + _author = TextEditingController(text: widget.deck.author); + _organization = TextEditingController(text: widget.deck.organization); + _version = TextEditingController(text: widget.deck.version); + _date = TextEditingController(text: widget.deck.date); + _description = TextEditingController(text: widget.deck.description); + _keywords = TextEditingController(text: widget.deck.keywords); + } + + @override + void dispose() { + _author.dispose(); + _organization.dispose(); + _version.dispose(); + _date.dispose(); + _description.dispose(); + _keywords.dispose(); + super.dispose(); + } + + void _save() { + Navigator.pop( + context, + PresentationInfo( + author: _author.text.trim(), + organization: _organization.text.trim(), + version: _version.text.trim(), + date: _date.text.trim(), + description: _description.text.trim(), + keywords: _keywords.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + Navigator.pop(context), + }, + child: AlertDialog( + title: Row( + children: const [ + Icon(Icons.info_outline, size: 20), + SizedBox(width: 8), + Text('Presentatie-eigenschappen'), + ], + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.deck.title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _field(_author, 'Auteur', 'Bijv. Jan Jansen'), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: _field(_version, 'Versie', 'Bijv. 1.0'), + ), + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _field( + _organization, + 'Organisatie', + 'Bijv. Vigilis', + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 120, + child: _field(_date, 'Datum', 'Bijv. 2026-05-30'), + ), + ], + ), + const SizedBox(height: 12), + _field( + _description, + 'Beschrijving', + 'Korte omschrijving van de presentatie', + maxLines: 3, + ), + const SizedBox(height: 12), + _field( + _keywords, + 'Trefwoorden', + 'Komma-gescheiden, bijv. kwartaal, cijfers, 2026', + ), + const SizedBox(height: 8), + const Text( + 'Deze gegevens worden in de markdown opgeslagen en zijn ' + 'doorzoekbaar bij het openen.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ElevatedButton(onPressed: _save, child: const Text('Opslaan')), + ], + ), + ); + } + + Widget _field( + TextEditingController controller, + String label, + String hint, { + int maxLines = 1, + }) { + return TextField( + controller: controller, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + hintText: hint, + isDense: true, + border: const OutlineInputBorder(), + ), + ); + } +} diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart new file mode 100644 index 0000000..4eec124 --- /dev/null +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -0,0 +1,735 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../models/settings.dart'; +import '../../state/settings_provider.dart'; +import '../../state/tabs_provider.dart'; +import '../../theme/app_theme.dart'; + +TextStyle _fontStyle(String font, TextStyle base) { + if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base); + return base.copyWith(fontFamily: font); +} + +class SettingsDialog extends ConsumerStatefulWidget { + const SettingsDialog({super.key}); + + static Future show(BuildContext context) { + return showDialog(context: context, builder: (_) => const SettingsDialog()); + } + + @override + ConsumerState createState() => _SettingsDialogState(); +} + +class _SettingsDialogState extends ConsumerState { + late String? _homeDirectory; + late ThemeProfile _themeProfile; + + /// The saved name of the profile currently being edited. Used as a stable + /// identity so renaming updates the existing profile instead of creating a + /// duplicate. + late String _originalName; + late TextEditingController _profileName; + late TextEditingController _logoSize; + late TextEditingController _footerText; + + /// Whether the user changed the active profile in this session. Used to + /// decide whether to apply the profile to the currently open presentation. + bool _profileTouched = false; + + static const _colorPresets = [ + '#FFFFFF', + '#F8FAFC', + '#111827', + '#003399', + '#FFCC00', + '#1C2B47', + '#2E7D64', + '#2563EB', + '#7C3AED', + '#DC2626', + '#F59E0B', + ]; + + @override + void initState() { + super.initState(); + final settings = ref.read(settingsProvider); + _homeDirectory = settings.homeDirectory; + // Reflect the profile the open presentation actually uses, falling back to + // the globally selected profile when no deck is open. + final deckProfile = ref + .read(tabsProvider) + .current + ?.deckNotifier + .currentState + .deck + ?.themeProfile; + _themeProfile = deckProfile ?? settings.themeProfile; + _originalName = _themeProfile.name; + _profileName = TextEditingController(text: _themeProfile.name); + _logoSize = TextEditingController(text: _themeProfile.logoSize.toString()); + _footerText = TextEditingController(text: _themeProfile.footerText); + } + + @override + void dispose() { + _profileName.dispose(); + _logoSize.dispose(); + _footerText.dispose(); + super.dispose(); + } + + List get _profiles { + final seen = {}; + return [ + for (final profile in ref.watch(settingsProvider).themeProfiles) + if (seen.add(profile.name)) profile, + ]; + } + + Future _pickHomeDirectory() async { + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Standaard map voor presentaties', + initialDirectory: _homeDirectory, + ); + if (result != null) setState(() => _homeDirectory = result); + } + + Future _pickLogo() async { + final result = await FilePicker.pickFiles( + dialogTitle: 'Logo kiezen', + type: FileType.image, + ); + final path = result?.files.single.path; + if (path != null) { + setState(() { + _themeProfile = _themeProfile.copyWith(logoPath: path); + _profileTouched = true; + }); + } + } + + void _selectProfile(String name) { + final profile = _profiles.firstWhere((p) => p.name == name); + setState(() { + _themeProfile = profile; + _originalName = profile.name; + _profileName.text = profile.name; + _logoSize.text = profile.logoSize.toString(); + _footerText.text = profile.footerText; + _profileTouched = true; + }); + } + + void _save() { + final notifier = ref.read(settingsProvider.notifier); + final name = _profileName.text.trim(); + final size = int.tryParse(_logoSize.text)?.clamp(32, 240); + final profile = _themeProfile.copyWith( + name: name.isEmpty ? 'Stijlprofiel' : name, + logoSize: size, + footerText: _footerText.text, + ); + notifier.setHomeDirectory(_homeDirectory); + notifier.saveThemeProfile(profile, previousName: _originalName); + + // Apply the chosen/edited profile to the presentation that is currently + // open, so the change is visible immediately. Only when the user actually + // touched the profile in this session (otherwise we would clobber a + // per-deck profile the user set elsewhere). + if (_profileTouched) { + ref.read(tabsProvider).current?.deckNotifier.updateThemeProfile(profile); + } + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final profiles = _profiles; + final dropdownValue = profiles.any((p) => p.name == _originalName) + ? _originalName + : profiles.first.name; + + return DefaultTabController( + length: 3, + child: AlertDialog( + title: const Text('Instellingen'), + content: SizedBox( + width: 520, + height: 560, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _profileSelector(profiles, dropdownValue), + const SizedBox(height: 12), + _profileNameField(), + const SizedBox(height: 12), + const TabBar( + tabs: [ + Tab(icon: Icon(Icons.tune), text: 'Algemeen'), + Tab(icon: Icon(Icons.palette_outlined), text: 'Kleuren'), + Tab(icon: Icon(Icons.image_outlined), text: 'Logo'), + ], + ), + const SizedBox(height: 12), + Expanded( + child: TabBarView( + children: [ + _tabBody(_generalTab()), + _tabBody(_colorsTab()), + _tabBody(_logoTab()), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuleren'), + ), + ElevatedButton(onPressed: _save, child: const Text('Opslaan')), + ], + ), + ); + } + + Widget _profileNameField() { + return TextField( + controller: _profileName, + textInputAction: TextInputAction.done, + decoration: const InputDecoration( + labelText: 'Profielnaam', + hintText: 'Naam van het stijlprofiel', + isDense: true, + prefixIcon: Icon(Icons.badge_outlined, size: 18), + ), + onChanged: (value) { + final name = value.trim(); + _themeProfile = _themeProfile.copyWith( + name: name.isEmpty ? _themeProfile.name : name, + ); + _profileTouched = true; + }, + ); + } + + Widget _profileSelector(List profiles, String dropdownValue) { + return Row( + children: [ + Expanded( + child: InputDecorator( + decoration: const InputDecoration( + labelText: 'Stijlprofiel', + isDense: true, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: dropdownValue, + isExpanded: true, + isDense: true, + items: [ + for (final profile in profiles) + DropdownMenuItem( + value: profile.name, + child: Text(profile.name), + ), + ], + onChanged: (name) { + if (name != null) _selectProfile(name); + }, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Nieuw profiel', + onPressed: _createProfile, + icon: const Icon(Icons.add, size: 18), + ), + IconButton( + tooltip: 'Standaardprofiel laden', + onPressed: _loadDefaultProfile, + icon: const Icon(Icons.restart_alt, size: 18), + ), + IconButton( + tooltip: 'Profiel verwijderen', + onPressed: profiles.length <= 1 + ? null + : () { + ref + .read(settingsProvider.notifier) + .deleteThemeProfile(_themeProfile.name); + final profile = ref.read(settingsProvider).themeProfile; + setState(() { + _themeProfile = profile; + _originalName = profile.name; + _profileName.text = profile.name; + _logoSize.text = profile.logoSize.toString(); + _footerText.text = profile.footerText; + _profileTouched = true; + }); + }, + icon: const Icon(Icons.delete_outline, size: 18), + ), + ], + ); + } + + void _loadDefaultProfile() { + final profile = ref.read(settingsProvider).themeProfile; + setState(() { + _themeProfile = profile; + _originalName = profile.name; + _profileName.text = profile.name; + _logoSize.text = profile.logoSize.toString(); + _footerText.text = profile.footerText; + _profileTouched = true; + }); + } + + Future _createProfile() async { + final created = await ref + .read(settingsProvider.notifier) + .createThemeProfile(base: _themeProfile); + if (!mounted) return; + setState(() { + _themeProfile = created; + _originalName = created.name; + _profileName.text = created.name; + _logoSize.text = created.logoSize.toString(); + _footerText.text = created.footerText; + _profileTouched = true; + }); + } + + Widget _tabBody(Widget child) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(right: 4, bottom: 8), + child: child, + ), + ); + } + + Widget _generalTab() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Presentatiemap'), + Row( + children: [ + Expanded( + child: _pathBox( + _homeDirectory ?? 'Niet ingesteld', + muted: _homeDirectory == null, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _pickHomeDirectory, + icon: const Icon(Icons.folder_open, size: 16), + label: const Text('Kiezen'), + ), + if (_homeDirectory != null) + IconButton( + onPressed: () => setState(() => _homeDirectory = null), + icon: const Icon(Icons.clear, size: 18), + tooltip: 'Verwijder standaard map', + ), + ], + ), + ], + ); + } + + /// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app. + Widget _fontSection() { + return Container( + decoration: _boxDecoration(), + child: Column( + children: AppSettings.availableFonts.map((font) { + final selected = font == _themeProfile.fontFamily; + return InkWell( + onTap: () => setState(() { + _themeProfile = _themeProfile.copyWith(fontFamily: font); + _profileTouched = true; + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: selected + ? AppTheme.accent.withValues(alpha: 0.08) + : Colors.transparent, + border: Border( + bottom: BorderSide( + color: const Color(0xFFE2E8F0), + width: font == AppSettings.availableFonts.last ? 0 : 1, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + font, + style: _fontStyle( + font, + TextStyle( + fontSize: 15, + color: selected + ? AppTheme.accent + : const Color(0xFF334155), + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + if (selected) + const Icon(Icons.check, size: 16, color: AppTheme.accent), + ], + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _colorsTab() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Lettertype'), + _fontSection(), + const SizedBox(height: 20), + _sectionTitle('Kleuren'), + _colorSetting( + 'Achtergrond slides', + _themeProfile.slideBackgroundColor, + (v) => + _themeProfile = _themeProfile.copyWith(slideBackgroundColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Tekst', + _themeProfile.textColor, + (v) => _themeProfile = _themeProfile.copyWith(textColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Accent / bullets', + _themeProfile.accentColor, + (v) => _themeProfile = _themeProfile.copyWith(accentColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Tabeltekst', + _themeProfile.tableTextColor, + (v) => _themeProfile = _themeProfile.copyWith(tableTextColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Tabel koptekst', + _themeProfile.tableHeaderTextColor, + (v) => + _themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Titelachtergrond', + _themeProfile.titleBackgroundColor, + (v) => + _themeProfile = _themeProfile.copyWith(titleBackgroundColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Titeltekst', + _themeProfile.titleTextColor, + (v) => _themeProfile = _themeProfile.copyWith(titleTextColor: v), + ), + const SizedBox(height: 12), + _colorSetting( + 'Sectieachtergrond', + _themeProfile.sectionBackgroundColor, + (v) => + _themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v), + ), + const SizedBox(height: 18), + _stylePreview(), + ], + ); + } + + Widget _logoTab() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Logo'), + Row( + children: [ + Expanded( + child: _pathBox( + _themeProfile.logoPath ?? 'Geen logo ingesteld', + muted: _themeProfile.logoPath == null, + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _pickLogo, + icon: const Icon(Icons.image_outlined, size: 16), + label: const Text('Kiezen'), + ), + if (_themeProfile.logoPath != null) + IconButton( + onPressed: () => setState(() { + _themeProfile = _themeProfile.copyWith(clearLogo: true); + _profileTouched = true; + }), + icon: const Icon(Icons.clear, size: 18), + tooltip: 'Verwijder logo', + ), + ], + ), + const SizedBox(height: 18), + DropdownButtonFormField( + initialValue: _themeProfile.logoPosition, + decoration: const InputDecoration( + labelText: 'Logo positie', + isDense: true, + ), + items: const [ + DropdownMenuItem(value: 'top-left', child: Text('Linksboven')), + DropdownMenuItem(value: 'top-right', child: Text('Rechtsboven')), + DropdownMenuItem(value: 'bottom-left', child: Text('Linksonder')), + DropdownMenuItem(value: 'bottom-right', child: Text('Rechtsonder')), + ], + onChanged: (v) { + if (v != null) { + setState(() { + _themeProfile = _themeProfile.copyWith(logoPosition: v); + _profileTouched = true; + }); + } + }, + ), + const SizedBox(height: 14), + SizedBox( + width: 160, + child: TextField( + controller: _logoSize, + decoration: const InputDecoration( + labelText: 'Logo px', + isDense: true, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => _profileTouched = true, + ), + ), + const SizedBox(height: 24), + _sectionTitle('Footer'), + TextField( + controller: _footerText, + decoration: const InputDecoration( + labelText: 'Footertekst', + hintText: 'bijv. Vertrouwelijk · {title} · {date}', + isDense: true, + ), + onChanged: (_) => _profileTouched = true, + ), + const SizedBox(height: 6), + const Text( + 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle ' + 'slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + const SizedBox(height: 14), + DropdownButtonFormField( + initialValue: _themeProfile.footerPosition, + decoration: const InputDecoration( + labelText: 'Footerpositie', + isDense: true, + ), + items: const [ + DropdownMenuItem(value: 'left', child: Text('Links')), + DropdownMenuItem(value: 'center', child: Text('Midden')), + DropdownMenuItem(value: 'right', child: Text('Rechts')), + ], + onChanged: (v) { + if (v != null) { + setState(() { + _themeProfile = _themeProfile.copyWith(footerPosition: v); + _profileTouched = true; + }); + } + }, + ), + const SizedBox(height: 6), + CheckboxListTile( + value: _themeProfile.footerShowPageNumbers, + onChanged: (v) => setState(() { + _themeProfile = _themeProfile.copyWith( + footerShowPageNumbers: v ?? false, + ); + _profileTouched = true; + }), + title: const Text( + 'Paginanummers tonen (rechtsonder)', + style: TextStyle(fontSize: 13), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ], + ); + } + + Widget _colorSetting( + String label, + String value, + ValueChanged onChanged, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF334155), + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final color in _colorPresets) + Tooltip( + message: color, + child: InkWell( + onTap: () => setState(() { + onChanged(color); + _profileTouched = true; + }), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: _parseColor(color), + shape: BoxShape.circle, + border: Border.all( + color: value == color + ? AppTheme.accent + : const Color(0xFFCBD5E1), + width: value == color ? 2 : 1, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _stylePreview() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: _parseColor(_themeProfile.titleBackgroundColor), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Voorvertoning', + style: _fontStyle( + _themeProfile.fontFamily, + TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _parseColor(_themeProfile.titleTextColor), + ), + ), + ), + const SizedBox(height: 2), + Text( + 'De snelle bruine vos springt over de luie hond.', + style: _fontStyle( + _themeProfile.fontFamily, + TextStyle( + fontSize: 12, + color: _parseColor( + _themeProfile.titleTextColor, + ).withValues(alpha: 0.72), + ), + ), + ), + ], + ), + ); + } + + Widget _sectionTitle(String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + text.toUpperCase(), + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Color(0xFF64748B), + letterSpacing: 1.2, + ), + ), + ); + } + + Widget _pathBox(String text, {bool muted = false}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: _boxDecoration(), + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: muted ? const Color(0xFF94A3B8) : const Color(0xFF334155), + ), + overflow: TextOverflow.ellipsis, + ), + ); + } + + BoxDecoration _boxDecoration() { + return BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(6), + color: Colors.white, + ); + } + + Color _parseColor(String hex) { + final cleaned = hex.replaceFirst('#', ''); + final value = int.tryParse( + cleaned.length == 6 ? 'FF$cleaned' : cleaned, + radix: 16, + ); + return Color(value ?? 0xFFFFFFFF); + } +} diff --git a/lib/widgets/dialogs/slide_finder_dialog.dart b/lib/widgets/dialogs/slide_finder_dialog.dart new file mode 100644 index 0000000..40541dc --- /dev/null +++ b/lib/widgets/dialogs/slide_finder_dialog.dart @@ -0,0 +1,388 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import '../../models/slide.dart'; +import '../../services/file_service.dart'; +import '../../theme/app_theme.dart'; +import '../slides/slide_preview.dart'; + +/// A single search hit: one slide from a scanned presentation. +class _Hit { + final ScannedPresentation source; + final int slideIndex; + final Slide slide; + const _Hit(this.source, this.slideIndex, this.slide); + + String get key => '${source.path}#$slideIndex'; +} + +/// "Slide finder": search across every presentation in a directory and add +/// matching, fully-rendered slides to the current presentation. The dialog +/// stays open so several slides can be gathered in one session. +class SlideFinderDialog extends StatefulWidget { + final FileService fileService; + final String? initialDirectory; + final String? excludePath; + + /// Called with a slide (image paths already resolved to absolute) that the + /// user wants to add to the current presentation. + final void Function(Slide slide) onAdd; + + const SlideFinderDialog({ + super.key, + required this.fileService, + required this.initialDirectory, + required this.onAdd, + this.excludePath, + }); + + static Future show( + BuildContext context, { + required FileService fileService, + required String? initialDirectory, + required void Function(Slide slide) onAdd, + String? excludePath, + }) { + return showDialog( + context: context, + builder: (_) => SlideFinderDialog( + fileService: fileService, + initialDirectory: initialDirectory, + onAdd: onAdd, + excludePath: excludePath, + ), + ); + } + + @override + State createState() => _SlideFinderDialogState(); +} + +class _SlideFinderDialogState extends State { + static const _maxResults = 200; + + String? _directory; + bool _loading = false; + List _presentations = const []; + String _query = ''; + int _addedCount = 0; + final _addedKeys = {}; + + @override + void initState() { + super.initState(); + _directory = widget.initialDirectory; + if (_directory != null) _scan(); + } + + Future _scan() async { + final dir = _directory; + if (dir == null) return; + setState(() => _loading = true); + final results = await widget.fileService.scanPresentations( + dir, + excludePath: widget.excludePath, + ); + if (!mounted) return; + setState(() { + _presentations = results; + _loading = false; + }); + } + + Future _pickDirectory() async { + final result = await FilePicker.getDirectoryPath( + dialogTitle: 'Map met presentaties kiezen', + initialDirectory: _directory, + ); + if (result != null) { + setState(() => _directory = result); + await _scan(); + } + } + + String _slideText(Slide slide) { + return [ + slide.title, + slide.subtitle, + ...slide.bullets, + slide.quote, + slide.quoteAuthor, + slide.customMarkdown, + slide.imageCaption, + slide.imageCaption2, + slide.imagePath, + slide.imagePath2, + slide.videoPath, + slide.audioPath, + slide.notes, + slide.type.label, + ].join(' ').toLowerCase(); + } + + String _resolve(String imagePath, String? projectPath) { + if (imagePath.isEmpty || p.isAbsolute(imagePath)) return imagePath; + if (projectPath != null) return p.join(projectPath, imagePath); + return imagePath; + } + + /// Flat, capped list of slides matching the current query (every term must + /// appear somewhere in the slide). + List<_Hit> _hits() { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return const []; + final terms = q.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList(); + final hits = <_Hit>[]; + for (final pres in _presentations) { + for (var i = 0; i < pres.deck.slides.length; i++) { + final text = _slideText(pres.deck.slides[i]); + if (terms.every(text.contains)) { + hits.add(_Hit(pres, i, pres.deck.slides[i])); + if (hits.length >= _maxResults) return hits; + } + } + } + return hits; + } + + void _add(_Hit hit) { + final projectPath = hit.source.deck.projectPath; + final resolved = hit.slide.copyWith( + imagePath: _resolve(hit.slide.imagePath, projectPath), + imagePath2: _resolve(hit.slide.imagePath2, projectPath), + ); + widget.onAdd(resolved); + setState(() { + _addedKeys.add(hit.key); + _addedCount++; + }); + } + + @override + Widget build(BuildContext context) { + final hits = _hits(); + + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.travel_explore_outlined, size: 20), + const SizedBox(width: 8), + const Text('Slide zoeken'), + const Spacer(), + if (_addedCount > 0) + Text( + '$_addedCount toegevoegd', + style: const TextStyle( + fontSize: 12, + color: AppTheme.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0), + content: SizedBox( + width: 900, + height: 600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _toolbar(), + const SizedBox(height: 12), + Expanded(child: _body(hits)), + ], + ), + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Klaar'), + ), + ], + ); + } + + Widget _toolbar() { + return Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + decoration: const InputDecoration( + isDense: true, + prefixIcon: Icon(Icons.search, size: 18), + hintText: 'Zoek slides op tekst, titel, onderschrift, pad…', + ), + onChanged: (v) => setState(() => _query = v), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: _directory ?? 'Geen map gekozen', + child: OutlinedButton.icon( + onPressed: _pickDirectory, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: Text( + _directory == null ? 'Map kiezen' : p.basename(_directory!), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + Widget _body(List<_Hit> hits) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_directory == null) { + return _empty( + Icons.folder_off_outlined, + 'Kies een map met presentaties om te beginnen.', + ); + } + if (_query.trim().isEmpty) { + return _empty( + Icons.travel_explore_outlined, + 'Typ zoektermen om slides uit al je presentaties te vinden.', + ); + } + if (hits.isEmpty) { + return _empty( + Icons.search_off_outlined, + 'Geen slides gevonden voor "${_query.trim()}".', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8, left: 2), + child: Text( + hits.length >= _maxResults + ? 'Eerste $_maxResults treffers — verfijn je zoekopdracht' + : '${hits.length} treffer(s)', + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + Expanded( + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 280, + mainAxisSpacing: 14, + crossAxisSpacing: 14, + childAspectRatio: 0.78, + ), + itemCount: hits.length, + itemBuilder: (_, i) => _SlideHitCard( + hit: hits[i], + added: _addedKeys.contains(hits[i].key), + onAdd: () => _add(hits[i]), + ), + ), + ), + ], + ); + } + + Widget _empty(IconData icon, String message) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: const Color(0xFF94A3B8)), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B), fontSize: 13), + ), + ], + ), + ); + } +} + +// ── A rendered slide result with an add button ─────────────────────────────── + +class _SlideHitCard extends StatelessWidget { + final _Hit hit; + final bool added; + final VoidCallback onAdd; + + const _SlideHitCard({ + required this.hit, + required this.added, + required this.onAdd, + }); + + @override + Widget build(BuildContext context) { + final deck = hit.source.deck; + final sourceName = deck.title.isEmpty ? hit.source.fileName : deck.title; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: added ? AppTheme.accent : const Color(0xFFCBD5E1), + width: added ? 2 : 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: AspectRatio( + aspectRatio: 16 / 9, + child: SlidePreviewWidget( + slide: hit.slide, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + '$sourceName · slide ${hit.slideIndex + 1}', + style: const TextStyle(fontSize: 10.5, color: Color(0xFF94A3B8)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + SizedBox( + height: 28, + child: added + ? OutlinedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.check, size: 14), + label: const Text('Toegevoegd'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.accent, + side: const BorderSide(color: AppTheme.accent), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ) + : ElevatedButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 14), + label: const Text('Toevoegen'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/editors/_editor_field.dart b/lib/widgets/editors/_editor_field.dart new file mode 100644 index 0000000..c643496 --- /dev/null +++ b/lib/widgets/editors/_editor_field.dart @@ -0,0 +1,472 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import '../../services/caption_service.dart'; +import '../../services/description_service.dart'; +import '../../services/image_service.dart'; +import '../../state/tabs_provider.dart'; +import '../dialogs/image_carousel_picker.dart'; + +/// Shared layout helpers for slide editors. + +class EditorField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String hint; + final int maxLines; + + const EditorField({ + super.key, + required this.label, + required this.controller, + this.hint = '', + this.maxLines = 1, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + const SizedBox(height: 5), + TextField( + controller: controller, + maxLines: maxLines, + minLines: 1, + decoration: InputDecoration(hintText: hint), + ), + ], + ); + } +} + +class EditorFieldList extends StatelessWidget { + final List children; + const EditorFieldList({super.key, required this.children}); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: children.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (_, i) => children[i], + ); + } +} + +/// Zoom-bediening voor afbeeldingen in slides. +/// De afbeelding vult ALTIJD de slide; de zoom bepaalt welk deel zichtbaar is. +/// 0 of 100 = normaal (cover, Marp-standaard) +/// 150 = ingezoomd: ziet alleen het midden van de foto +/// 300 = flink ingezoomd: ziet 1/3 van de foto +class ImageZoomControl extends StatelessWidget { + final int value; // 0 = auto/normaal, anders minValue–maxValue + final ValueChanged onChanged; + final int step; + final int minValue; + final int maxValue; + + const ImageZoomControl({ + super.key, + required this.value, + required this.onChanged, + this.step = 10, + this.minValue = 20, + this.maxValue = 300, + }); + + // Effectieve sliderwaarde: 0 behandelen als 100 + int get _effective => value == 0 ? 100 : value.clamp(minValue, maxValue); + + String get _label { + final v = _effective; + if (maxValue <= 100) return '$v%'; // paneelbreedte-modus + if (v == 100) return 'Volledig zichtbaar (100%)'; + if (v > 100) { + return 'Ingezoomd $v% — ${((1 / (v / 100)) * 100).round()}% van de foto zichtbaar'; + } + return 'Uitgezoomd $v%'; + } + + @override + Widget build(BuildContext context) { + final zoomed = _effective != 100; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Tooltip( + message: 'Uitzoomen (meer van de foto zichtbaar)', + child: Icon(Icons.zoom_out, size: 16, color: Color(0xFF94A3B8)), + ), + Expanded( + child: Slider( + value: _effective.toDouble(), + min: minValue.toDouble(), + max: maxValue.toDouble(), + divisions: (maxValue - minValue) ~/ step, + label: _label, + onChanged: (v) { + final snapped = ((v.round() / step).round() * step).clamp( + minValue, + maxValue, + ); + onChanged(snapped); + }, + ), + ), + const Tooltip( + message: 'Inzoomen (minder van de foto zichtbaar)', + child: Icon(Icons.zoom_in, size: 16, color: Color(0xFF94A3B8)), + ), + const SizedBox(width: 8), + SizedBox( + width: 52, + child: Text( + '$_effective%', + style: TextStyle( + fontSize: 12, + color: zoomed + ? const Color(0xFF2563EB) + : const Color(0xFF94A3B8), + fontWeight: zoomed ? FontWeight.w600 : FontWeight.normal, + ), + textAlign: TextAlign.right, + ), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Terugzetten (volledige afbeelding zichtbaar)', + child: IconButton( + icon: const Icon(Icons.refresh, size: 16), + onPressed: zoomed ? () => onChanged(100) : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 4), + child: Text( + _label, + style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)), + ), + ), + ], + ); + } +} + +/// Callback met pad én caption. +typedef ImagePickedCallback = void Function(String path, String caption); + +/// Afbeeldingskiezer-balk met carousel-knop + optionele caption. +class ImagePickerBar extends ConsumerWidget { + final String imagePath; + final String imageCaption; + final String? captionBasePath; + final List searchPaths; + final ImagePickedCallback onPicked; + final VoidCallback? onBrowse; + final VoidCallback? onPaste; + final VoidCallback? onClear; + final ValueChanged? onCaptionChanged; + final String label; + + const ImagePickerBar({ + super.key, + required this.imagePath, + required this.searchPaths, + required this.onPicked, + this.imageCaption = '', + this.captionBasePath, + this.onBrowse, + this.onPaste, + this.onClear, + this.onCaptionChanged, + this.label = 'Geen afbeelding gekozen', + }); + + Future _openCarousel( + BuildContext context, + WidgetRef ref, + CaptionService captions, + ) async { + final result = await ImageCarouselPicker.show( + context, + searchPaths: searchPaths, + initialPath: imagePath.isNotEmpty ? _resolveImagePath(imagePath) : null, + captionService: captions, + descriptionService: ref.read(descriptionServiceProvider), + usageOf: (absolutePath) => _imageUsages(ref, absolutePath), + ); + if (result != null) onPicked(result.path, result.caption); + } + + /// Find every open-deck slide that references [absolutePath], so we can warn + /// before deleting an image that is still in use. + List _imageUsages(WidgetRef ref, String absolutePath) { + final target = p.normalize(absolutePath); + final usages = []; + for (final tab in ref.read(tabsProvider).tabs) { + final deck = tab.deckNotifier.currentState.deck; + if (deck == null) continue; + for (var i = 0; i < deck.slides.length; i++) { + final slide = deck.slides[i]; + for (final candidate in [slide.imagePath, slide.imagePath2]) { + if (candidate.isEmpty) continue; + final resolved = p.normalize( + p.isAbsolute(candidate) + ? candidate + : p.join(deck.projectPath ?? '', candidate), + ); + if (resolved == target) { + usages.add('${tab.label} · slide ${i + 1}'); + break; + } + } + } + } + return usages; + } + + String _resolveImagePath(String path) { + if (p.isAbsolute(path) || captionBasePath == null) return path; + return p.join(captionBasePath!, path); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final captions = ref.read(captionServiceProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Pad-display + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(6), + color: Colors.white, + ), + child: Text( + imagePath.isEmpty ? label : imagePath, + style: TextStyle( + fontSize: 12, + color: imagePath.isEmpty + ? const Color(0xFF94A3B8) + : const Color(0xFF334155), + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 8), + // Knoppen + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => _openCarousel(context, ref, captions), + icon: const Icon(Icons.photo_library_outlined, size: 16), + label: const Text('Uit bibliotheek…'), + ), + if (onBrowse != null) + OutlinedButton.icon( + onPressed: onBrowse, + icon: const Icon(Icons.folder_open_outlined, size: 16), + label: const Text('Van computer…'), + ), + if (onPaste != null) + Tooltip( + message: 'Afbeelding plakken uit klembord', + child: IconButton( + onPressed: onPaste, + icon: const Icon(Icons.content_paste, size: 18), + color: const Color(0xFF64748B), + ), + ), + if (imagePath.isNotEmpty) + Tooltip( + message: 'Kopieer afbeelding naar klembord', + child: IconButton( + onPressed: () async { + final ok = await ImageService().copyImageToClipboard( + _resolveImagePath(imagePath), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + ok + ? 'Afbeelding gekopieerd naar klembord.' + : 'Kopiëren naar klembord mislukt.', + ), + ), + ); + } + }, + icon: const Icon(Icons.content_copy_outlined, size: 18), + color: const Color(0xFF64748B), + ), + ), + if (onClear != null && imagePath.isNotEmpty) + Tooltip( + message: 'Verwijder afbeelding', + child: IconButton( + onPressed: onClear, + icon: const Icon(Icons.clear, size: 18), + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + // Caption-veld + if (imagePath.isNotEmpty && onCaptionChanged != null) ...[ + const SizedBox(height: 8), + _CaptionField( + caption: imageCaption, + imagePath: imagePath, + captionBasePath: captionBasePath, + captionService: captions, + onChanged: onCaptionChanged!, + ), + ], + ], + ); + } +} + +/// Captionveld met auto-save naar sidecar. +class _CaptionField extends StatefulWidget { + final String caption; + final String imagePath; + final String? captionBasePath; + final CaptionService captionService; + final ValueChanged onChanged; + + const _CaptionField({ + required this.caption, + required this.imagePath, + this.captionBasePath, + required this.captionService, + required this.onChanged, + }); + + @override + State<_CaptionField> createState() => _CaptionFieldState(); +} + +class _CaptionFieldState extends State<_CaptionField> { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.caption); + _ctrl.addListener(_onChanged); + _loadStoredCaption(); + } + + @override + void didUpdateWidget(_CaptionField old) { + super.didUpdateWidget(old); + if (old.imagePath != widget.imagePath) { + _ctrl.removeListener(_onChanged); + _ctrl.text = widget.caption; + _ctrl.addListener(_onChanged); + _loadStoredCaption(); + } + } + + void _onChanged() { + widget.onChanged(_ctrl.text); + widget.captionService.saveCaption( + widget.imagePath, + _ctrl.text, + basePath: widget.captionBasePath, + ); + } + + Future _loadStoredCaption() async { + if (widget.caption.isNotEmpty) return; + final stored = await widget.captionService.getCaption( + widget.imagePath, + basePath: widget.captionBasePath, + ); + if (!mounted || stored == null || stored == _ctrl.text) return; + _ctrl.removeListener(_onChanged); + _ctrl.text = stored; + _ctrl.addListener(_onChanged); + widget.onChanged(stored); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _ctrl, + decoration: InputDecoration( + hintText: 'Caption / bronvermelding (bijv. © Naam Fotograaf)', + hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)), + prefixIcon: const Icon( + Icons.copyright_outlined, + size: 16, + color: Color(0xFF94A3B8), + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + filled: true, + fillColor: const Color(0xFFF8FAFC), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFCBD5E1)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFFCBD5E1)), + ), + ), + style: const TextStyle(fontSize: 12), + ); + } +} + +class SectionLabel extends StatelessWidget { + final String text; + const SectionLabel(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + text, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ); + } +} diff --git a/lib/widgets/editors/audio_attachment_editor.dart b/lib/widgets/editors/audio_attachment_editor.dart new file mode 100644 index 0000000..958baed --- /dev/null +++ b/lib/widgets/editors/audio_attachment_editor.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; +import '../../services/image_service.dart'; +import '_editor_field.dart'; + +class AudioAttachmentEditor extends StatelessWidget { + final Slide slide; + final ImageService imageService; + final ValueChanged onUpdate; + + const AudioAttachmentEditor({ + super.key, + required this.slide, + required this.imageService, + required this.onUpdate, + }); + + Future _pickAudio() async { + final path = await imageService.pickAudio(); + if (path != null) onUpdate(slide.copyWith(audioPath: path)); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionLabel('Audio bij deze slide'), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(6), + color: Colors.white, + ), + child: Text( + slide.audioPath.isEmpty + ? 'Geen audiobestand gekozen' + : slide.audioPath, + style: TextStyle( + fontSize: 12, + color: slide.audioPath.isEmpty + ? const Color(0xFF94A3B8) + : const Color(0xFF334155), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _pickAudio, + icon: const Icon(Icons.audio_file_outlined, size: 16), + label: const Text('Kiezen'), + ), + if (slide.audioPath.isNotEmpty) + IconButton( + onPressed: () => onUpdate( + slide.copyWith(audioPath: '', audioAutoplay: false), + ), + icon: const Icon(Icons.clear, size: 18), + tooltip: 'Audio verwijderen', + ), + ], + ), + Material( + color: Colors.transparent, + child: CheckboxListTile( + value: slide.audioAutoplay, + onChanged: slide.audioPath.isEmpty + ? null + : (value) => + onUpdate(slide.copyWith(audioAutoplay: value ?? false)), + title: const Text('Audio automatisch afspelen'), + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/editors/bullets_editor.dart b/lib/widgets/editors/bullets_editor.dart new file mode 100644 index 0000000..e43369d --- /dev/null +++ b/lib/widgets/editors/bullets_editor.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/slide.dart'; +import '_editor_field.dart'; + +class BulletsEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + + const BulletsEditor({super.key, required this.slide, required this.onUpdate}); + + @override + State createState() => _BulletsEditorState(); +} + +class _BulletsEditorState extends State { + late final TextEditingController _title; + late List _bullets; + late List _levels; + late List _focusNodes; + + static const _maxLevel = 4; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + _initBullets(widget.slide.bullets); + } + + void _initBullets(List raw) { + final list = raw.isEmpty ? [''] : raw; + _levels = list.map(_levelOf).toList(); + _bullets = list.map((b) => _makeCtrl(b.trimLeft())).toList(); + _focusNodes = List.generate(_bullets.length, (_) => FocusNode()); + } + + static int _levelOf(String b) { + int l = 0; + while (l < b.length && b[l] == '\t' && l < _maxLevel) { + l++; + } + return l; + } + + TextEditingController _makeCtrl(String text) { + final c = TextEditingController(text: text); + c.addListener(_emit); + return c; + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith( + title: _title.text, + bullets: List.generate( + _bullets.length, + (i) => '\t' * _levels[i] + _bullets[i].text, + ), + ), + ); + } + + void _reorderItem(int oldIndex, int newIndex) { + _moveBullet(oldIndex, newIndex); + } + + void _moveBullet(int oldIndex, int newIndex) { + setState(() { + final ctrl = _bullets.removeAt(oldIndex); + final level = _levels.removeAt(oldIndex); + final focus = _focusNodes.removeAt(oldIndex); + _bullets.insert(newIndex, ctrl); + _levels.insert(newIndex, level); + _focusNodes.insert(newIndex, focus); + }); + _emit(); + } + + void _addBulletAfter(int i) { + final newLevel = _levels[i]; // inherit current level + setState(() { + _bullets.insert(i + 1, _makeCtrl('')); + _levels.insert(i + 1, newLevel); + _focusNodes.insert(i + 1, FocusNode()); + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (i + 1 < _focusNodes.length) _focusNodes[i + 1].requestFocus(); + }); + } + + void _removeBulletAndFocus(int i) { + if (_bullets.length <= 1) return; + final target = (i - 1).clamp(0, _bullets.length - 2); + setState(() { + _bullets[i].removeListener(_emit); + _bullets[i].dispose(); + _bullets.removeAt(i); + _levels.removeAt(i); + _focusNodes[i].dispose(); + _focusNodes.removeAt(i); + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (target < _focusNodes.length) _focusNodes[target].requestFocus(); + }); + } + + Future _handlePaste(int i) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text == null) return; + final lines = data!.text! + .split('\n') + .map((l) => l.trim().replaceAll(RegExp(r'^[-*•◦▪▫]\s*'), '')) + .where((l) => l.isNotEmpty) + .toList(); + if (lines.isEmpty) return; + + if (lines.length == 1) { + final ctrl = _bullets[i]; + final sel = ctrl.selection; + final start = sel.isValid ? sel.start : ctrl.text.length; + final end = sel.isValid ? sel.end : ctrl.text.length; + ctrl.value = TextEditingValue( + text: ctrl.text.replaceRange(start, end, lines[0]), + selection: TextSelection.collapsed(offset: start + lines[0].length), + ); + return; + } + + setState(() { + _bullets[i].removeListener(_emit); + _bullets[i].dispose(); + _bullets[i] = _makeCtrl(lines[0]); + for (int j = 1; j < lines.length; j++) { + _bullets.insert(i + j, _makeCtrl(lines[j])); + _levels.insert(i + j, _levels[i]); + _focusNodes.insert(i + j, FocusNode()); + } + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final last = i + lines.length - 1; + if (last < _focusNodes.length) _focusNodes[last].requestFocus(); + }); + } + + @override + void dispose() { + _title.dispose(); + for (final c in _bullets) { + c.dispose(); + } + for (final f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), + const SizedBox(height: 16), + const SectionLabel('Bullets'), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: _reorderItem, + children: [ + for (int i = 0; i < _bullets.length; i++) _buildBulletRow(i), + ], + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => _addBulletAfter(_bullets.length - 1), + icon: const Icon(Icons.add, size: 16), + label: const Text('Bullet toevoegen'), + ), + ), + ], + ); + } + + Widget _buildBulletRow(int i) { + final level = _levels[i]; + return Padding( + key: ValueKey(_bullets[i]), + padding: EdgeInsets.only(left: level * 20.0, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ReorderableDragStartListener( + index: i, + child: const Icon( + Icons.drag_indicator, + size: 16, + color: Color(0xFFCBD5E1), + ), + ), + const SizedBox(width: 4), + Text( + _markerForLevel(level), + style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), + const SizedBox(width: 8), + Expanded( + child: Focus( + onKeyEvent: (_, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + // Enter → nieuwe bullet + if (event.logicalKey == LogicalKeyboardKey.enter) { + _addBulletAfter(i); + return KeyEventResult.handled; + } + // Backspace op lege bullet → verwijder + if (event.logicalKey == LogicalKeyboardKey.backspace && + _bullets[i].text.isEmpty && + _bullets.length > 1) { + _removeBulletAndFocus(i); + return KeyEventResult.handled; + } + // Tab → inspringing + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (HardwareKeyboard.instance.isShiftPressed) { + if (_levels[i] > 0) setState(() => _levels[i]--); + } else { + if (_levels[i] < _maxLevel) setState(() => _levels[i]++); + } + _emit(); + return KeyEventResult.handled; + } + // Cmd/Ctrl+V → slim plakken + if (event.logicalKey == LogicalKeyboardKey.keyV && + (HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed)) { + _handlePaste(i); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _bullets[i], + focusNode: _focusNodes[i], + decoration: InputDecoration( + hintText: 'Bullet ${i + 1}', + isDense: true, + ), + ), + ), + ), + IconButton( + icon: const Icon( + Icons.remove_circle_outline, + size: 18, + color: Color(0xFF94A3B8), + ), + onPressed: _bullets.length > 1 + ? () => _removeBulletAndFocus(i) + : null, + tooltip: 'Verwijder', + padding: const EdgeInsets.symmetric(horizontal: 4), + constraints: const BoxConstraints(minWidth: 28), + ), + ], + ), + ); + } + + String _markerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; + } +} diff --git a/lib/widgets/editors/bullets_image_editor.dart b/lib/widgets/editors/bullets_image_editor.dart new file mode 100644 index 0000000..fb5c17a --- /dev/null +++ b/lib/widgets/editors/bullets_image_editor.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/slide.dart'; +import '../../services/image_service.dart'; +import '_editor_field.dart'; + +class BulletsImageEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final ImageService imageService; + final List searchPaths; + final String? captionBasePath; + + const BulletsImageEditor({ + super.key, + required this.slide, + required this.onUpdate, + required this.imageService, + this.searchPaths = const [], + this.captionBasePath, + }); + + @override + State createState() => _BulletsImageEditorState(); +} + +class _BulletsImageEditorState extends State { + late final TextEditingController _title; + late List _bullets; + late List _levels; + late List _focusNodes; + + static const _maxLevel = 4; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + final list = widget.slide.bullets.isEmpty ? [''] : widget.slide.bullets; + _levels = list.map(_levelOf).toList(); + _bullets = list.map((b) => _makeCtrl(b.trimLeft())).toList(); + _focusNodes = List.generate(_bullets.length, (_) => FocusNode()); + } + + static int _levelOf(String b) { + int l = 0; + while (l < b.length && b[l] == '\t' && l < _maxLevel) { + l++; + } + return l; + } + + TextEditingController _makeCtrl(String text) { + final c = TextEditingController(text: text); + c.addListener(_emit); + return c; + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith( + title: _title.text, + bullets: List.generate( + _bullets.length, + (i) => '\t' * _levels[i] + _bullets[i].text, + ), + ), + ); + } + + void _reorderItem(int oldIndex, int newIndex) { + setState(() { + final ctrl = _bullets.removeAt(oldIndex); + final level = _levels.removeAt(oldIndex); + final focus = _focusNodes.removeAt(oldIndex); + _bullets.insert(newIndex, ctrl); + _levels.insert(newIndex, level); + _focusNodes.insert(newIndex, focus); + }); + _emit(); + } + + void _addBulletAfter(int i) { + setState(() { + _bullets.insert(i + 1, _makeCtrl('')); + _levels.insert(i + 1, _levels[i]); + _focusNodes.insert(i + 1, FocusNode()); + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (i + 1 < _focusNodes.length) _focusNodes[i + 1].requestFocus(); + }); + } + + void _removeBulletAndFocus(int i) { + if (_bullets.length <= 1) return; + final target = (i - 1).clamp(0, _bullets.length - 2); + setState(() { + _bullets[i].removeListener(_emit); + _bullets[i].dispose(); + _bullets.removeAt(i); + _levels.removeAt(i); + _focusNodes[i].dispose(); + _focusNodes.removeAt(i); + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (target < _focusNodes.length) _focusNodes[target].requestFocus(); + }); + } + + Future _handlePaste(int i) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text == null) return; + final lines = data!.text! + .split('\n') + .map((l) => l.trim().replaceAll(RegExp(r'^[-*•◦▪▫]\s*'), '')) + .where((l) => l.isNotEmpty) + .toList(); + if (lines.isEmpty) return; + if (lines.length == 1) { + final ctrl = _bullets[i]; + final sel = ctrl.selection; + final start = sel.isValid ? sel.start : ctrl.text.length; + final end = sel.isValid ? sel.end : ctrl.text.length; + ctrl.value = TextEditingValue( + text: ctrl.text.replaceRange(start, end, lines[0]), + selection: TextSelection.collapsed(offset: start + lines[0].length), + ); + return; + } + setState(() { + _bullets[i].removeListener(_emit); + _bullets[i].dispose(); + _bullets[i] = _makeCtrl(lines[0]); + for (int j = 1; j < lines.length; j++) { + _bullets.insert(i + j, _makeCtrl(lines[j])); + _levels.insert(i + j, _levels[i]); + _focusNodes.insert(i + j, FocusNode()); + } + }); + _emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final last = i + lines.length - 1; + if (last < _focusNodes.length) _focusNodes[last].requestFocus(); + }); + } + + Future _pasteImage() async { + final path = await widget.imageService.pasteImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + Future _pickImage() async { + final path = await widget.imageService.pickImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + @override + void dispose() { + _title.dispose(); + for (final c in _bullets) { + c.dispose(); + } + for (final f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final imagePath = widget.slide.imagePath; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), + const SizedBox(height: 16), + const SectionLabel('Bullets (links)'), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: _reorderItem, + children: [ + for (int i = 0; i < _bullets.length; i++) _buildBulletRow(i), + ], + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => _addBulletAfter(_bullets.length - 1), + icon: const Icon(Icons.add, size: 16), + label: const Text('Bullet toevoegen'), + ), + ), + const SizedBox(height: 16), + const SectionLabel('Afbeelding (rechts)'), + ImagePickerBar( + imagePath: imagePath, + imageCaption: widget.slide.imageCaption, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath: path, imageCaption: caption), + ), + onBrowse: _pickImage, + onPaste: _pasteImage, + onClear: imagePath.isNotEmpty + ? () => widget.onUpdate( + widget.slide.copyWith(imagePath: '', imageCaption: ''), + ) + : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption: caption)), + ), + const SizedBox(height: 12), + const SectionLabel('Breedte afbeeldingspaneel (rechts)'), + ImageZoomControl( + value: widget.slide.imageSize > 0 ? widget.slide.imageSize : 40, + onChanged: (v) => + widget.onUpdate(widget.slide.copyWith(imageSize: v)), + step: 5, + minValue: 20, + maxValue: 70, + ), + ], + ); + } + + Widget _buildBulletRow(int i) { + final level = _levels[i]; + return Padding( + key: ValueKey(_bullets[i]), + padding: EdgeInsets.only(left: level * 20.0, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ReorderableDragStartListener( + index: i, + child: const Icon( + Icons.drag_indicator, + size: 16, + color: Color(0xFFCBD5E1), + ), + ), + const SizedBox(width: 4), + Text( + _markerForLevel(level), + style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), + const SizedBox(width: 8), + Expanded( + child: Focus( + onKeyEvent: (_, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + _addBulletAfter(i); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.backspace && + _bullets[i].text.isEmpty && + _bullets.length > 1) { + _removeBulletAndFocus(i); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (HardwareKeyboard.instance.isShiftPressed) { + if (_levels[i] > 0) setState(() => _levels[i]--); + } else { + if (_levels[i] < _maxLevel) setState(() => _levels[i]++); + } + _emit(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.keyV && + (HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed)) { + _handlePaste(i); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _bullets[i], + focusNode: _focusNodes[i], + decoration: InputDecoration( + hintText: 'Bullet ${i + 1}', + isDense: true, + ), + ), + ), + ), + IconButton( + icon: const Icon( + Icons.remove_circle_outline, + size: 18, + color: Color(0xFF94A3B8), + ), + onPressed: _bullets.length > 1 + ? () => _removeBulletAndFocus(i) + : null, + padding: const EdgeInsets.symmetric(horizontal: 4), + constraints: const BoxConstraints(minWidth: 28), + ), + ], + ), + ); + } + + String _markerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; + } +} diff --git a/lib/widgets/editors/free_markdown_editor.dart b/lib/widgets/editors/free_markdown_editor.dart new file mode 100644 index 0000000..4653693 --- /dev/null +++ b/lib/widgets/editors/free_markdown_editor.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; + +class FreeMarkdownEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + + const FreeMarkdownEditor({ + super.key, + required this.slide, + required this.onUpdate, + }); + + @override + State createState() => _FreeMarkdownEditorState(); +} + +class _FreeMarkdownEditorState extends State { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.slide.customMarkdown); + _ctrl.addListener(_emit); + } + + void _emit() { + widget.onUpdate(widget.slide.copyWith(customMarkdown: _ctrl.text)); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Markdown inhoud', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + const SizedBox(height: 6), + Expanded( + child: TextField( + controller: _ctrl, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: const InputDecoration( + hintText: '# Slide\n\nInhoud hier...', + alignLabelWithHint: true, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/editors/image_slide_editor.dart b/lib/widgets/editors/image_slide_editor.dart new file mode 100644 index 0000000..45ac32f --- /dev/null +++ b/lib/widgets/editors/image_slide_editor.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; +import '../../services/image_service.dart'; +import '_editor_field.dart'; + +class ImageSlideEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final ImageService imageService; + final List searchPaths; + final String? captionBasePath; + + const ImageSlideEditor({ + super.key, + required this.slide, + required this.onUpdate, + required this.imageService, + this.searchPaths = const [], + this.captionBasePath, + }); + + @override + State createState() => _ImageSlideEditorState(); +} + +class _ImageSlideEditorState extends State { + late final TextEditingController _title; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + } + + void _emit() => widget.onUpdate(widget.slide.copyWith(title: _title.text)); + + Future _pasteImage() async { + final path = await widget.imageService.pasteImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + Future _pickImage() async { + final path = await widget.imageService.pickImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const SectionLabel('Achtergrondafbeelding'), + ImagePickerBar( + imagePath: widget.slide.imagePath, + imageCaption: widget.slide.imageCaption, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath: path, imageCaption: caption), + ), + onBrowse: _pickImage, + onPaste: _pasteImage, + onClear: widget.slide.imagePath.isNotEmpty + ? () => widget.onUpdate( + widget.slide.copyWith(imagePath: '', imageCaption: ''), + ) + : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption: caption)), + ), + const SizedBox(height: 16), + const SectionLabel('Zoom afbeelding'), + ImageZoomControl( + value: widget.slide.imageSize, + onChanged: (v) => + widget.onUpdate(widget.slide.copyWith(imageSize: v)), + ), + const SizedBox(height: 16), + EditorField( + label: 'Titel overlay (optioneel)', + controller: _title, + hint: 'Titel over de afbeelding', + maxLines: 2, + ), + ], + ); + } +} diff --git a/lib/widgets/editors/quote_editor.dart b/lib/widgets/editors/quote_editor.dart new file mode 100644 index 0000000..15aa873 --- /dev/null +++ b/lib/widgets/editors/quote_editor.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/slide.dart'; +import '../../state/deck_provider.dart'; +import '_editor_field.dart'; + +class QuoteEditor extends ConsumerStatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final List searchPaths; + final String? captionBasePath; + + const QuoteEditor({ + super.key, + required this.slide, + required this.onUpdate, + this.searchPaths = const [], + this.captionBasePath, + }); + + @override + ConsumerState createState() => _QuoteEditorState(); +} + +class _QuoteEditorState extends ConsumerState { + late final TextEditingController _quote; + late final TextEditingController _author; + + @override + void initState() { + super.initState(); + _quote = TextEditingController(text: widget.slide.quote); + _author = TextEditingController(text: widget.slide.quoteAuthor); + _quote.addListener(_emit); + _author.addListener(_emit); + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith(quote: _quote.text, quoteAuthor: _author.text), + ); + } + + Future _pasteBgImage() async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pasteImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + Future _pickBgImage() async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pickImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + void _clearBgImage() { + widget.onUpdate(widget.slide.copyWith(imagePath: '', imageCaption: '')); + } + + @override + void dispose() { + _quote.dispose(); + _author.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final imagePath = widget.slide.imagePath; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField( + label: 'Citaat', + controller: _quote, + hint: 'Citaat tekst...', + maxLines: 5, + ), + const SizedBox(height: 16), + EditorField( + label: 'Auteur', + controller: _author, + hint: 'Naam van de auteur', + maxLines: 1, + ), + const SizedBox(height: 20), + + // ── Background image ────────────────────────────────────────────── + const SectionLabel('Achtergrondafbeelding (optioneel)'), + const SizedBox(height: 4), + const Text( + 'De afbeelding wordt schermvullend als achtergrond getoond ' + 'met verminderde opaciteit zodat de tekst leesbaar blijft.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + const SizedBox(height: 8), + ImagePickerBar( + imagePath: imagePath, + imageCaption: widget.slide.imageCaption, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath: path, imageCaption: caption), + ), + onBrowse: _pickBgImage, + onPaste: _pasteBgImage, + onClear: imagePath.isNotEmpty ? _clearBgImage : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption: caption)), + label: 'Geen achtergrondafbeelding', + ), + if (imagePath.isNotEmpty) ...[ + const SizedBox(height: 12), + const SectionLabel('Zoom achtergrond'), + ImageZoomControl( + value: widget.slide.imageSize, + onChanged: (v) => + widget.onUpdate(widget.slide.copyWith(imageSize: v)), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/editors/section_editor.dart b/lib/widgets/editors/section_editor.dart new file mode 100644 index 0000000..10a9297 --- /dev/null +++ b/lib/widgets/editors/section_editor.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; +import '_editor_field.dart'; + +class SectionEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + + const SectionEditor({super.key, required this.slide, required this.onUpdate}); + + @override + State createState() => _SectionEditorState(); +} + +class _SectionEditorState extends State { + late final TextEditingController _title; + late final TextEditingController _subtitle; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _subtitle = TextEditingController(text: widget.slide.subtitle); + _title.addListener(_emit); + _subtitle.addListener(_emit); + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith(title: _title.text, subtitle: _subtitle.text), + ); + } + + @override + void dispose() { + _title.dispose(); + _subtitle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return EditorFieldList( + children: [ + EditorField( + label: 'Tussentitel (H1)', + controller: _title, + hint: 'Sectienaam', + maxLines: 2, + ), + EditorField( + label: 'Ondertitel / toelichting', + controller: _subtitle, + hint: 'Optionele toelichting', + maxLines: 3, + ), + ], + ); + } +} diff --git a/lib/widgets/editors/table_editor.dart b/lib/widgets/editors/table_editor.dart new file mode 100644 index 0000000..a88d104 --- /dev/null +++ b/lib/widgets/editors/table_editor.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; +import '_editor_field.dart'; + +/// Editor for a table slide. Stores cells as a rectangular grid of +/// [TextEditingController]s where the first row is the header. +class TableEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + + const TableEditor({super.key, required this.slide, required this.onUpdate}); + + @override + State createState() => _TableEditorState(); +} + +class _TableEditorState extends State { + static const double _rowActionWidth = 40; + + late final TextEditingController _title; + late List> _cells; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + _initCells(widget.slide.tableRows); + } + + void _initCells(List> raw) { + final rows = raw.isEmpty + ? >[ + // Lege koppen; de hint in het invoerveld toont 'Kolom 1' etc. + ['', ''], + ['', ''], + ] + : raw.map((r) => List.from(r)).toList(); + final colCount = rows.fold(1, (m, r) => r.length > m ? r.length : m); + _cells = rows.map((row) { + return List.generate( + colCount, + (c) => _makeCtrl(c < row.length ? row[c] : ''), + ); + }).toList(); + } + + TextEditingController _makeCtrl(String text) { + final c = TextEditingController(text: text); + c.addListener(_emit); + return c; + } + + int get _colCount => _cells.isEmpty ? 0 : _cells.first.length; + + void _emit() { + widget.onUpdate( + widget.slide.copyWith( + title: _title.text, + tableRows: _cells + .map((row) => row.map((c) => c.text).toList()) + .toList(), + ), + ); + } + + void _addRow() { + setState(() { + _cells.add( + List.generate(_colCount, (_) => _makeCtrl('')), + ); + }); + _emit(); + } + + void _removeRow(int r) { + if (_cells.length <= 1) return; + setState(() { + for (final c in _cells[r]) { + c.removeListener(_emit); + c.dispose(); + } + _cells.removeAt(r); + }); + _emit(); + } + + void _addColumn() { + setState(() { + for (var r = 0; r < _cells.length; r++) { + // Nieuwe kolom start overal leeg; de koptekst toont een hint. + _cells[r].add(_makeCtrl('')); + } + }); + _emit(); + } + + void _removeColumn(int c) { + if (_colCount <= 1) return; + setState(() { + for (final row in _cells) { + row[c].removeListener(_emit); + row[c].dispose(); + row.removeAt(c); + } + }); + _emit(); + } + + @override + void dispose() { + _title.dispose(); + for (final row in _cells) { + for (final c in row) { + c.dispose(); + } + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), + const SizedBox(height: 16), + const SectionLabel('Tabel'), + const Padding( + padding: EdgeInsets.only(bottom: 6), + child: Text( + 'Tip: druk op Enter binnen een cel voor een nieuwe regel.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + _buildColumnControls(), + for (int r = 0; r < _cells.length; r++) _buildRow(r), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + onPressed: _addRow, + icon: const Icon(Icons.add, size: 16), + label: const Text('Rij toevoegen'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: _addColumn, + icon: const Icon(Icons.add, size: 16), + label: const Text('Kolom toevoegen'), + ), + ], + ), + ], + ); + } + + Widget _buildColumnControls() { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + for (int c = 0; c < _colCount; c++) + Expanded( + child: Center( + child: IconButton( + icon: const Icon( + Icons.delete_outline, + size: 16, + color: Color(0xFF94A3B8), + ), + onPressed: _colCount > 1 ? () => _removeColumn(c) : null, + tooltip: 'Kolom ${c + 1} verwijderen', + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + ), + ), + ), + const SizedBox(width: _rowActionWidth), + ], + ), + ); + } + + Widget _buildRow(int r) { + final isHeader = r == 0; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + // Top-align so cells that grow to multiple lines stay lined up. + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int c = 0; c < _cells[r].length; c++) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: TextField( + controller: _cells[r][c], + // Meerdere regels toestaan: het veld groeit mee en Enter + // voegt een nieuwe regel toe binnen de cel. + minLines: 1, + maxLines: null, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + style: TextStyle( + fontSize: 13, + fontWeight: isHeader ? FontWeight.w600 : FontWeight.normal, + ), + decoration: InputDecoration( + isDense: true, + filled: isHeader, + fillColor: isHeader ? const Color(0xFFF1F5F9) : null, + hintText: isHeader ? 'Kolom ${c + 1}' : null, + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + ), + ), + ), + ), + // Verwijderknop op de hoogte van de eerste regel houden. + SizedBox( + width: _rowActionWidth, + height: 40, + child: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + size: 18, + color: Color(0xFF94A3B8), + ), + onPressed: _cells.length > 1 ? () => _removeRow(r) : null, + tooltip: isHeader ? 'Koprij verwijderen' : 'Rij verwijderen', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/editors/title_editor.dart b/lib/widgets/editors/title_editor.dart new file mode 100644 index 0000000..09c69fe --- /dev/null +++ b/lib/widgets/editors/title_editor.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/slide.dart'; +import '../../state/deck_provider.dart'; +import '_editor_field.dart'; + +class TitleEditor extends ConsumerStatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final List searchPaths; + final String? captionBasePath; + + const TitleEditor({ + super.key, + required this.slide, + required this.onUpdate, + this.searchPaths = const [], + this.captionBasePath, + }); + + @override + ConsumerState createState() => _TitleEditorState(); +} + +class _TitleEditorState extends ConsumerState { + late final TextEditingController _title; + late final TextEditingController _subtitle; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _subtitle = TextEditingController(text: widget.slide.subtitle); + _title.addListener(_emit); + _subtitle.addListener(_emit); + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith(title: _title.text, subtitle: _subtitle.text), + ); + } + + Future _pasteBgImage() async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pasteImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + Future _pickBgImage() async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pickImage(); + if (path != null) { + widget.onUpdate(widget.slide.copyWith(imagePath: path, imageCaption: '')); + } + } + + void _clearBgImage() { + widget.onUpdate(widget.slide.copyWith(imagePath: '', imageCaption: '')); + } + + @override + void dispose() { + _title.dispose(); + _subtitle.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final imagePath = widget.slide.imagePath; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField( + label: 'Titel (H1)', + controller: _title, + hint: 'Presentatietitel', + maxLines: 2, + ), + const SizedBox(height: 16), + EditorField( + label: 'Subtitel (H2)', + controller: _subtitle, + hint: 'Optionele subtitel', + maxLines: 2, + ), + const SizedBox(height: 20), + + // ── Background image ───────────────────────────────────────────── + const SectionLabel('Achtergrondafbeelding (optioneel)'), + const SizedBox(height: 4), + const Text( + 'De afbeelding wordt schermvullend als achtergrond getoond ' + 'met verminderde opaciteit zodat de tekst leesbaar blijft.', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + const SizedBox(height: 8), + ImagePickerBar( + imagePath: imagePath, + imageCaption: widget.slide.imageCaption, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath: path, imageCaption: caption), + ), + onBrowse: _pickBgImage, + onPaste: _pasteBgImage, + onClear: imagePath.isNotEmpty ? _clearBgImage : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption: caption)), + label: 'Geen achtergrondafbeelding', + ), + if (imagePath.isNotEmpty) ...[ + const SizedBox(height: 12), + const SectionLabel('Zoom achtergrond'), + ImageZoomControl( + value: widget.slide.imageSize, + onChanged: (v) => + widget.onUpdate(widget.slide.copyWith(imageSize: v)), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/editors/two_bullets_editor.dart b/lib/widgets/editors/two_bullets_editor.dart new file mode 100644 index 0000000..dd97941 --- /dev/null +++ b/lib/widgets/editors/two_bullets_editor.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../models/slide.dart'; +import '_editor_field.dart'; + +typedef _Mutate = void Function(VoidCallback fn); + +class TwoBulletsEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + + const TwoBulletsEditor({ + super.key, + required this.slide, + required this.onUpdate, + }); + + @override + State createState() => _TwoBulletsEditorState(); +} + +class _TwoBulletsEditorState extends State { + late final TextEditingController _title; + late _BulletSet _left; + late _BulletSet _right; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + _left = _BulletSet(widget.slide.bullets, _emit); + _right = _BulletSet(widget.slide.bullets2, _emit); + } + + void _emit() { + widget.onUpdate( + widget.slide.copyWith( + title: _title.text, + bullets: _left.values, + bullets2: _right.values, + ), + ); + } + + @override + void dispose() { + _title.dispose(); + _left.dispose(); + _right.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 560; + final columns = [ + _BulletColumn(label: 'Bullets links', set: _left, emit: _emit), + _BulletColumn(label: 'Bullets rechts', set: _right, emit: _emit), + ]; + if (narrow) { + return Column( + children: [columns[0], const SizedBox(height: 18), columns[1]], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: columns[0]), + const SizedBox(width: 16), + Expanded(child: columns[1]), + ], + ); + }, + ), + ], + ); + } +} + +class _BulletSet { + static const maxLevel = 4; + + final VoidCallback emit; + late List controllers; + late List levels; + late List focusNodes; + + _BulletSet(List raw, this.emit) { + final list = raw.isEmpty ? [''] : raw; + levels = list.map(_levelOf).toList(); + controllers = list.map((b) => _makeCtrl(b.trimLeft())).toList(); + focusNodes = List.generate(controllers.length, (_) => FocusNode()); + } + + List get values => List.generate( + controllers.length, + (i) => '\t' * levels[i] + controllers[i].text, + ); + + static int _levelOf(String b) { + int l = 0; + while (l < b.length && b[l] == '\t' && l < maxLevel) { + l++; + } + return l; + } + + TextEditingController _makeCtrl(String text) { + final c = TextEditingController(text: text); + c.addListener(emit); + return c; + } + + void addAfter(_Mutate mutate, int i) { + mutate(() { + controllers.insert(i + 1, _makeCtrl('')); + levels.insert(i + 1, levels[i]); + focusNodes.insert(i + 1, FocusNode()); + }); + emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (i + 1 < focusNodes.length) focusNodes[i + 1].requestFocus(); + }); + } + + void removeAndFocus(_Mutate mutate, int i) { + if (controllers.length <= 1) return; + final target = (i - 1).clamp(0, controllers.length - 2); + mutate(() { + controllers[i].removeListener(emit); + controllers[i].dispose(); + controllers.removeAt(i); + levels.removeAt(i); + focusNodes[i].dispose(); + focusNodes.removeAt(i); + }); + emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (target < focusNodes.length) focusNodes[target].requestFocus(); + }); + } + + Future paste(_Mutate mutate, int i) async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text == null) return; + final lines = data!.text! + .split('\n') + .map((l) => l.trim().replaceAll(RegExp(r'^[-*•◦▪▫]\s*'), '')) + .where((l) => l.isNotEmpty) + .toList(); + if (lines.isEmpty) return; + + if (lines.length == 1) { + final ctrl = controllers[i]; + final sel = ctrl.selection; + final start = sel.isValid ? sel.start : ctrl.text.length; + final end = sel.isValid ? sel.end : ctrl.text.length; + ctrl.value = TextEditingValue( + text: ctrl.text.replaceRange(start, end, lines[0]), + selection: TextSelection.collapsed(offset: start + lines[0].length), + ); + return; + } + + mutate(() { + controllers[i].removeListener(emit); + controllers[i].dispose(); + controllers[i] = _makeCtrl(lines[0]); + for (int j = 1; j < lines.length; j++) { + controllers.insert(i + j, _makeCtrl(lines[j])); + levels.insert(i + j, levels[i]); + focusNodes.insert(i + j, FocusNode()); + } + }); + emit(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final last = i + lines.length - 1; + if (last < focusNodes.length) focusNodes[last].requestFocus(); + }); + } + + void dispose() { + for (final c in controllers) { + c.dispose(); + } + for (final f in focusNodes) { + f.dispose(); + } + } +} + +class _BulletColumn extends StatefulWidget { + final String label; + final _BulletSet set; + final VoidCallback emit; + + const _BulletColumn({ + required this.label, + required this.set, + required this.emit, + }); + + @override + State<_BulletColumn> createState() => _BulletColumnState(); +} + +class _BulletColumnState extends State<_BulletColumn> { + _BulletSet get set => widget.set; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionLabel(widget.label), + const SizedBox(height: 6), + for (int i = 0; i < set.controllers.length; i++) _buildRow(i), + const SizedBox(height: 4), + TextButton.icon( + onPressed: () => + set.addAfter((fn) => setState(fn), set.controllers.length - 1), + icon: const Icon(Icons.add, size: 16), + label: const Text('Bullet toevoegen'), + ), + ], + ); + } + + Widget _buildRow(int i) { + final level = set.levels[i]; + return Padding( + key: ValueKey(set.controllers[i]), + padding: EdgeInsets.only(left: level * 20.0, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + _markerForLevel(level), + style: const TextStyle(fontSize: 16, color: Color(0xFF94A3B8)), + ), + const SizedBox(width: 8), + Expanded( + child: Focus( + onKeyEvent: (_, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey == LogicalKeyboardKey.enter) { + set.addAfter((fn) => setState(fn), i); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.backspace && + set.controllers[i].text.isEmpty && + set.controllers.length > 1) { + set.removeAndFocus((fn) => setState(fn), i); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.tab) { + if (HardwareKeyboard.instance.isShiftPressed) { + if (set.levels[i] > 0) setState(() => set.levels[i]--); + } else { + if (set.levels[i] < _BulletSet.maxLevel) { + setState(() => set.levels[i]++); + } + } + widget.emit(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.keyV && + (HardwareKeyboard.instance.isMetaPressed || + HardwareKeyboard.instance.isControlPressed)) { + set.paste((fn) => setState(fn), i); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: set.controllers[i], + focusNode: set.focusNodes[i], + decoration: InputDecoration( + hintText: 'Bullet ${i + 1}', + isDense: true, + ), + ), + ), + ), + IconButton( + icon: const Icon( + Icons.remove_circle_outline, + size: 18, + color: Color(0xFF94A3B8), + ), + onPressed: set.controllers.length > 1 + ? () => set.removeAndFocus((fn) => setState(fn), i) + : null, + tooltip: 'Verwijder', + padding: const EdgeInsets.symmetric(horizontal: 4), + constraints: const BoxConstraints(minWidth: 28), + ), + ], + ), + ); + } + + String _markerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; + } +} diff --git a/lib/widgets/editors/two_images_editor.dart b/lib/widgets/editors/two_images_editor.dart new file mode 100644 index 0000000..620305c --- /dev/null +++ b/lib/widgets/editors/two_images_editor.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/slide.dart'; +import '../../state/deck_provider.dart'; +import '_editor_field.dart'; + +class TwoImagesEditor extends ConsumerStatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final List searchPaths; + final String? captionBasePath; + + const TwoImagesEditor({ + super.key, + required this.slide, + required this.onUpdate, + this.searchPaths = const [], + this.captionBasePath, + }); + + @override + ConsumerState createState() => _TwoImagesEditorState(); +} + +class _TwoImagesEditorState extends ConsumerState { + late final TextEditingController _title; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emitTitle); + } + + void _emitTitle() { + widget.onUpdate(widget.slide.copyWith(title: _title.text)); + } + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + Future _pasteImage(bool isSecond) async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pasteImage(); + if (path != null) { + widget.onUpdate( + isSecond + ? widget.slide.copyWith(imagePath2: path, imageCaption2: '') + : widget.slide.copyWith(imagePath: path, imageCaption: ''), + ); + } + } + + Future _pickImage(bool isSecond) async { + final imgService = ref.read(imageServiceProvider); + final path = await imgService.pickImage(); + if (path != null) { + widget.onUpdate( + isSecond + ? widget.slide.copyWith(imagePath2: path, imageCaption2: '') + : widget.slide.copyWith(imagePath: path, imageCaption: ''), + ); + } + } + + void _clearImage(bool isSecond) { + widget.onUpdate( + isSecond + ? widget.slide.copyWith(imagePath2: '', imageCaption2: '') + : widget.slide.copyWith(imagePath: '', imageCaption: ''), + ); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField( + label: 'Ondertitel (optioneel)', + controller: _title, + hint: 'Tekst onder de afbeeldingen', + ), + const SizedBox(height: 20), + const SectionLabel('Linker afbeelding'), + ImagePickerBar( + imagePath: widget.slide.imagePath, + imageCaption: widget.slide.imageCaption, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath: path, imageCaption: caption), + ), + onBrowse: () => _pickImage(false), + onPaste: () => _pasteImage(false), + onClear: widget.slide.imagePath.isNotEmpty + ? () => _clearImage(false) + : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption: caption)), + ), + const SizedBox(height: 20), + const SectionLabel('Rechter afbeelding'), + ImagePickerBar( + imagePath: widget.slide.imagePath2, + imageCaption: widget.slide.imageCaption2, + searchPaths: widget.searchPaths, + captionBasePath: widget.captionBasePath, + onPicked: (path, caption) => widget.onUpdate( + widget.slide.copyWith(imagePath2: path, imageCaption2: caption), + ), + onBrowse: () => _pickImage(true), + onPaste: () => _pasteImage(true), + onClear: widget.slide.imagePath2.isNotEmpty + ? () => _clearImage(true) + : null, + onCaptionChanged: (caption) => + widget.onUpdate(widget.slide.copyWith(imageCaption2: caption)), + ), + const SizedBox(height: 20), + const SectionLabel('Verdeling (links / rechts)'), + ImageZoomControl( + value: widget.slide.imageSize > 0 ? widget.slide.imageSize : 50, + onChanged: (v) => + widget.onUpdate(widget.slide.copyWith(imageSize: v)), + step: 5, + minValue: 20, + maxValue: 80, + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + 'Links ${widget.slide.imageSize > 0 ? widget.slide.imageSize : 50}% — ' + 'Rechts ${100 - (widget.slide.imageSize > 0 ? widget.slide.imageSize : 50)}%', + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/editors/video_slide_editor.dart b/lib/widgets/editors/video_slide_editor.dart new file mode 100644 index 0000000..8a91657 --- /dev/null +++ b/lib/widgets/editors/video_slide_editor.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import '../../models/slide.dart'; +import '../../services/image_service.dart'; +import '_editor_field.dart'; +import 'audio_attachment_editor.dart'; + +class VideoSlideEditor extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + final ImageService imageService; + + const VideoSlideEditor({ + super.key, + required this.slide, + required this.onUpdate, + required this.imageService, + }); + + @override + State createState() => _VideoSlideEditorState(); +} + +class _VideoSlideEditorState extends State { + late final TextEditingController _title; + + @override + void initState() { + super.initState(); + _title = TextEditingController(text: widget.slide.title); + _title.addListener(_emit); + } + + void _emit() { + widget.onUpdate(widget.slide.copyWith(title: _title.text)); + } + + Future _pickVideo() async { + final path = await widget.imageService.pickVideo(); + if (path != null) widget.onUpdate(widget.slide.copyWith(videoPath: path)); + } + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + EditorField( + label: 'Titel (optioneel)', + controller: _title, + hint: 'Titel boven de video', + maxLines: 2, + ), + const SizedBox(height: 16), + const SectionLabel('Video'), + Row( + children: [ + Expanded(child: _PathBox(path: widget.slide.videoPath)), + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _pickVideo, + icon: const Icon(Icons.movie_outlined, size: 16), + label: const Text('Kiezen'), + ), + ], + ), + const SizedBox(height: 8), + Material( + color: Colors.transparent, + child: CheckboxListTile( + value: widget.slide.videoAutoplay, + onChanged: (value) => widget.onUpdate( + widget.slide.copyWith(videoAutoplay: value ?? false), + ), + title: const Text('Video automatisch afspelen'), + dense: true, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ), + const SizedBox(height: 16), + AudioAttachmentEditor( + slide: widget.slide, + imageService: widget.imageService, + onUpdate: widget.onUpdate, + ), + ], + ); + } +} + +class _PathBox extends StatelessWidget { + final String path; + + const _PathBox({required this.path}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFCBD5E1)), + borderRadius: BorderRadius.circular(6), + color: Colors.white, + ), + child: Text( + path.isEmpty ? 'Geen video gekozen' : path, + style: TextStyle( + fontSize: 12, + color: path.isEmpty + ? const Color(0xFF94A3B8) + : const Color(0xFF334155), + ), + overflow: TextOverflow.ellipsis, + ), + ); + } +} diff --git a/lib/widgets/panels/editor_panel.dart b/lib/widgets/panels/editor_panel.dart new file mode 100644 index 0000000..efa0303 --- /dev/null +++ b/lib/widgets/panels/editor_panel.dart @@ -0,0 +1,828 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../services/image_service.dart'; +import '../../state/deck_provider.dart'; +import '../../state/editor_provider.dart'; +import '../../state/settings_provider.dart'; +import '../../theme/app_theme.dart'; +import '../editors/bullets_editor.dart'; +import '../editors/bullets_image_editor.dart'; +import '../editors/audio_attachment_editor.dart'; +import '../editors/free_markdown_editor.dart'; +import '../editors/image_slide_editor.dart'; +import '../editors/quote_editor.dart'; +import '../editors/section_editor.dart'; +import '../editors/table_editor.dart'; +import '../editors/title_editor.dart'; +import '../editors/two_bullets_editor.dart'; +import '../editors/two_images_editor.dart'; +import '../editors/video_slide_editor.dart'; + +class EditorPanel extends ConsumerWidget { + const EditorPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final deckState = ref.watch(deckProvider); + final editor = ref.watch(editorProvider); + + final deck = deckState.deck!; + final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1); + final slide = deck.slides[idx]; + + final deckNotifier = ref.read(deckProvider.notifier); + final editorNotifier = ref.read(editorProvider.notifier); + final imgService = ref.read(imageServiceProvider); + + void update(Slide updated) => deckNotifier.updateSlide(idx, updated); + + final settings = ref.watch(settingsProvider); + + // Zoekpaden voor de afbeeldingencarousel + final searchPaths = [ + if (deck.projectPath != null) '${deck.projectPath}/images', + if (deck.projectPath != null) deck.projectPath!, + if (settings.homeDirectory != null) settings.homeDirectory!, + ]; + + if (editor.mode == EditorMode.markdown) { + return _MarkdownModeEditor( + // Verse instantie na undo/redo zodat de markdown opnieuw wordt geladen. + key: ValueKey('md-${deckState.revision}'), + initialContent: deckNotifier.generateMarkdown(), + onApply: (md) { + final ok = deckNotifier.applyMarkdown(md); + editorNotifier.setParseError(!ok); + return ok; + }, + parseError: editor.parseError, + onExitMarkdown: () => editorNotifier.setMode(EditorMode.visual), + ); + } + + // De tekstvelden cachen hun inhoud in eigen controllers en verversen alleen + // op slide-id. Bij undo/redo verandert [revision], waardoor deze subtree + // remount en de velden de teruggedraaide inhoud tonen. + return KeyedSubtree( + key: ValueKey('editor-rev-${deckState.revision}'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Toolbar: slide-type + stijlprofiel ───────────────────────────── + _EditorToolbar( + slide: slide, + profiles: settings.themeProfiles, + activeProfile: deck.themeProfile, + defaultProfile: settings.themeProfile, + onTypeChanged: (newType) { + if (newType == slide.type) return; + update(_convertSlideType(slide, newType)); + }, + onProfileChanged: (profile) => + deckNotifier.updateThemeProfile(profile), + onDefaultProfileRequested: () => + deckNotifier.updateThemeProfile(settings.themeProfile), + ), + const Divider(height: 1), + + // ── Slide editor body ──────────────────────────────────────────── + Expanded( + child: Column( + children: [ + Expanded( + child: _buildEditor( + slide, + update, + imgService, + searchPaths, + deck.projectPath, + ), + ), + if (slide.type != SlideType.video) ...[ + const Divider(height: 1), + Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.all(12), + child: AudioAttachmentEditor( + slide: slide, + imageService: imgService, + onUpdate: update, + ), + ), + ], + if (deck.themeProfile.logoPath?.isNotEmpty == true) ...[ + const Divider(height: 1), + _SlideLogoControl(slide: slide, onUpdate: update), + ], + if (deck.themeProfile.footerText.trim().isNotEmpty || + deck.themeProfile.footerShowPageNumbers) ...[ + const Divider(height: 1), + _SlideFooterControl(slide: slide, onUpdate: update), + ], + const Divider(height: 1), + _SlideTimingControl(slide: slide, onUpdate: update), + const Divider(height: 1), + _NotesField(slide: slide, onUpdate: update), + ], + ), + ), + ], + ), + ); + } + + /// Re-type a slide while carrying over text fields where they make sense. + static Slide _convertSlideType(Slide slide, SlideType newType) { + final keepsBullets = + newType == SlideType.bullets || + newType == SlideType.twoBullets || + newType == SlideType.bulletsImage; + final keepsImage = + newType == SlideType.bulletsImage || + newType == SlideType.image || + newType == SlideType.twoImages; + return Slide( + id: slide.id, + type: newType, + title: slide.title, + subtitle: slide.subtitle, + bullets: keepsBullets + ? (slide.bullets.isNotEmpty ? slide.bullets : ['']) + : const [], + bullets2: newType == SlideType.twoBullets + ? (slide.bullets2.isNotEmpty ? slide.bullets2 : ['']) + : const [], + imagePath: keepsImage ? slide.imagePath : '', + imagePath2: newType == SlideType.twoImages ? slide.imagePath2 : '', + imageCaption: keepsImage ? slide.imageCaption : '', + imageCaption2: newType == SlideType.twoImages ? slide.imageCaption2 : '', + videoPath: newType == SlideType.video ? slide.videoPath : '', + videoAutoplay: slide.videoAutoplay, + audioPath: slide.audioPath, + audioAutoplay: slide.audioAutoplay, + quote: slide.quote, + quoteAuthor: slide.quoteAuthor, + customMarkdown: slide.customMarkdown, + cssClass: slide.cssClass, + notes: slide.notes, + advanceDuration: slide.advanceDuration, + imageSize: slide.imageSize, + showLogo: slide.showLogo, + showFooter: slide.showFooter, + tableRows: newType == SlideType.table + ? (slide.tableRows.isNotEmpty + ? slide.tableRows + : const [ + // Lege koppen; de editor toont 'Kolom 1' etc. als hint. + ['', ''], + ['', ''], + ]) + : const [], + ); + } + + Widget _buildEditor( + Slide slide, + ValueChanged onUpdate, + ImageService imgService, + List searchPaths, + String? captionBasePath, + ) { + switch (slide.type) { + case SlideType.title: + return TitleEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + searchPaths: searchPaths, + captionBasePath: captionBasePath, + ); + case SlideType.section: + return SectionEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + ); + case SlideType.bullets: + return BulletsEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + ); + case SlideType.twoBullets: + return TwoBulletsEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + ); + case SlideType.bulletsImage: + return BulletsImageEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + imageService: imgService, + searchPaths: searchPaths, + captionBasePath: captionBasePath, + ); + case SlideType.twoImages: + return TwoImagesEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + searchPaths: searchPaths, + captionBasePath: captionBasePath, + ); + case SlideType.image: + return ImageSlideEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + imageService: imgService, + searchPaths: searchPaths, + captionBasePath: captionBasePath, + ); + case SlideType.video: + return VideoSlideEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + imageService: imgService, + ); + case SlideType.quote: + return QuoteEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + searchPaths: searchPaths, + captionBasePath: captionBasePath, + ); + case SlideType.table: + return TableEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + ); + case SlideType.freeMarkdown: + return FreeMarkdownEditor( + key: ValueKey(slide.id), + slide: slide, + onUpdate: onUpdate, + ); + } + } +} + +// ── Editor toolbar: slide-type + stijlprofiel dropdowns ────────────────────── + +IconData _slideTypeIcon(SlideType type) { + switch (type) { + case SlideType.title: + return Icons.title; + case SlideType.section: + return Icons.bookmark_outline; + case SlideType.bullets: + return Icons.format_list_bulleted; + case SlideType.twoBullets: + return Icons.view_column_outlined; + case SlideType.bulletsImage: + return Icons.view_agenda_outlined; + case SlideType.twoImages: + return Icons.auto_stories_outlined; + case SlideType.image: + return Icons.image_outlined; + case SlideType.video: + return Icons.movie_outlined; + case SlideType.quote: + return Icons.format_quote_outlined; + case SlideType.table: + return Icons.table_chart_outlined; + case SlideType.freeMarkdown: + return Icons.code; + } +} + +class _EditorToolbar extends StatelessWidget { + final Slide slide; + final List profiles; + final ThemeProfile activeProfile; + final ThemeProfile defaultProfile; + final ValueChanged onTypeChanged; + final ValueChanged onProfileChanged; + final VoidCallback onDefaultProfileRequested; + + const _EditorToolbar({ + required this.slide, + required this.profiles, + required this.activeProfile, + required this.defaultProfile, + required this.onTypeChanged, + required this.onProfileChanged, + required this.onDefaultProfileRequested, + }); + + @override + Widget build(BuildContext context) { + // Make sure the active profile is always selectable, even when it was + // loaded from a file and is not part of the saved profile list. + final profileItems = [ + ...profiles, + if (!profiles.any((p) => p.name == activeProfile.name)) activeProfile, + ]; + + return Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + Expanded( + child: _ToolbarField( + label: 'TYPE', + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: slide.type, + isExpanded: true, + isDense: true, + borderRadius: BorderRadius.circular(6), + style: const TextStyle( + fontSize: 12, + color: AppTheme.navy, + fontWeight: FontWeight.w600, + ), + items: [ + for (final type in SlideType.values) + DropdownMenuItem( + value: type, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _slideTypeIcon(type), + size: 14, + color: AppTheme.navy, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + type.label, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + onChanged: (v) { + if (v != null) onTypeChanged(v); + }, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _ToolbarField( + label: 'STIJL', + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: activeProfile.name, + isExpanded: true, + isDense: true, + borderRadius: BorderRadius.circular(6), + style: const TextStyle( + fontSize: 12, + color: AppTheme.teal, + fontWeight: FontWeight.w600, + ), + items: [ + for (final profile in profileItems) + DropdownMenuItem( + value: profile.name, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.palette_outlined, + size: 14, + color: AppTheme.teal, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + profile.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + onChanged: (name) { + if (name == null) return; + final profile = profileItems.firstWhere( + (p) => p.name == name, + orElse: () => activeProfile, + ); + onProfileChanged(profile); + }, + ), + ), + ), + ), + // Alleen tonen wanneer de stijl afwijkt van de standaard — anders + // voegt het niets toe. Eén klik zet 'm terug op het standaardprofiel. + if (activeProfile.name != defaultProfile.name) ...[ + const SizedBox(width: 2), + Tooltip( + message: "Terug naar standaardstijl '${defaultProfile.name}'", + child: IconButton( + onPressed: onDefaultProfileRequested, + icon: const Icon(Icons.restart_alt, size: 16), + color: AppTheme.teal, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ), + ], + ], + ), + ); + } +} + +class _ToolbarField extends StatelessWidget { + final String label; + final Widget child; + + const _ToolbarField({required this.label, required this.child}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFF94A3B8), + letterSpacing: 1.0, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: child, + ), + ), + ], + ); + } +} + +// ── Timing instelling ───────────────────────────────────────────────────────── + +class _SlideTimingControl extends StatelessWidget { + final Slide slide; + final ValueChanged onUpdate; + const _SlideTimingControl({required this.slide, required this.onUpdate}); + + void _setDuration(double value) { + final clamped = (value * 10).round() / 10; // snap to 0.1s + onUpdate(slide.copyWith(advanceDuration: clamped < 0 ? 0 : clamped)); + } + + @override + Widget build(BuildContext context) { + final enabled = slide.advanceDuration > 0; + final duration = slide.advanceDuration; + + return Container( + color: const Color(0xFFF0F9FF), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Row( + children: [ + const Icon(Icons.timer_outlined, size: 14, color: Color(0xFF0369A1)), + const SizedBox(width: 8), + Checkbox( + value: enabled, + onChanged: (v) => _setDuration(v == true ? 3.0 : 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + const Text( + 'Automatisch doorgaan na', + style: TextStyle(fontSize: 12, color: Color(0xFF0369A1)), + ), + const SizedBox(width: 8), + // Minus knop + SizedBox( + width: 24, + height: 24, + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.remove, size: 14), + onPressed: enabled && duration > 0.1 + ? () => _setDuration(duration - 0.1) + : null, + color: const Color(0xFF0369A1), + ), + ), + // Waarde + Container( + width: 52, + alignment: Alignment.center, + child: Text( + enabled ? '${duration.toStringAsFixed(1)} s' : '—', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: enabled + ? const Color(0xFF0369A1) + : const Color(0xFF94A3B8), + ), + ), + ), + // Plus knop + SizedBox( + width: 24, + height: 24, + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.add, size: 14), + onPressed: enabled ? () => _setDuration(duration + 0.1) : null, + color: const Color(0xFF0369A1), + ), + ), + ], + ), + ); + } +} + +// ── Per-slide logo-zichtbaarheid ────────────────────────────────────────────── + +class _SlideLogoControl extends StatelessWidget { + final Slide slide; + final ValueChanged onUpdate; + const _SlideLogoControl({required this.slide, required this.onUpdate}); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Row( + children: [ + const Icon( + Icons.branding_watermark_outlined, + size: 14, + color: Color(0xFF64748B), + ), + const SizedBox(width: 8), + Checkbox( + value: slide.showLogo, + onChanged: (v) => onUpdate(slide.copyWith(showLogo: v ?? true)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + const Text( + 'Logo tonen op deze slide', + style: TextStyle(fontSize: 12, color: Color(0xFF475569)), + ), + ], + ), + ); + } +} + +// ── Per-slide footer-zichtbaarheid ──────────────────────────────────────────── + +class _SlideFooterControl extends StatelessWidget { + final Slide slide; + final ValueChanged onUpdate; + const _SlideFooterControl({required this.slide, required this.onUpdate}); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Row( + children: [ + const Icon( + Icons.short_text_outlined, + size: 14, + color: Color(0xFF64748B), + ), + const SizedBox(width: 8), + Checkbox( + value: slide.showFooter, + onChanged: (v) => onUpdate(slide.copyWith(showFooter: v ?? true)), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + const SizedBox(width: 4), + const Text( + 'Footer tonen op deze slide', + style: TextStyle(fontSize: 12, color: Color(0xFF475569)), + ), + ], + ), + ); + } +} + +// ── Speakernotes veld ───────────────────────────────────────────────────────── + +class _NotesField extends StatefulWidget { + final Slide slide; + final ValueChanged onUpdate; + const _NotesField({required this.slide, required this.onUpdate}); + + @override + State<_NotesField> createState() => _NotesFieldState(); +} + +class _NotesFieldState extends State<_NotesField> { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.slide.notes); + _ctrl.addListener(_emit); + } + + @override + void didUpdateWidget(_NotesField old) { + super.didUpdateWidget(old); + if (old.slide.id != widget.slide.id) { + _ctrl.removeListener(_emit); + _ctrl.text = widget.slide.notes; + _ctrl.addListener(_emit); + } + } + + void _emit() => widget.onUpdate(widget.slide.copyWith(notes: _ctrl.text)); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFFFFBEB), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 10), + child: Icon(Icons.notes, size: 14, color: Color(0xFFB45309)), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _ctrl, + maxLines: 3, + minLines: 1, + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + hintText: 'Sprekersnotities...', + hintStyle: TextStyle(fontSize: 12, color: Color(0xFFD97706)), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + ], + ), + ); + } +} + +// ── Markdown mode editor ────────────────────────────────────────────────────── + +class _MarkdownModeEditor extends StatefulWidget { + final String initialContent; + final bool Function(String) onApply; + final bool parseError; + final VoidCallback onExitMarkdown; + + const _MarkdownModeEditor({ + super.key, + required this.initialContent, + required this.onApply, + required this.parseError, + required this.onExitMarkdown, + }); + + @override + State<_MarkdownModeEditor> createState() => _MarkdownModeEditorState(); +} + +class _MarkdownModeEditorState extends State<_MarkdownModeEditor> { + late final TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController(text: widget.initialContent); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Toolbar + Container( + color: const Color(0xFFFFF9E6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + const Icon(Icons.code, size: 14, color: Color(0xFF92400E)), + const SizedBox(width: 6), + const Expanded( + child: Text( + 'Markdown modus — bewerk de volledige presentatie als Marp Markdown', + style: TextStyle(fontSize: 11, color: Color(0xFF92400E)), + ), + ), + TextButton( + onPressed: () { + final ok = widget.onApply(_ctrl.text); + if (ok) widget.onExitMarkdown(); + }, + child: const Text('Toepassen'), + ), + TextButton( + onPressed: widget.onExitMarkdown, + child: const Text('Annuleren'), + ), + ], + ), + ), + if (widget.parseError) + Container( + color: const Color(0xFFFEE2E2), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: const Row( + children: [ + Icon(Icons.warning_amber_outlined, size: 14, color: Colors.red), + SizedBox(width: 6), + Expanded( + child: Text( + 'Markdown kon niet worden verwerkt. Controleer de syntax.', + style: TextStyle(fontSize: 11, color: Colors.red), + ), + ), + ], + ), + ), + const Divider(height: 1), + // Code editor + Expanded( + child: TextField( + controller: _ctrl, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 13, + height: 1.5, + ), + decoration: const InputDecoration( + contentPadding: EdgeInsets.all(16), + border: InputBorder.none, + filled: true, + fillColor: Color(0xFFF8FAFC), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/panels/preview_panel.dart b/lib/widgets/panels/preview_panel.dart new file mode 100644 index 0000000..f51aeda --- /dev/null +++ b/lib/widgets/panels/preview_panel.dart @@ -0,0 +1,454 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../state/deck_provider.dart'; +import '../../state/editor_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../utils/url_launcher_util.dart'; +import '../slides/slide_preview.dart'; + +/// Of het preview-paneel ingeklapt is (UI-voorkeur, app-breed). +final previewCollapsedProvider = StateProvider((_) => false); + +class PreviewPanel extends ConsumerStatefulWidget { + const PreviewPanel({super.key}); + + @override + ConsumerState createState() => _PreviewPanelState(); +} + +class _PreviewPanelState extends ConsumerState { + final TransformationController _transform = TransformationController(); + final FocusNode _focusNode = FocusNode(debugLabel: 'PreviewPanel'); + double _zoom = 1.0; + + static const double _minZoom = 1.0; + static const double _maxZoom = 4.0; + static const double _zoomStep = 0.5; + + @override + void dispose() { + _transform.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + /// Verplaats de slideselectie met de pijltjestoetsen (toegankelijkheid). + void _move(int delta) { + final deck = ref.read(deckProvider).deck; + if (deck == null) return; + final current = ref.read(editorProvider).selectedIndex; + final next = (current + delta).clamp(0, deck.slides.length - 1); + if (next != current) ref.read(editorProvider.notifier).select(next); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowLeft: + case LogicalKeyboardKey.arrowUp: + case LogicalKeyboardKey.pageUp: + _move(-1); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowRight: + case LogicalKeyboardKey.arrowDown: + case LogicalKeyboardKey.pageDown: + _move(1); + return KeyEventResult.handled; + default: + return KeyEventResult.ignored; + } + } + + void _zoomIn() { + final next = (_zoom + _zoomStep).clamp(_minZoom, _maxZoom); + _applyZoom(next); + } + + void _zoomOut() { + final next = (_zoom - _zoomStep).clamp(_minZoom, _maxZoom); + _applyZoom(next); + } + + void _resetZoom() { + _applyZoom(_minZoom); + } + + void _applyZoom(double zoom) { + setState(() => _zoom = zoom); + _transform.value = Matrix4.identity()..scaleByDouble(zoom, zoom, 1, 1); + } + + @override + Widget build(BuildContext context) { + final deckState = ref.watch(deckProvider); + final deck = deckState.deck!; + final editor = ref.watch(editorProvider); + + final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1); + final slide = deck.slides[idx]; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _onKey, + child: GestureDetector( + onTap: _focusNode.requestFocus, + child: Container( + color: const Color(0xFFF1F5F9), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Header ────────────────────────────────────────────────────── + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Row( + children: [ + const Icon( + Icons.preview_outlined, + size: 16, + color: Color(0xFF64748B), + ), + const SizedBox(width: 6), + const Text( + 'Preview', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFF334155), + ), + ), + const Spacer(), + // ── Zoom controls ────────────────────────────────────── + Tooltip( + message: 'Uitzoomen', + child: IconButton( + icon: const Icon(Icons.remove, size: 16), + onPressed: _zoom > _minZoom ? _zoomOut : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: const Color(0xFF64748B), + ), + ), + GestureDetector( + onTap: _zoom != _minZoom ? _resetZoom : null, + child: Tooltip( + message: 'Zoom resetten', + child: Text( + '${(_zoom * 100).round()}%', + style: TextStyle( + fontSize: 11, + color: _zoom != _minZoom + ? AppTheme.accent + : const Color(0xFF94A3B8), + fontWeight: _zoom != _minZoom + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + Tooltip( + message: 'Inzoomen', + child: IconButton( + icon: const Icon(Icons.add, size: 16), + onPressed: _zoom < _maxZoom ? _zoomIn : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: const Color(0xFF64748B), + ), + ), + const SizedBox(width: 8), + Text( + '${idx + 1} / ${deck.slides.length}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(width: 4), + Tooltip( + message: 'Preview inklappen', + child: IconButton( + icon: const Icon(Icons.chevron_right, size: 18), + onPressed: () => + ref.read(previewCollapsedProvider.notifier).state = + true, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, + ), + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + const Divider(height: 1), + + // ── Slide canvas ───────────────────────────────────────────────── + Expanded( + child: ClipRect( + child: InteractiveViewer( + transformationController: _transform, + minScale: _minZoom, + maxScale: _maxZoom, + constrained: true, + onInteractionUpdate: (details) { + final scale = _transform.value.getMaxScaleOnAxis(); + if ((scale - _zoom).abs() > 0.01) { + setState(() => _zoom = scale.clamp(_minZoom, _maxZoom)); + } + }, + child: Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: SlidePreviewWidget( + slide: slide, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + onLinkTap: openExternalUrl, + slideNumber: idx + 1, + slideCount: deck.slides.length, + tlp: deck.tlp, + // In de editor mag audio/video bediend worden, maar + // niet vanzelf starten (anders dreunt het door op + // elke slide-wissel). + enableMedia: true, + autoplayMedia: false, + ), + ), + ), + ), + ), + ), + ), + + // ── Navigation footer ──────────────────────────────────────────── + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: idx > 0 + ? () => ref + .read(editorProvider.notifier) + .select(idx - 1) + : null, + icon: const Icon(Icons.chevron_left), + iconSize: 20, + tooltip: 'Vorige slide', + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + slide.type.label, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF64748B), + ), + ), + ), + IconButton( + onPressed: idx < deck.slides.length - 1 + ? () => ref + .read(editorProvider.notifier) + .select(idx + 1) + : null, + icon: const Icon(Icons.chevron_right), + iconSize: 20, + tooltip: 'Volgende slide', + ), + ], + ), + ), + + // ── Theme chip ─────────────────────────────────────────────────── + Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + children: [ + const Icon( + Icons.palette_outlined, + size: 12, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 4), + Text( + 'Thema: ${deck.theme}', + style: const TextStyle( + fontSize: 10, + color: Color(0xFF94A3B8), + ), + ), + if (deck.paginate) ...[ + const SizedBox(width: 10), + const Icon(Icons.tag, size: 12, color: Color(0xFF94A3B8)), + const SizedBox(width: 2), + const Text( + 'paginering aan', + style: TextStyle( + fontSize: 10, + color: Color(0xFF94A3B8), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Full-deck preview overlay ───────────────────────────────────────────────── + +class FullDeckPreview extends StatelessWidget { + final Deck deck; + final ThemeProfile themeProfile; + + const FullDeckPreview({ + super.key, + required this.deck, + required this.themeProfile, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1E2028), + appBar: AppBar( + title: Text('${deck.title} — volledig deck'), + backgroundColor: AppTheme.navy, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ), + body: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 40), + itemCount: deck.slides.length, + itemBuilder: (_, i) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Slide ${i + 1}', + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 11, + ), + ), + const SizedBox(height: 4), + Container( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black38, + blurRadius: 12, + offset: Offset(0, 2), + ), + ], + ), + child: SlidePreviewWidget( + slide: deck.slides[i], + projectPath: deck.projectPath, + themeProfile: themeProfile, + onLinkTap: openExternalUrl, + slideNumber: i + 1, + slideCount: deck.slides.length, + tlp: deck.tlp, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +// ── Ingeklapt preview-paneel ────────────────────────────────────────────────── + +/// Smal verticaal balkje dat het ingeklapte preview-paneel vervangt; klikken +/// klapt het weer uit. +class CollapsedPreviewBar extends ConsumerWidget { + const CollapsedPreviewBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + width: 34, + color: Colors.white, + child: Column( + children: [ + const SizedBox(height: 6), + Tooltip( + message: 'Preview uitklappen', + child: IconButton( + icon: const Icon(Icons.chevron_left, size: 18), + onPressed: () => + ref.read(previewCollapsedProvider.notifier).state = false, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + color: const Color(0xFF64748B), + ), + ), + const SizedBox(height: 8), + // Verticaal label "PREVIEW". + RotatedBox( + quarterTurns: 1, + child: Text( + 'PREVIEW', + style: TextStyle( + fontSize: 10, + letterSpacing: 1.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF94A3B8), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart new file mode 100644 index 0000000..81016c9 --- /dev/null +++ b/lib/widgets/panels/slide_list_panel.dart @@ -0,0 +1,871 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/deck.dart'; +import '../../models/slide.dart'; +import '../../state/deck_provider.dart'; +import '../../state/editor_provider.dart'; +import '../../state/settings_provider.dart'; +import '../../services/image_service.dart'; +import '../../services/slide_rasterizer.dart'; +import '../../state/slide_clipboard_provider.dart'; +import '../../theme/app_theme.dart'; +import '../dialogs/add_slide_dialog.dart'; +import '../dialogs/import_slides_dialog.dart'; +import '../dialogs/slide_finder_dialog.dart'; +import '../slides/slide_thumbnail.dart'; + +class SlideListPanel extends ConsumerStatefulWidget { + const SlideListPanel({super.key}); + + @override + ConsumerState createState() => _SlideListPanelState(); +} + +class _SlideListPanelState extends ConsumerState { + String _query = ''; + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + final _focusNode = FocusNode(debugLabel: 'SlideListPanel'); + final Map _slideKeys = {}; + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + /// Lower-cased, concatenated text of a slide for searching. Kept broad on + /// purpose: everything you typed into the slide should make it findable. + String _slideText(Slide slide) { + return [ + slide.title, + slide.subtitle, + ...slide.bullets, + ...slide.bullets2, + slide.quote, + slide.quoteAuthor, + slide.customMarkdown, + slide.imageCaption, + slide.imageCaption2, + slide.notes, + slide.imagePath, + slide.imagePath2, + slide.videoPath, + slide.audioPath, + slide.type.label, + ].join(' ').toLowerCase(); + } + + /// Multi-word AND match: every term must appear somewhere in the slide. + bool _matches(Slide slide, String query) { + final text = _slideText(slide); + return query + .split(RegExp(r'\s+')) + .where((t) => t.isNotEmpty) + .every(text.contains); + } + + bool get _textInputHasFocus { + final context = FocusManager.instance.primaryFocus?.context; + return context?.widget is EditableText; + } + + GlobalKey _keyForSlide(Slide slide) { + return _slideKeys.putIfAbsent( + slide.id, + () => GlobalKey(debugLabel: 'slide-${slide.id}'), + ); + } + + void _pruneSlideKeys(Deck deck) { + final ids = deck.slides.map((slide) => slide.id).toSet(); + _slideKeys.removeWhere((id, _) => !ids.contains(id)); + } + + void _scrollSlideToTop(int index) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final deck = ref.read(deckProvider).deck; + if (deck == null || + index < 0 || + index >= deck.slides.length || + !_scrollController.hasClients) { + return; + } + + final keyContext = _slideKeys[deck.slides[index].id]?.currentContext; + final target = keyContext?.findRenderObject(); + if (target == null) return; + + final viewport = RenderAbstractViewport.maybeOf(target); + if (viewport == null) return; + + final offset = viewport + .getOffsetToReveal(target, 0) + .offset + .clamp(0.0, _scrollController.position.maxScrollExtent); + _scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + ); + }); + } + + void _selectSlide(int index) { + final deck = ref.read(deckProvider).deck; + if (deck == null || deck.slides.isEmpty) return; + final clamped = index.clamp(0, deck.slides.length - 1); + ref.read(editorProvider.notifier).select(clamped); + _focusNode.requestFocus(); + _scrollSlideToTop(clamped); + } + + void _moveSelection(int delta) { + final deck = ref.read(deckProvider).deck; + if (deck == null || deck.slides.isEmpty) return; + final current = ref.read(editorProvider).selectedIndex; + _selectSlide((current + delta).clamp(0, deck.slides.length - 1)); + } + + /// Klik met modifier: Shift = bereik, Ctrl/Cmd = toevoegen/verwijderen, + /// anders enkelvoudige selectie. + void _onSlideTap(int index) { + final keys = HardwareKeyboard.instance; + final editorN = ref.read(editorProvider.notifier); + if (keys.isShiftPressed) { + editorN.selectRange(index); + _focusNode.requestFocus(); + _scrollSlideToTop(index); + } else if (keys.isControlPressed || keys.isMetaPressed) { + editorN.toggleSelect(index); + _focusNode.requestFocus(); + _scrollSlideToTop(index); + } else { + _selectSlide(index); + } + } + + /// Render de hele slide naar een afbeelding en kopieer 'm naar het klembord, + /// zodat je 'm elders kunt plakken. + Future _copySlideAsImage(Slide slide) async { + final deck = ref.read(deckProvider).deck; + if (deck == null) return; + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar( + content: Text('Slide renderen…'), + duration: Duration(milliseconds: 700), + ), + ); + Uint8List? bytes; + try { + final images = await SlideRasterizer.rasterize( + context: context, + slides: [slide], + themeProfile: deck.themeProfile, + projectPath: deck.projectPath, + tlp: deck.tlp, + ); + if (images.isNotEmpty) bytes = images.first; + } catch (_) {} + if (!mounted) return; + final ok = + bytes != null && + await ImageService().copyImageBytesToClipboard(bytes); + if (!mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text( + ok ? 'Slide gekopieerd naar klembord.' : 'Kopiëren mislukt.', + ), + ), + ); + } + + /// Verwijder alle geselecteerde slides (bulk). Houdt minstens één over. + void _deleteSelection() { + final deck = ref.read(deckProvider).deck; + if (deck == null) return; + final selection = ref.read(editorProvider).selection; + final remaining = deck.slides.length - selection.length; + if (remaining < 1) return; + ref.read(deckProvider.notifier).removeSlides(selection); + final target = selection + .reduce((a, b) => a < b ? a : b) + .clamp(0, remaining - 1); + ref.read(editorProvider.notifier).select(target); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent || _textInputHasFocus) { + return KeyEventResult.ignored; + } + switch (event.logicalKey) { + case LogicalKeyboardKey.arrowUp: + case LogicalKeyboardKey.arrowLeft: + _moveSelection(-1); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowDown: + case LogicalKeyboardKey.arrowRight: + _moveSelection(1); + return KeyEventResult.handled; + case LogicalKeyboardKey.pageUp: + _moveSelection(-5); + return KeyEventResult.handled; + case LogicalKeyboardKey.pageDown: + _moveSelection(5); + return KeyEventResult.handled; + case LogicalKeyboardKey.home: + _selectSlide(0); + return KeyEventResult.handled; + case LogicalKeyboardKey.end: + final deck = ref.read(deckProvider).deck; + if (deck != null) _selectSlide(deck.slides.length - 1); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyA + when HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed: + final deck = ref.read(deckProvider).deck; + if (deck != null) { + ref.read(editorProvider.notifier).selectAll(deck.slides.length); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.delete: + case LogicalKeyboardKey.backspace: + _deleteSelection(); + return KeyEventResult.handled; + default: + return KeyEventResult.ignored; + } + } + + Future _findSlides( + BuildContext context, + WidgetRef ref, + DeckState deckState, + ) async { + final settings = ref.read(settingsProvider); + final deck = deckState.deck; + final initialDir = deck?.projectPath ?? settings.homeDirectory; + + await SlideFinderDialog.show( + context, + fileService: ref.read(fileServiceProvider), + initialDirectory: initialDir, + excludePath: deckState.filePath, + onAdd: (slide) { + final at = ref.read(deckProvider.notifier).insertSlides([slide]); + if (at >= 0) ref.read(editorProvider.notifier).select(at); + }, + ); + } + + Future _importSlides( + BuildContext context, + WidgetRef ref, + DeckState deckState, + ) async { + final settings = ref.read(settingsProvider); + final deck = deckState.deck; + final initialDir = deck?.projectPath ?? settings.homeDirectory; + + final slides = await ImportSlidesDialog.show( + context, + fileService: ref.read(fileServiceProvider), + initialDirectory: initialDir, + excludePath: deckState.filePath, + ); + if (slides == null || slides.isEmpty) return; + + final notifier = ref.read(deckProvider.notifier); + final editorNotifier = ref.read(editorProvider.notifier); + final at = ref.read(editorProvider).selectedIndex; + final firstIndex = notifier.insertSlides(slides, afterIndex: at); + if (firstIndex >= 0) editorNotifier.select(firstIndex); + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + slides.length == 1 + ? '1 slide geïmporteerd.' + : '${slides.length} slides geïmporteerd.', + ), + ), + ); + } + + Widget _buildSearchField() { + return SizedBox( + height: 30, + child: TextField( + controller: _searchController, + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: Colors.white, fontSize: 12), + decoration: InputDecoration( + isDense: true, + hintText: 'Zoek in slides…', + hintStyle: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), + prefixIcon: const Icon( + Icons.search, + size: 15, + color: Color(0xFF6B7280), + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, + ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + padding: EdgeInsets.zero, + iconSize: 14, + splashRadius: 14, + icon: const Icon(Icons.clear, color: Color(0xFF6B7280)), + onPressed: () => setState(() { + _searchController.clear(); + _query = ''; + }), + ), + filled: true, + fillColor: const Color(0xFF1B1E25), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFF3A3F4B)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: Color(0xFF3A3F4B)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: const BorderSide(color: AppTheme.accent), + ), + ), + ), + ); + } + + Widget _buildFilteredList( + Deck deck, + String query, + EditorState editor, + DeckNotifier notifier, + EditorNotifier editorNotifier, + ) { + final matches = [ + for (var i = 0; i < deck.slides.length; i++) + if (_matches(deck.slides[i], query)) i, + ]; + + if (matches.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_off_outlined, + size: 32, + color: Color(0xFF4A4F5B), + ), + const SizedBox(height: 10), + Text( + 'Geen slides met "$query"', + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ), + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: matches.length, + itemBuilder: (_, i) { + final index = matches[i]; + final slide = deck.slides[index]; + return SlideThumbnail( + key: _keyForSlide(slide), + slide: slide, + index: index, + isSelected: editor.selection.contains(index), + isPrimary: editor.selectedIndex == index, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + slideCount: deck.slides.length, + tlp: deck.tlp, + onTap: () => _onSlideTap(index), + onToggleSkip: () => notifier.toggleSkip(index), + onCopyImage: () => _copySlideAsImage(slide), + onDuplicate: () { + notifier.duplicateSlide(index); + editorNotifier.select(index + 1); + }, + onDelete: () { + if (deck.slides.length <= 1) return; + notifier.removeSlide(index); + editorNotifier.clampIndex(deck.slides.length - 2); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final deckState = ref.watch(deckProvider); + final deck = deckState.deck!; + _pruneSlideKeys(deck); + final editor = ref.watch(editorProvider); + final notifier = ref.read(deckProvider.notifier); + final editorNotifier = ref.read(editorProvider.notifier); + + final clipboard = ref.watch(slideClipboardProvider); + + final query = _query.trim().toLowerCase(); + final searching = query.isNotEmpty; + final matchCount = searching + ? deck.slides.where((s) => _matches(s, query)).length + : deck.slides.length; + final skippedCount = deck.slides.where((s) => s.skipped).length; + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _focusNode.requestFocus, + child: Container( + color: AppTheme.panelBg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Header ────────────────────────────────────────────────────── + Container( + color: const Color(0xFF252830), + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Text( + 'SLIDES', + style: TextStyle( + color: Color(0xFF94A3B8), + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + const Spacer(), + Text( + searching + ? '$matchCount / ${deck.slides.length}' + : '${deck.slides.length}', + style: const TextStyle( + color: Color(0xFF64748B), + fontSize: 10, + ), + ), + ], + ), + const SizedBox(height: 6), + _buildSearchField(), + // "Overslaan"-balk: alleen zichtbaar als er slides overgeslagen + // worden. Eén klik zet alle markeringen weer uit. + if (skippedCount > 0) ...[ + const SizedBox(height: 6), + _SkipBanner( + count: skippedCount, + onClearAll: notifier.clearAllSkips, + ), + ], + // Bulk-actiebalk bij een meervoudige selectie. + if (editor.hasMultiSelection) ...[ + const SizedBox(height: 6), + _BulkActionBar( + count: editor.selection.length, + onDelete: _deleteSelection, + onSkip: () => notifier.setSkippedForSlides( + editor.selection, + true, + ), + onShow: () => notifier.setSkippedForSlides( + editor.selection, + false, + ), + onDeselect: () => + editorNotifier.select(editor.selectedIndex), + ), + ], + ], + ), + ), + + // ── Slide list ─────────────────────────────────────────────────── + Expanded( + child: searching + ? _buildFilteredList( + deck, + query, + editor, + notifier, + editorNotifier, + ) + : ReorderableListView.builder( + scrollController: _scrollController, + padding: const EdgeInsets.symmetric(vertical: 4), + buildDefaultDragHandles: false, + itemCount: deck.slides.length, + onReorderItem: (old, nw) { + notifier.reorderSlides(old, nw); + // Adjust selection when active slide moved + final selIdx = editor.selectedIndex; + int newSel = selIdx; + if (old == selIdx) { + newSel = nw; + } else if (old < selIdx && nw >= selIdx) { + newSel = selIdx - 1; + } else if (old > selIdx && nw <= selIdx) { + newSel = selIdx + 1; + } + editorNotifier.select( + newSel.clamp(0, deck.slides.length - 1), + ); + }, + proxyDecorator: (child, index, animation) => + Material(color: Colors.transparent, child: child), + itemBuilder: (_, i) { + final slide = deck.slides[i]; + return SlideThumbnail( + key: _keyForSlide(slide), + slide: slide, + index: i, + isSelected: editor.selection.contains(i), + isPrimary: editor.selectedIndex == i, + projectPath: deck.projectPath, + themeProfile: deck.themeProfile, + slideCount: deck.slides.length, + tlp: deck.tlp, + onTap: () => _onSlideTap(i), + onToggleSkip: () => notifier.toggleSkip(i), + onCopyImage: () => _copySlideAsImage(slide), + onDuplicate: () { + notifier.duplicateSlide(i); + editorNotifier.select(i + 1); + }, + onDelete: () { + if (deck.slides.length <= 1) return; + notifier.removeSlide(i); + editorNotifier.clampIndex(deck.slides.length - 2); + }, + ); + }, + ), + ), + + // ── Add / Paste slide buttons ───────────────────────────────── + Container( + color: const Color(0xFF252830), + padding: const EdgeInsets.all(8), + child: Column( + children: [ + SizedBox( + height: 32, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + final path = await ref + .read(imageServiceProvider) + .pasteImage(); + if (path == null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Geen afbeelding op het klembord gevonden.', + ), + ), + ); + return; + } + final idx = editor.selectedIndex; + notifier.addSlide(SlideType.image, afterIndex: idx); + final newIdx = idx + 1; + notifier.updateSlide( + newIdx, + Slide.create( + SlideType.image, + ).copyWith(imagePath: path), + ); + editorNotifier.select(newIdx); + }, + icon: const Icon(Icons.image_outlined, size: 14), + label: const Text('Afbeelding plakken'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Color(0xFF4A4F5B)), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ), + ), + const SizedBox(height: 6), + SizedBox( + height: 36, + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () async { + final type = await AddSlideDialog.show(context); + if (type != null) { + final idx = editor.selectedIndex; + notifier.addSlide(type, afterIndex: idx); + editorNotifier.select(idx + 1); + } + }, + icon: const Icon(Icons.add, size: 16), + label: const Text('Slide toevoegen'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.accent, + padding: const EdgeInsets.symmetric(horizontal: 12), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(height: 6), + SizedBox( + height: 32, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _findSlides(context, ref, deckState), + icon: const Icon( + Icons.travel_explore_outlined, + size: 14, + ), + label: const Text('Slide zoeken'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Color(0xFF4A4F5B)), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ), + ), + const SizedBox(height: 6), + SizedBox( + height: 32, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _importSlides(context, ref, deckState), + icon: const Icon(Icons.library_add_outlined, size: 14), + label: const Text('Slides importeren'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Color(0xFF4A4F5B)), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ), + ), + if (clipboard != null) ...[ + const SizedBox(height: 6), + SizedBox( + height: 32, + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + final idx = editor.selectedIndex; + notifier.addSlide(clipboard.type, afterIndex: idx); + // Replace the newly created blank slide with the copied one + final newIdx = idx + 1; + notifier.updateSlide( + newIdx, + Slide.duplicate(clipboard), + ); + editorNotifier.select(newIdx); + }, + icon: const Icon(Icons.content_paste, size: 14), + label: const Text('Slide plakken'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white70, + side: const BorderSide(color: Color(0xFF4A4F5B)), + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 11), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Smalle balk bovenin de slidelijst die toont hoeveel slides overgeslagen +/// worden, met één knop om alle markeringen ineens te wissen. +class _SkipBanner extends StatelessWidget { + final int count; + final VoidCallback onClearAll; + + const _SkipBanner({required this.count, required this.onClearAll}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(8, 5, 4, 5), + decoration: BoxDecoration( + color: const Color(0x33B8860B), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFF8A6D3B)), + ), + child: Row( + children: [ + const Icon( + Icons.visibility_off_outlined, + size: 13, + color: Color(0xFFD4A24E), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + count == 1 + ? '1 slide overgeslagen' + : '$count slides overgeslagen', + style: const TextStyle( + color: Color(0xFFE3C281), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + TextButton( + onPressed: onClearAll, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFD4A24E), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + minimumSize: const Size(0, 26), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Alles tonen'), + ), + ], + ), + ); + } +} + +// ── Bulk-actiebalk (meervoudige selectie) ───────────────────────────────────── + +class _BulkActionBar extends StatelessWidget { + final int count; + final VoidCallback onDelete; + final VoidCallback onSkip; + final VoidCallback onShow; + final VoidCallback onDeselect; + + const _BulkActionBar({ + required this.count, + required this.onDelete, + required this.onSkip, + required this.onShow, + required this.onDeselect, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(8, 4, 4, 4), + decoration: BoxDecoration( + color: const Color(0x332E7D64), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppTheme.accent.withValues(alpha: 0.6)), + ), + child: Row( + children: [ + Expanded( + child: Text( + '$count geselecteerd', + style: const TextStyle( + color: Color(0xFFE2E8F0), + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + _BulkIcon( + icon: Icons.visibility_off_outlined, + tooltip: 'Overslaan bij presenteren/exporteren', + onTap: onSkip, + ), + _BulkIcon( + icon: Icons.visibility_outlined, + tooltip: 'Weer tonen', + onTap: onShow, + ), + _BulkIcon( + icon: Icons.delete_outline, + tooltip: 'Verwijderen', + color: const Color(0xFFE5746E), + onTap: onDelete, + ), + _BulkIcon( + icon: Icons.close, + tooltip: 'Selectie opheffen', + onTap: onDeselect, + ), + ], + ), + ); + } +} + +class _BulkIcon extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + final Color? color; + + const _BulkIcon({ + required this.icon, + required this.tooltip, + required this.onTap, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: IconButton( + icon: Icon(icon, size: 16), + onPressed: onTap, + color: color ?? const Color(0xFFCBD5E1), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + visualDensity: VisualDensity.compact, + ), + ); + } +} diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart new file mode 100644 index 0000000..7349fc1 --- /dev/null +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -0,0 +1,1385 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../utils/url_launcher_util.dart'; +import '../slides/slide_preview.dart'; + +/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). +enum _Blank { none, black, white } + +class FullscreenPresenter extends StatefulWidget { + final List slides; + final String? projectPath; + final ThemeProfile themeProfile; + final int initialIndex; + final TlpLevel tlp; + + const FullscreenPresenter({ + super.key, + required this.slides, + required this.projectPath, + required this.themeProfile, + required this.initialIndex, + this.tlp = TlpLevel.none, + }); + + static Future show( + BuildContext context, { + required List slides, + required String? projectPath, + required ThemeProfile themeProfile, + required int initialIndex, + TlpLevel tlp = TlpLevel.none, + }) async { + await windowManager.setFullScreen(true); + if (context.mounted) { + await Navigator.push( + context, + PageRouteBuilder( + opaque: true, + pageBuilder: (context, anim, anim2) => FullscreenPresenter( + slides: slides, + projectPath: projectPath, + themeProfile: themeProfile, + initialIndex: initialIndex, + tlp: tlp, + ), + transitionsBuilder: (context, animation, secondary, child) => + FadeTransition(opacity: animation, child: child), + transitionDuration: const Duration(milliseconds: 200), + ), + ); + } + } + + @override + State createState() => _FullscreenPresenterState(); +} + +class _FullscreenPresenterState extends State { + late int _index; + late FocusNode _focusNode; + Timer? _advanceTimer; + Timer? _clockTimer; + double _progress = 0; // 0..1 voor de voortgangsbalk + + /// Presenter view (notities, klok, volgende slide) vs. publieksweergave. + bool _presenterView = false; + + /// Blanco scherm (zwart/wit) tijdens het presenteren. + _Blank _blank = _Blank.none; + + /// Rasteroverzicht van alle slides om snel te springen. + bool _gridOpen = false; + + /// Gemarkeerde positie in het raster (los van de getoonde slide) plus de + /// huidige kolom-/rijmaat, nodig om met de pijltjes te navigeren en mee te + /// scrollen. + int _gridCursor = 0; + int _gridCols = 3; + double _gridRowExtent = 220; + final ScrollController _gridScroll = ScrollController(); + + /// Starttijd voor de verstreken-tijd-teller (resetbaar met R). + late DateTime _startTime; + + /// Getypte cijfers om naar een slidenummer te springen (leeg = niet actief). + String _typed = ''; + Timer? _typedTimer; + + /// Sneltoets-overzicht (cheatsheet) zichtbaar. + bool _helpOpen = false; + + /// Automatische modus: slides wisselen vanzelf (op tijd of na audio). Staat + /// standaard aan zodat ingestelde tijdwissels meteen werken; met A te pauzeren. + bool _autoPlay = true; + + /// Herhaling: na de laatste slide terug naar de eerste (anders blijft de + /// laatste slide staan). Met L te wisselen. + bool _loop = false; + + /// Wissel ná het afspelen van de audio op de slide i.p.v. op de tijdwissel. + /// Met M te wisselen. + bool _advanceOnAudioEnd = true; + + @override + void initState() { + super.initState(); + _index = widget.initialIndex; + _startTime = DateTime.now(); + _focusNode = FocusNode(); + // Tik elke seconde, maar herbouw alleen in presenter view (klok/teller). + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted && _presenterView) setState(() {}); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + _scheduleAdvance(); + }); + } + + @override + void dispose() { + _advanceTimer?.cancel(); + _clockTimer?.cancel(); + _typedTimer?.cancel(); + _gridScroll.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _scheduleAdvance() { + _advanceTimer?.cancel(); + _advanceTimer = null; + setState(() => _progress = 0); + + // Auto-modus uit: nooit vanzelf wisselen. + if (!_autoPlay) return; + + final slide = widget.slides[_index.clamp(0, widget.slides.length - 1)]; + + // Audio-gestuurd: heeft deze slide audio die vanzelf speelt én is de keuze + // 'na audio doorgaan' actief? Dan wachten we op het audio-einde (de + // _AudioPlayback meldt zich via onAudioComplete) en zetten we geen timer. + final audioDriven = + _advanceOnAudioEnd && slide.audioPath.isNotEmpty && slide.audioAutoplay; + if (audioDriven) return; + + final dur = slide.advanceDuration; + if (dur <= 0) return; + // Op de laatste slide alleen doortikken als we herhalen. + if (_index >= widget.slides.length - 1 && !_loop) return; + + final totalMs = (dur * 1000).round(); + final startTime = DateTime.now(); + + // Tick elke 50ms voor een vloeiende voortgangsbalk + _advanceTimer = Timer.periodic(const Duration(milliseconds: 50), (t) { + if (!mounted) { + t.cancel(); + return; + } + final elapsed = DateTime.now().difference(startTime).inMilliseconds; + final p = (elapsed / totalMs).clamp(0.0, 1.0); + setState(() => _progress = p); + if (elapsed >= totalMs) { + t.cancel(); + _autoAdvance(); + } + }); + } + + /// Automatisch doorschakelen (tijd of audio-einde): naar de volgende slide, + /// of bij herhaling vanaf de laatste terug naar de eerste. Zonder herhaling + /// blijft de laatste slide gewoon staan. + void _autoAdvance() { + if (_blank != _Blank.none) return; + if (_index < widget.slides.length - 1) { + setState(() => _index++); + _scheduleAdvance(); + } else if (_loop) { + setState(() => _index = 0); + _scheduleAdvance(); + } + } + + /// Aangeroepen door de audiospeler zodra de audio op de huidige slide klaar + /// is. In automatische modus met 'na audio doorgaan' schakelen we dan door. + void _onAudioCompleted() { + if (_autoPlay && _advanceOnAudioEnd) _autoAdvance(); + } + + void _toggleAutoPlay() { + setState(() => _autoPlay = !_autoPlay); + _scheduleAdvance(); + } + + void _toggleLoop() { + setState(() => _loop = !_loop); + _scheduleAdvance(); + } + + void _toggleAudioAdvance() { + setState(() => _advanceOnAudioEnd = !_advanceOnAudioEnd); + _scheduleAdvance(); + } + + Future _exit() async { + _advanceTimer?.cancel(); + await windowManager.setFullScreen(false); + if (mounted) Navigator.pop(context); + } + + void _next() { + // Eerste toets/klik op een blanco scherm haalt het scherm terug. + if (_blank != _Blank.none) { + setState(() => _blank = _Blank.none); + return; + } + if (_index < widget.slides.length - 1) { + setState(() => _index++); + _scheduleAdvance(); + } + } + + void _prev() { + if (_blank != _Blank.none) { + setState(() => _blank = _Blank.none); + return; + } + if (_index > 0) { + setState(() => _index--); + _scheduleAdvance(); + } + } + + void _togglePresenterView() { + setState(() => _presenterView = !_presenterView); + } + + void _resetTimer() { + setState(() => _startTime = DateTime.now()); + } + + void _toggleHelp() { + setState(() => _helpOpen = !_helpOpen); + } + + /// Cijfer (gewoon of numpad) → karakter, of null bij andere toetsen. + static final Map _digits = { + LogicalKeyboardKey.digit0: '0', + LogicalKeyboardKey.digit1: '1', + LogicalKeyboardKey.digit2: '2', + LogicalKeyboardKey.digit3: '3', + LogicalKeyboardKey.digit4: '4', + LogicalKeyboardKey.digit5: '5', + LogicalKeyboardKey.digit6: '6', + LogicalKeyboardKey.digit7: '7', + LogicalKeyboardKey.digit8: '8', + LogicalKeyboardKey.digit9: '9', + LogicalKeyboardKey.numpad0: '0', + LogicalKeyboardKey.numpad1: '1', + LogicalKeyboardKey.numpad2: '2', + LogicalKeyboardKey.numpad3: '3', + LogicalKeyboardKey.numpad4: '4', + LogicalKeyboardKey.numpad5: '5', + LogicalKeyboardKey.numpad6: '6', + LogicalKeyboardKey.numpad7: '7', + LogicalKeyboardKey.numpad8: '8', + LogicalKeyboardKey.numpad9: '9', + }; + + void _appendDigit(String d) { + setState(() { + _typed += d; + if (_typed.length > 4) _typed = _typed.substring(_typed.length - 4); + }); + _typedTimer?.cancel(); + _typedTimer = Timer(const Duration(milliseconds: 2500), _clearTyped); + } + + void _clearTyped() { + _typedTimer?.cancel(); + _typedTimer = null; + if (_typed.isNotEmpty) setState(() => _typed = ''); + } + + /// Spring naar het getypte slidenummer (1-gebaseerd) en wis de invoer. + void _commitTyped() { + final n = int.tryParse(_typed); + _clearTyped(); + if (n != null) _goTo(n - 1); + } + + /// Zet het scherm op zwart/wit, of terug naar de slide bij dezelfde toets. + void _toggleBlank(_Blank target) { + setState(() { + _blank = _blank == target ? _Blank.none : target; + if (_blank != _Blank.none) _gridOpen = false; + }); + } + + void _toggleGrid() { + setState(() { + _gridOpen = !_gridOpen; + if (_gridOpen) { + _blank = _Blank.none; + _gridCursor = _index; + } + }); + if (_gridOpen) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollGridToCursor(), + ); + } + } + + /// Spring direct naar een slide (vanuit het rasteroverzicht). + void _jumpTo(int index) { + setState(() { + _index = index.clamp(0, widget.slides.length - 1); + _blank = _Blank.none; + _gridOpen = false; + }); + _scheduleAdvance(); + } + + /// Ga rechtstreeks naar slide [index] zonder het raster te openen (Home/End). + void _goTo(int index) { + if (_blank != _Blank.none) { + setState(() => _blank = _Blank.none); + return; + } + final target = index.clamp(0, widget.slides.length - 1); + if (target == _index) return; + setState(() => _index = target); + _scheduleAdvance(); + } + + /// Verplaats de rastercursor en houd 'm in beeld. + void _moveGridCursor(int delta) { + setState(() { + _gridCursor = (_gridCursor + delta).clamp(0, widget.slides.length - 1); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGridToCursor()); + } + + void _setGridCursor(int index) { + setState(() { + _gridCursor = index.clamp(0, widget.slides.length - 1); + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGridToCursor()); + } + + /// Scroll het raster zo dat de cursorrij zichtbaar is (met wat context). + void _scrollGridToCursor() { + if (!_gridScroll.hasClients) return; + final row = _gridCols == 0 ? 0 : _gridCursor ~/ _gridCols; + final target = (row - 1) * _gridRowExtent; // één rij context erboven + final max = _gridScroll.position.maxScrollExtent; + _gridScroll.animateTo( + target.clamp(0.0, max), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + KeyEventResult _handleKey(FocusNode _, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final key = event.logicalKey; + + // Sneltoets-overzicht vangt alles: sluiten met ? / H / Esc. + if (_helpOpen) { + if (key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.keyH || + key == LogicalKeyboardKey.question) { + setState(() => _helpOpen = false); + } + return KeyEventResult.handled; + } + + // Terwijl het raster open is, sturen de pijltjes een aparte cursor aan. + if (_gridOpen) return _handleGridKey(key); + + // Cijfers verzamelen om naar een slidenummer te springen. + final digit = _digits[key]; + if (digit != null) { + _appendDigit(digit); + return KeyEventResult.handled; + } + + final last = widget.slides.length - 1; + switch (key) { + case LogicalKeyboardKey.enter: + case LogicalKeyboardKey.numpadEnter: + // Met een getypt nummer: springen; anders gewoon door. + if (_typed.isNotEmpty) { + _commitTyped(); + } else { + _next(); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.backspace: + if (_typed.isNotEmpty) { + setState(() => _typed = _typed.substring(0, _typed.length - 1)); + } + return KeyEventResult.handled; + case LogicalKeyboardKey.keyH: + case LogicalKeyboardKey.question: + _toggleHelp(); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowRight: + case LogicalKeyboardKey.space: + case LogicalKeyboardKey.pageDown: + _next(); + return KeyEventResult.handled; + case LogicalKeyboardKey.arrowLeft: + case LogicalKeyboardKey.pageUp: + _prev(); + return KeyEventResult.handled; + case LogicalKeyboardKey.home: + _goTo(0); + return KeyEventResult.handled; + case LogicalKeyboardKey.end: + _goTo(last); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyP: + _togglePresenterView(); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyR: + _resetTimer(); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyB: + _toggleBlank(_Blank.black); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyW: + _toggleBlank(_Blank.white); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyG: + _toggleGrid(); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyA: + _toggleAutoPlay(); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyL: + _toggleLoop(); + return KeyEventResult.handled; + case LogicalKeyboardKey.keyM: + _toggleAudioAdvance(); + return KeyEventResult.handled; + case LogicalKeyboardKey.escape: + // Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten. + if (_typed.isNotEmpty) { + _clearTyped(); + } else if (_blank != _Blank.none) { + setState(() => _blank = _Blank.none); + } else { + _exit(); + } + return KeyEventResult.handled; + default: + return KeyEventResult.ignored; + } + } + + /// Toetsen terwijl het rasteroverzicht open is. + KeyEventResult _handleGridKey(LogicalKeyboardKey key) { + final last = widget.slides.length - 1; + switch (key) { + case LogicalKeyboardKey.arrowRight: + _moveGridCursor(1); + case LogicalKeyboardKey.arrowLeft: + _moveGridCursor(-1); + case LogicalKeyboardKey.arrowDown: + _moveGridCursor(_gridCols); + case LogicalKeyboardKey.arrowUp: + _moveGridCursor(-_gridCols); + case LogicalKeyboardKey.home: + _setGridCursor(0); + case LogicalKeyboardKey.end: + _setGridCursor(last); + case LogicalKeyboardKey.enter: + case LogicalKeyboardKey.numpadEnter: + case LogicalKeyboardKey.space: + _jumpTo(_gridCursor); + case LogicalKeyboardKey.keyG: + case LogicalKeyboardKey.escape: + setState(() => _gridOpen = false); + default: + return KeyEventResult.ignored; + } + return KeyEventResult.handled; + } + + // ── Formatters ───────────────────────────────────────────────────────────── + + String _fmtClock(DateTime t) { + final h = t.hour.toString().padLeft(2, '0'); + final m = t.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + String _fmtElapsed(Duration d) { + final mm = (d.inMinutes % 60).toString().padLeft(2, '0'); + final ss = (d.inSeconds % 60).toString().padLeft(2, '0'); + return d.inHours > 0 ? '${d.inHours}:$mm:$ss' : '$mm:$ss'; + } + + @override + Widget build(BuildContext context) { + final total = widget.slides.length; + if (total == 0) { + _exit(); + return const SizedBox.shrink(); + } + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _handleKey, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + _presenterView + ? _buildPresenterView(context) + : _buildAudienceView(context), + if (_gridOpen) Positioned.fill(child: _buildGridOverlay()), + if (_typed.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 60, + child: Center(child: _buildTypedBadge(total)), + ), + if (_helpOpen) Positioned.fill(child: _buildHelpOverlay()), + ], + ), + ), + ); + } + + /// Badge met het getypte slidenummer ("→ 12 / 28 · Enter"). + Widget _buildTypedBadge(int total) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF60A5FA), width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.south_east, color: Color(0xFF60A5FA), size: 20), + const SizedBox(width: 10), + Text( + '$_typed / $total', + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w700, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + const SizedBox(width: 12), + const Text( + 'Enter', + style: TextStyle(color: Colors.white38, fontSize: 13), + ), + ], + ), + ); + } + + /// Sneltoets-overzicht (cheatsheet). + Widget _buildHelpOverlay() { + const rows = <(String, String)>[ + ('→ · spatie · klik', 'Volgende slide'), + ('←', 'Vorige slide'), + ('cijfers + Enter', 'Naar slidenummer'), + ('Home · End', 'Eerste · laatste slide'), + ('G', 'Slide-overzicht (pijltjes + Enter)'), + ('P', 'Presenter view (notities, klok)'), + ('B · W', 'Zwart · wit scherm'), + ('R', 'Verstreken tijd resetten'), + ('A', 'Automatische modus aan/uit'), + ('L', 'Herhalen (loop) aan/uit'), + ('M', 'Na audio automatisch doorgaan'), + ('? · H', 'Dit overzicht'), + ('Esc', 'Terug / afsluiten'), + ]; + return GestureDetector( + onTap: _toggleHelp, + child: Container( + color: Colors.black.withValues(alpha: 0.85), + alignment: Alignment.center, + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 460, + maxHeight: MediaQuery.of(context).size.height - 48, + ), + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: const Color(0xFF161616), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFF2A2A2A)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon( + Icons.keyboard_outlined, + color: Colors.white70, + size: 20, + ), + SizedBox(width: 10), + Text( + 'Sneltoetsen', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 18), + for (final (keys, desc) in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + SizedBox( + width: 150, + child: Text( + keys, + style: const TextStyle( + color: Color(0xFF60A5FA), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded( + child: Text( + desc, + style: const TextStyle( + color: Color(0xFFE5E5E5), + fontSize: 14, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Center( + child: Text( + 'Klik of druk op ? / H / Esc om te sluiten', + style: TextStyle(color: Colors.white30, fontSize: 12), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Subtiele statusindicator (linksonder) voor de automatische modus. Toont + /// of auto-play, herhalen en 'na audio doorgaan' actief zijn. + Widget _autoPlayStatus() { + final items = <(IconData, String, bool)>[ + ( + _autoPlay ? Icons.play_circle_outline : Icons.pause_circle_outline, + _autoPlay ? 'Auto (A)' : 'Handmatig (A)', + _autoPlay, + ), + (Icons.repeat, 'Herhalen (L)', _loop), + (Icons.graphic_eq, 'Na audio (M)', _advanceOnAudioEnd), + ]; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (icon, label, active) in items) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Opacity( + opacity: active ? 0.7 : 0.28, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.white), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 11), + ), + ], + ), + ), + ), + ], + ); + } + + /// Vol-vlak zwart/wit scherm dat met een klik weer verdwijnt. + Widget _blankFill() { + return GestureDetector( + onTap: () => setState(() => _blank = _Blank.none), + child: Container( + color: _blank == _Blank.white ? Colors.white : Colors.black, + ), + ); + } + + /// A 16:9 slide sized to fit within the given constraints. + Widget _slideCanvas(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: SlidePreviewWidget( + slide: slide, + projectPath: widget.projectPath, + themeProfile: widget.themeProfile, + onLinkTap: openExternalUrl, + slideNumber: _index + 1, + slideCount: widget.slides.length, + tlp: widget.tlp, + // Tijdens het presenteren speelt media en starten audio/video + // vanzelf; het audio-einde stuurt de auto-advance aan. + enableMedia: true, + autoplayMedia: true, + onAudioComplete: _onAudioCompleted, + ), + ), + ); + }, + ); + } + + // ── Audience view (alleen de slide) ────────────────────────────────────── + + Widget _buildAudienceView(BuildContext context) { + final total = widget.slides.length; + final slide = widget.slides[_index.clamp(0, total - 1)]; + + // Blanco scherm vult in publieksweergave het hele beeld. + if (_blank != _Blank.none) return _blankFill(); + + return GestureDetector( + onTap: _next, + onSecondaryTap: _prev, + child: Stack( + children: [ + // ── Slide canvas ───────────────────────────────────────────────── + Positioned.fill(child: _slideCanvas(slide)), + + // ── Voortgangsbalk (auto-advance) ──────────────────────────────── + if (_progress > 0) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.white12, + color: Colors.white54, + minHeight: 3, + ), + ), + + // ── Slide counter ──────────────────────────────────────────────── + Positioned( + right: 24, + bottom: 10, + child: Text( + '${_index + 1} / $total', + style: const TextStyle( + color: Colors.white38, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + + // ── Auto-play status (linksonder) ──────────────────────────────── + Positioned(left: 24, bottom: 10, child: _autoPlayStatus()), + + // ── Navigation arrows ──────────────────────────────────────────── + Positioned( + left: 0, + top: 0, + bottom: 0, + child: MouseRegion( + cursor: _index > 0 ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + onTap: _prev, + child: Container( + width: 60, + color: Colors.transparent, + child: _index > 0 + ? const Icon( + Icons.chevron_left, + color: Colors.white24, + size: 40, + ) + : null, + ), + ), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: MouseRegion( + cursor: _index < total - 1 + ? SystemMouseCursors.click + : MouseCursor.defer, + child: GestureDetector( + onTap: _next, + child: Container( + width: 60, + color: Colors.transparent, + child: _index < total - 1 + ? const Icon( + Icons.chevron_right, + color: Colors.white24, + size: 40, + ) + : null, + ), + ), + ), + ), + + // ── Top-right controls (presenter view + afsluiten) ────────────── + Positioned( + top: 16, + right: 16, + child: Row( + children: [ + Tooltip( + message: 'Sneltoetsen (?)', + child: IconButton( + onPressed: _toggleHelp, + icon: const Icon(Icons.help_outline), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + ), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: 'Slide-overzicht (G)', + child: IconButton( + onPressed: _toggleGrid, + icon: const Icon(Icons.grid_view_rounded), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + ), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: 'Presenter view (P)', + child: IconButton( + onPressed: _togglePresenterView, + icon: const Icon(Icons.co_present_outlined), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + ), + ), + ), + const SizedBox(width: 8), + Tooltip( + message: 'Afsluiten (Escape)', + child: IconButton( + onPressed: _exit, + icon: const Icon(Icons.close), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.black45, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // ── Presenter view (slide + volgende + notities + tijd) ────────────────── + + Widget _buildPresenterView(BuildContext context) { + final total = widget.slides.length; + final slide = widget.slides[_index.clamp(0, total - 1)]; + final hasNext = _index < total - 1; + final nextSlide = hasNext ? widget.slides[_index + 1] : null; + + return Container( + color: const Color(0xFF0A0A0A), + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Hoofdgebied: huidige slide ─────────────────────────────────── + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel('HUIDIGE SLIDE'), + const SizedBox(height: 8), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GestureDetector( + onTap: _next, + child: Stack( + children: [ + Positioned.fill(child: _slideCanvas(slide)), + // Blanco scherm dekt alleen het slidevlak; jouw + // notities en klok blijven zichtbaar. + if (_blank != _Blank.none) + Positioned.fill(child: _blankFill()), + if (_progress > 0 && _blank == _Blank.none) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.white12, + color: Colors.white54, + minHeight: 3, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 10), + _buildPresenterControls(total), + ], + ), + ), + const SizedBox(width: 20), + + // ── Zijbalk: klok, volgende slide, notities ───────────────────── + SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildClockBar(), + const SizedBox(height: 16), + const _SectionLabel('VOLGENDE'), + const SizedBox(height: 8), + AspectRatio( + aspectRatio: 16 / 9, + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: nextSlide != null + ? Container( + color: Colors.black, + child: SlidePreviewWidget( + slide: nextSlide, + projectPath: widget.projectPath, + themeProfile: widget.themeProfile, + ), + ) + : Container( + color: const Color(0xFF161616), + alignment: Alignment.center, + child: const Text( + 'Einde van de presentatie', + style: TextStyle( + color: Colors.white38, + fontSize: 13, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + const _SectionLabel('NOTITIES'), + const SizedBox(height: 8), + Expanded(child: _buildNotes(slide)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildClockBar() { + final elapsed = DateTime.now().difference(_startTime); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF161616), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF262626)), + ), + child: Row( + children: [ + // Verstreken tijd + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Verstreken', + style: TextStyle(color: Colors.white38, fontSize: 10), + ), + const SizedBox(height: 2), + Text( + _fmtElapsed(elapsed), + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + // Reset-knop + Tooltip( + message: 'Tijd resetten (R)', + child: IconButton( + onPressed: _resetTimer, + icon: const Icon(Icons.restart_alt, size: 18), + color: Colors.white38, + visualDensity: VisualDensity.compact, + ), + ), + const SizedBox(width: 4), + // Wandklok + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text( + 'Klok', + style: TextStyle(color: Colors.white38, fontSize: 10), + ), + const SizedBox(height: 2), + Text( + _fmtClock(DateTime.now()), + style: const TextStyle( + color: Colors.white70, + fontSize: 24, + fontWeight: FontWeight.w600, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildNotes(Slide slide) { + final notes = slide.notes.trim(); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF161616), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF262626)), + ), + child: notes.isEmpty + ? const Align( + alignment: Alignment.topLeft, + child: Text( + 'Geen notities voor deze slide.', + style: TextStyle( + color: Colors.white30, + fontSize: 14, + fontStyle: FontStyle.italic, + ), + ), + ) + : SingleChildScrollView( + child: Text( + notes, + style: const TextStyle( + color: Color(0xFFE5E5E5), + fontSize: 17, + height: 1.5, + ), + ), + ), + ); + } + + Widget _buildPresenterControls(int total) { + return Row( + children: [ + _NavButton(icon: Icons.chevron_left, onTap: _index > 0 ? _prev : null), + const SizedBox(width: 8), + _NavButton( + icon: Icons.chevron_right, + onTap: _index < total - 1 ? _next : null, + ), + const SizedBox(width: 16), + Text( + 'Slide ${_index + 1} / $total', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + 'P publiek · G overzicht · B/W zwart/wit · R tijd · Esc stop', + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white24, fontSize: 11), + ), + ), + const SizedBox(width: 12), + Tooltip( + message: 'Afsluiten (Escape)', + child: IconButton( + onPressed: _exit, + icon: const Icon(Icons.close), + color: Colors.white, + style: IconButton.styleFrom(backgroundColor: Colors.black45), + ), + ), + ], + ); + } + + // ── Rasteroverzicht (snel naar een slide springen) ─────────────────────── + + Widget _buildGridOverlay() { + final total = widget.slides.length; + return Container( + color: Colors.black.withValues(alpha: 0.94), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 16, 12), + child: Row( + children: [ + const Text( + 'Slide-overzicht', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + Text( + 'pijltjes + Enter of klik om te springen · $total slides', + style: const TextStyle(color: Colors.white38, fontSize: 12), + ), + const Spacer(), + Tooltip( + message: 'Sluiten (G of Esc)', + child: IconButton( + onPressed: _toggleGrid, + icon: const Icon(Icons.close), + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: Colors.white10, + ), + ), + ), + ], + ), + ), + Expanded( + child: LayoutBuilder( + builder: (_, constraints) { + // Mik op tegels van ~260px breed, tussen 2 en 6 kolommen. + const hPad = 24.0, spacing = 16.0; + const aspect = 16 / 10.4; // slide + nummerregel + final cols = (constraints.maxWidth ~/ 260).clamp(2, 6); + // Maten onthouden voor pijltjesnavigatie + auto-scroll. + _gridCols = cols; + final tileW = + (constraints.maxWidth - hPad * 2 - spacing * (cols - 1)) / + cols; + _gridRowExtent = tileW / aspect + spacing; + return GridView.builder( + controller: _gridScroll, + padding: const EdgeInsets.fromLTRB(hPad, 0, hPad, 24), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, + childAspectRatio: aspect, + ), + itemCount: total, + itemBuilder: (_, i) => _buildGridTile(i), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildGridTile(int i) { + final isCurrent = i == _index; // de slide die nu getoond wordt + final isCursor = i == _gridCursor; // de toetsenbordcursor + + // Cursor wint qua markering (witte rand + gloed); de huidige slide krijgt + // anders een accentrand zodat je beide posities ziet. + final Color borderColor; + final double borderWidth; + if (isCursor) { + borderColor = Colors.white; + borderWidth = 3; + } else if (isCurrent) { + borderColor = const Color(0xFF60A5FA); + borderWidth = 2; + } else { + borderColor = const Color(0xFF3A3A3A); + borderWidth = 1; + } + + return GestureDetector( + onTap: () => _jumpTo(i), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => _setGridCursor(i), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor, width: borderWidth), + boxShadow: isCursor + ? [ + BoxShadow( + color: Colors.white.withValues(alpha: 0.25), + blurRadius: 16, + ), + ] + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 16 / 9, + child: SlidePreviewWidget( + slide: widget.slides[i], + projectPath: widget.projectPath, + themeProfile: widget.themeProfile, + ), + ), + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${i + 1}', + style: TextStyle( + color: (isCursor || isCurrent) + ? Colors.white + : Colors.white54, + fontSize: 12, + fontWeight: (isCursor || isCurrent) + ? FontWeight.w700 + : FontWeight.w400, + ), + ), + if (isCurrent) ...[ + const SizedBox(width: 5), + const Icon( + Icons.play_arrow_rounded, + size: 13, + color: Color(0xFF60A5FA), + ), + ], + ], + ), + ], + ), + ), + ); + } +} + +// ── Kleine helpers ─────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + color: Color(0xFF6B7280), + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ); + } +} + +class _NavButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onTap; + const _NavButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Material( + color: enabled ? const Color(0xFF1F1F1F) : const Color(0xFF141414), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 44, + height: 36, + child: Icon( + icon, + color: enabled ? Colors.white70 : Colors.white12, + size: 24, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/slides/inline_markdown.dart b/lib/widgets/slides/inline_markdown.dart new file mode 100644 index 0000000..c8595ca --- /dev/null +++ b/lib/widgets/slides/inline_markdown.dart @@ -0,0 +1,353 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Lichtgewicht inline-markdown: **vet**, *cursief* / _cursief_, `code`, +/// ~~doorhalen~~ en [tekst](url). Geen block-niveau (dat doet de slide-layout +/// al); puur de opmaak binnen één tekstregel. +/// +/// Twee gebruiken: +/// - [stripInlineMarkdown] geeft de kale tekst (voor de auto-fit-metingen). +/// - [InlineMarkdownText] / [buildInlineSpans] rendert met opmaak en +/// (optioneel) klikbare links. + +/// Eén stuk tekst met de actieve opmaak. +class InlineRun { + final String text; + final bool bold; + final bool italic; + final bool code; + final bool strike; + final String? link; + + const InlineRun( + this.text, { + this.bold = false, + this.italic = false, + this.code = false, + this.strike = false, + this.link, + }); + + InlineRun _with({ + bool? bold, + bool? italic, + bool? code, + bool? strike, + String? link, + }) { + return InlineRun( + text, + bold: bold ?? this.bold, + italic: italic ?? this.italic, + code: code ?? this.code, + strike: strike ?? this.strike, + link: link ?? this.link, + ); + } +} + +const _markers = r'*_~`[]()\'; + +/// Parse [text] naar opeenvolgende [InlineRun]s. Onafgesloten of ongeldige +/// opmaaktekens blijven gewoon letterlijke tekst. +List parseInlineRuns(String text) { + final out = []; + _parseInto(text, const InlineRun(''), out); + // Voeg aangrenzende identieke runs samen (netter en sneller om te renderen). + final merged = []; + for (final r in out) { + if (r.text.isEmpty) continue; + if (merged.isNotEmpty && + merged.last.bold == r.bold && + merged.last.italic == r.italic && + merged.last.code == r.code && + merged.last.strike == r.strike && + merged.last.link == r.link) { + final prev = merged.removeLast(); + merged.add( + InlineRun( + prev.text + r.text, + bold: prev.bold, + italic: prev.italic, + code: prev.code, + strike: prev.strike, + link: prev.link, + ), + ); + } else { + merged.add(r); + } + } + return merged; +} + +/// De kale tekst zonder opmaaktekens (linktekst blijft, de URL valt weg). +String stripInlineMarkdown(String text) { + if (!_hasMarker(text)) return text; + final buf = StringBuffer(); + for (final run in parseInlineRuns(text)) { + buf.write(run.text); + } + return buf.toString(); +} + +bool _hasMarker(String s) { + for (var i = 0; i < s.length; i++) { + if (_markers.contains(s[i])) return true; + } + return false; +} + +void _parseInto(String s, InlineRun ctx, List out) { + final buf = StringBuffer(); + void flush() { + if (buf.isNotEmpty) { + out.add(ctx._with()._copyText(buf.toString())); + buf.clear(); + } + } + + var i = 0; + while (i < s.length) { + final c = s[i]; + + // Escape: \* → * + if (c == r'\' && i + 1 < s.length && _markers.contains(s[i + 1])) { + buf.write(s[i + 1]); + i += 2; + continue; + } + + // `code` (letterlijk, geen nesting) + if (c == '`') { + final end = s.indexOf('`', i + 1); + if (end > i) { + flush(); + out.add(ctx._with(code: true)._copyText(s.substring(i + 1, end))); + i = end + 1; + continue; + } + } + + // [tekst](url) + if (c == '[') { + final close = _matchClosingBracket(s, i); + if (close != -1 && close + 1 < s.length && s[close + 1] == '(') { + final paren = s.indexOf(')', close + 2); + if (paren != -1) { + flush(); + final inner = s.substring(i + 1, close); + final url = s.substring(close + 2, paren).trim(); + _parseInto(inner, ctx._with(link: url), out); + i = paren + 1; + continue; + } + } + } + + // **vet** + if (c == '*' && i + 1 < s.length && s[i + 1] == '*') { + final end = _findDelimiter(s, i + 2, '**'); + if (end != -1) { + flush(); + _parseInto(s.substring(i + 2, end), ctx._with(bold: true), out); + i = end + 2; + continue; + } + } + + // ~~doorhalen~~ + if (c == '~' && i + 1 < s.length && s[i + 1] == '~') { + final end = _findDelimiter(s, i + 2, '~~'); + if (end != -1) { + flush(); + _parseInto(s.substring(i + 2, end), ctx._with(strike: true), out); + i = end + 2; + continue; + } + } + + // *cursief* of _cursief_ + if (c == '*' || c == '_') { + final end = _findDelimiter(s, i + 1, c); + if (end != -1 && end > i + 1) { + flush(); + _parseInto(s.substring(i + 1, end), ctx._with(italic: true), out); + i = end + 1; + continue; + } + } + + buf.write(c); + i++; + } + flush(); +} + +/// Vind het index van het sluitteken [delim] vanaf [from], rekening houdend +/// met escapes. Geeft -1 als het er niet is. +int _findDelimiter(String s, int from, String delim) { + var i = from; + while (i <= s.length - delim.length) { + if (s[i] == r'\') { + i += 2; + continue; + } + if (s.startsWith(delim, i)) return i; + i++; + } + return -1; +} + +/// Vind de bijbehorende ']' voor de '[' op [open] (geneste haken meegerekend). +int _matchClosingBracket(String s, int open) { + var depth = 0; + for (var i = open; i < s.length; i++) { + if (s[i] == r'\') { + i++; + continue; + } + if (s[i] == '[') depth++; + if (s[i] == ']') { + depth--; + if (depth == 0) return i; + } + } + return -1; +} + +extension on InlineRun { + InlineRun _copyText(String t) => InlineRun( + t, + bold: bold, + italic: italic, + code: code, + strike: strike, + link: link, + ); +} + +/// Bouw [InlineSpan]s uit [text]. Voor links worden recognizers aangemaakt en +/// in [recognizers] geplaatst zodat de aanroeper ze kan opruimen (anders lekt +/// het). Zonder [onTapLink] krijgen links alleen styling. +List buildInlineSpans( + String text, { + required TextStyle baseStyle, + required Color linkColor, + List? recognizers, + void Function(String url)? onTapLink, +}) { + final runs = parseInlineRuns(text); + return [ + for (final run in runs) + TextSpan( + text: run.text, + style: _styleFor(run, baseStyle, linkColor), + recognizer: (run.link != null && onTapLink != null) + ? _makeRecognizer(run.link!, onTapLink, recognizers) + : null, + ), + ]; +} + +TextStyle _styleFor(InlineRun run, TextStyle base, Color linkColor) { + var style = base; + if (run.bold) style = style.copyWith(fontWeight: FontWeight.bold); + if (run.italic) style = style.copyWith(fontStyle: FontStyle.italic); + if (run.code) { + style = style.copyWith( + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + ); + } + if (run.strike) { + style = style.copyWith( + decoration: TextDecoration.combine([ + if (style.decoration != null && style.decoration != TextDecoration.none) + style.decoration!, + TextDecoration.lineThrough, + ]), + ); + } + if (run.link != null) { + style = style.copyWith( + color: linkColor, + decoration: TextDecoration.underline, + decorationColor: linkColor, + ); + } + return style; +} + +GestureRecognizer _makeRecognizer( + String url, + void Function(String url) onTap, + List? sink, +) { + final recognizer = TapGestureRecognizer()..onTap = () => onTap(url); + sink?.add(recognizer); + return recognizer; +} + +/// Rendert [text] met inline-opmaak. Beheert link-recognizers leak-vrij. +class InlineMarkdownText extends StatefulWidget { + final String text; + final TextStyle style; + final Color linkColor; + final void Function(String url)? onTapLink; + final int? maxLines; + final TextAlign textAlign; + final TextOverflow overflow; + final bool softWrap; + + const InlineMarkdownText( + this.text, { + super.key, + required this.style, + required this.linkColor, + this.onTapLink, + this.maxLines, + this.textAlign = TextAlign.start, + this.overflow = TextOverflow.clip, + this.softWrap = true, + }); + + @override + State createState() => _InlineMarkdownTextState(); +} + +class _InlineMarkdownTextState extends State { + final List _recognizers = []; + + void _disposeRecognizers() { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _disposeRecognizers(); // verse set per build + final spans = buildInlineSpans( + widget.text, + baseStyle: widget.style, + linkColor: widget.linkColor, + recognizers: _recognizers, + onTapLink: widget.onTapLink, + ); + return Text.rich( + TextSpan(children: spans), + maxLines: widget.maxLines, + textAlign: widget.textAlign, + overflow: widget.overflow, + softWrap: widget.softWrap, + ); + } +} diff --git a/lib/widgets/slides/slide_preview.dart b/lib/widgets/slides/slide_preview.dart new file mode 100644 index 0000000..cbc3cde --- /dev/null +++ b/lib/widgets/slides/slide_preview.dart @@ -0,0 +1,2309 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:video_player/video_player.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../theme/app_theme.dart'; +import 'inline_markdown.dart'; + +/// Returns a TextStyle with the correct font, using GoogleFonts for web fonts. +TextStyle _applyFont(String font, TextStyle base) { + if (font == 'EB Garamond') return GoogleFonts.ebGaramond(textStyle: base); + return base.copyWith(fontFamily: font); +} + +/// Geeft de link-tap-handler door aan alle tekst in een slide, zonder die door +/// elke sub-widget heen te hoeven sleuren. Draagt ook of er een TLP-markering +/// rechtsonder staat, zodat bijschriften daarboven uitwijken. +class _SlideLinkScope extends InheritedWidget { + final void Function(String url)? onTapLink; + final bool hasBottomTlp; + const _SlideLinkScope({ + required this.onTapLink, + this.hasBottomTlp = false, + required super.child, + }); + + static void Function(String url)? of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_SlideLinkScope>() + ?.onTapLink; + } + + static bool hasBottomTlpOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_SlideLinkScope>() + ?.hasBottomTlp ?? + false; + } + + @override + bool updateShouldNotify(_SlideLinkScope oldWidget) => + oldWidget.onTapLink != onTapLink || + oldWidget.hasBottomTlp != hasBottomTlp; +} + +/// Tekst met inline-markdown (**vet**, *cursief*, `code`, ~~door~~, [link](url)). +/// Vervangt platte [Text] op alle inhoudsplekken van een slide. +Widget _md( + BuildContext context, + String text, + TextStyle style, { + required Color linkColor, + int? maxLines, + TextAlign textAlign = TextAlign.start, + TextOverflow overflow = TextOverflow.clip, + bool softWrap = true, +}) { + return InlineMarkdownText( + text, + style: style, + linkColor: linkColor, + onTapLink: _SlideLinkScope.of(context), + maxLines: maxLines, + textAlign: textAlign, + overflow: overflow, + softWrap: softWrap, + ); +} + +Color _hexColor(String hex) { + final cleaned = hex.replaceFirst('#', ''); + final value = int.tryParse( + cleaned.length == 6 ? 'FF$cleaned' : cleaned, + radix: 16, + ); + return Color(value ?? 0xFFFFFFFF); +} + +EdgeInsets _logoSafeInsets(double w, ThemeProfile profile) { + if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero; + final reserved = w * ((profile.logoSize + 64) / 1280); + if (profile.logoPosition.startsWith('top')) { + return EdgeInsets.only(top: reserved); + } + return EdgeInsets.only(bottom: reserved); +} + +EdgeInsets _splitTextLogoSafeInsets(double w, ThemeProfile profile) { + if (profile.logoPath?.isEmpty ?? true) return EdgeInsets.zero; + if (profile.logoPosition.endsWith('right')) return EdgeInsets.zero; + final reserved = w * ((profile.logoSize + 24) / 1280); + if (profile.logoPosition.startsWith('top')) { + return EdgeInsets.only(top: reserved); + } + return EdgeInsets.only(bottom: reserved); +} + +/// Renders a visual approximation of a Marp slide inside a 16:9 container. +/// All font sizes and paddings are proportional to the widget width so the +/// same widget works both as the full preview pane and as a tiny thumbnail. +/// Content that exceeds the slide height is scaled down proportionally via +/// FittedBox rather than clipped. +class SlidePreviewWidget extends StatelessWidget { + final Slide slide; + final String? projectPath; + final ThemeProfile themeProfile; + + /// Het lettertype hoort bij de stijl (themeProfile), niet bij de app. + String get fontFamily => themeProfile.fontFamily; + + /// Optioneel: maakt links in de tekst klikbaar (preview/presenter). In + /// thumbnails en bij export blijft dit null → links zijn alleen gestyled. + final void Function(String url)? onLinkTap; + + /// 1-gebaseerd slidenummer en totaal, voor footer-paginanummers en de + /// {page}/{total}-tokens. Null → geen paginanummers. + final int? slideNumber; + final int? slideCount; + + /// TLP-classificatie van de presentatie; getoond als markering op de slide. + final TlpLevel tlp; + + /// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt + /// en video kan starten). Standaard uit — thumbnails en export spelen niets. + final bool enableMedia; + + /// Of media automatisch start (audio-/video-autoplay). In de editor-preview + /// staat dit uit (handmatig starten); in de presenter aan. + final bool autoplayMedia; + + /// Wordt aangeroepen wanneer de audio van deze slide klaar is (voor de + /// automatische modus van de presenter). + final VoidCallback? onAudioComplete; + + const SlidePreviewWidget({ + super.key, + required this.slide, + this.projectPath, + this.themeProfile = const ThemeProfile(), + this.onLinkTap, + this.slideNumber, + this.slideCount, + this.tlp = TlpLevel.none, + this.enableMedia = false, + this.autoplayMedia = false, + this.onAudioComplete, + }); + + @override + Widget build(BuildContext context) { + // Make the widget self-sufficient for text rendering. On screen it sits + // inside a Material (which supplies a clean DefaultTextStyle), but the + // export rasterizer mounts it in a bare Overlay subtree. Without an + // explicit DefaultTextStyle there, any Text that doesn't set its own color + // falls back to Flutter's broken default — red letters with a yellow + // underline — which is exactly what showed up in exports. Wrapping here + // guarantees identical results in the preview and the export. + return Directionality( + textDirection: TextDirection.ltr, + child: DefaultTextStyle( + style: TextStyle( + color: _hexColor(themeProfile.textColor), + decoration: TextDecoration.none, + fontWeight: FontWeight.normal, + fontStyle: FontStyle.normal, + ), + child: _SlideLinkScope( + onTapLink: onLinkTap, + hasBottomTlp: tlp != TlpLevel.none, + child: _buildSlide(), + ), + ), + ); + } + + Widget _buildSlide() { + return LayoutBuilder( + builder: (_, constraints) { + final w = constraints.maxWidth; + return AspectRatio( + aspectRatio: 16 / 9, + child: ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + _buildContent(w), + _FooterOverlay( + slide: slide, + w: w, + profile: themeProfile, + slideNumber: slideNumber, + slideCount: slideCount, + tlp: tlp, + ), + if (tlp != TlpLevel.none) + _TlpOverlay(tlp: tlp, w: w, profile: themeProfile), + if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) + _LogoOverlay( + logoPath: themeProfile.logoPath!, + projectPath: projectPath, + position: themeProfile.logoPosition, + size: w * (themeProfile.logoSize / 1280), + ), + if (enableMedia && slide.audioPath.isNotEmpty) + _AudioPlayback( + audioPath: slide.audioPath, + projectPath: projectPath, + autoplay: autoplayMedia && slide.audioAutoplay, + onComplete: onAudioComplete, + w: w, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildContent(double w) { + switch (slide.type) { + case SlideType.title: + return _TitlePreview( + slide: slide, + w: w, + projectPath: projectPath, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.section: + return _SectionPreview( + slide: slide, + w: w, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.bullets: + return _BulletsPreview( + slide: slide, + w: w, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.twoBullets: + return _TwoBulletsPreview( + slide: slide, + w: w, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.bulletsImage: + return _BulletsImagePreview( + slide: slide, + w: w, + projectPath: projectPath, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.twoImages: + return _TwoImagesPreview( + slide: slide, + w: w, + projectPath: projectPath, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.image: + return _ImagePreview( + slide: slide, + w: w, + projectPath: projectPath, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.video: + return _VideoPreview( + slide: slide, + w: w, + projectPath: projectPath, + font: fontFamily, + profile: themeProfile, + autoplay: autoplayMedia && slide.videoAutoplay, + ); + case SlideType.quote: + return _QuotePreview( + slide: slide, + w: w, + font: fontFamily, + projectPath: projectPath, + profile: themeProfile, + ); + case SlideType.table: + return _TablePreview( + slide: slide, + w: w, + font: fontFamily, + profile: themeProfile, + ); + case SlideType.freeMarkdown: + return _MarkdownPreview( + slide: slide, + w: w, + font: fontFamily, + profile: themeProfile, + ); + } + } +} + +class _AudioPlayback extends StatefulWidget { + final String audioPath; + final String? projectPath; + final bool autoplay; + final double w; + final VoidCallback? onComplete; + + const _AudioPlayback({ + required this.audioPath, + required this.projectPath, + required this.autoplay, + required this.w, + this.onComplete, + }); + + @override + State<_AudioPlayback> createState() => _AudioPlaybackState(); +} + +class _AudioPlaybackState extends State<_AudioPlayback> { + VideoPlayerController? _controller; + bool _completed = false; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_AudioPlayback oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.audioPath != widget.audioPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + _controller?.removeListener(_onTick); + await _controller?.dispose(); + _completed = false; + final path = _resolvePath(widget.audioPath, widget.projectPath); + if (path == null) return; + final controller = VideoPlayerController.file(File(path)); + _controller = controller; + try { + await controller.initialize(); + controller.addListener(_onTick); + if (widget.autoplay) await controller.play(); + } catch (_) {} + if (mounted) setState(() {}); + } + + /// Detecteer het einde van de audio en meld dat één keer (voor auto-advance). + void _onTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _completed) return; + final pos = c.value.position; + final dur = c.value.duration; + if (dur > Duration.zero && + pos.inMilliseconds >= dur.inMilliseconds - 200 && + !c.value.isPlaying) { + _completed = true; + widget.onComplete?.call(); + } + } + + @override + void dispose() { + _controller?.removeListener(_onTick); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Positioned( + right: widget.w * 0.035, + bottom: widget.w * 0.035, + child: IconButton( + tooltip: 'Audio', + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.volume_up + : Icons.volume_up_outlined, + ), + iconSize: widget.w * 0.032, + ), + ); + } +} + +// ── Individual slide-type renderers ────────────────────────────────────────── + +class _TitlePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TitlePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + Widget _content(BuildContext context) { + final pad = w * 0.08; + final link = _hexColor(profile.accentColor); + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.055, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: link, + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.02), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.03, + height: 1.3, + ), + ), + linkColor: link, + ), + ], + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final hasBg = slide.imagePath.isNotEmpty; + + if (!hasBg) { + return Container( + color: _hexColor(profile.titleBackgroundColor), + child: SizedBox.expand(child: _content(context)), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.titleBackgroundColor), + ), + Container( + color: _hexColor( + profile.titleBackgroundColor, + ).withValues(alpha: 0.62), + ), + _content(context), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _SectionPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _SectionPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + return Container( + color: _hexColor(profile.sectionBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (slide.title.isNotEmpty) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: _hexColor(profile.titleTextColor), + fontSize: w * 0.05, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (slide.subtitle.isNotEmpty) ...[ + SizedBox(height: w * 0.015), + _md( + context, + slide.subtitle, + _applyFont( + font, + TextStyle( + color: _hexColor( + profile.titleTextColor, + ).withValues(alpha: 0.72), + fontSize: w * 0.025, + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _BulletsPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final titleSize = w * 0.042; + final bulletSize = w * 0.026; + final spacing = pad * 0.5; + final bulletGap = w * 0.006; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + final slideHeight = w * 9 / 16; + final availW = (w - pad * 2).clamp(w * 0.12, w); + final availH = slideHeight - (pad + safe.top) - (pad + safe.bottom); + // Grow (or, when needed, shrink) the text so it uses the full vertical + // space instead of leaving a large empty area below a few short bullets. + final scale = _bulletsFitScale( + availW: availW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + maxScale: _kSplitBulletsMaxScale, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle && bullets.isNotEmpty) + SizedBox(height: spacing * scale), + ...bullets.map((b) { + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = b.substring(level); + final fontSize = + bulletSize * _bulletLevelScale(level) * scale; + return Padding( + padding: EdgeInsets.only( + left: level * bulletSize * 1.05 * scale, + top: bulletGap * scale, + bottom: bulletGap * scale, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_bulletMarkerForLevel(level)} ', + style: TextStyle( + fontSize: fontSize, + color: _hexColor(profile.accentColor), + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: _md( + context, + text, + _applyFont( + font, + TextStyle( + fontSize: fontSize, + height: _kBulletLineHeight, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TablePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TablePreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.06; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final titleSize = w * 0.038; + final rows = slide.tableRows.where((r) => r.isNotEmpty).toList(); + final colCount = rows.fold(0, (m, r) => r.length > m ? r.length : m); + + // Scale cell text down as the table grows so it keeps fitting nicely. + final density = (rows.length + colCount).clamp(2, 24); + final cellSize = (w * 0.025 * (10 / (density + 6))).clamp( + w * 0.010, + w * 0.021, + ); + + final accent = _hexColor(profile.accentColor); + final textColor = _hexColor(profile.tableTextColor); + final headerTextColor = _hexColor(profile.tableHeaderTextColor); + final borderColor = accent.withValues(alpha: 0.35); + + Widget cell(String value, {required bool header}) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: cellSize * 0.55, + vertical: cellSize * 0.36, + ), + child: _md( + context, + value, + _applyFont( + font, + TextStyle( + fontSize: cellSize, + color: header ? headerTextColor : textColor, + fontWeight: header ? FontWeight.bold : FontWeight.normal, + ), + ), + linkColor: header ? headerTextColor : accent, + ), + ); + } + + TableRow buildRow(List row, {required bool header}) { + return TableRow( + decoration: BoxDecoration(color: header ? accent : null), + children: List.generate(colCount, (c) { + final value = c < row.length ? row[c] : ''; + return TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: cell(value, header: header), + ); + }), + ); + } + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (slide.title.isNotEmpty) ...[ + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + SizedBox(height: pad * 0.35), + ], + if (rows.isNotEmpty && colCount > 0) + Table( + border: TableBorder.all( + color: borderColor, + width: w * 0.0012, + ), + defaultColumnWidth: const FlexColumnWidth(), + children: [ + buildRow(rows.first, header: true), + for (var i = 1; i < rows.length; i++) + buildRow(rows[i], header: false), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _TwoBulletsPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _TwoBulletsPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.065; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final titleSize = w * 0.04; + final bulletSize = w * 0.024; + final spacing = pad * 0.38; + final bulletGap = w * 0.0055; + final columnGap = w * 0.055; + final leftBullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final rightBullets = slide.bullets2 + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + final slideHeight = w * 9 / 16; + final contentW = (w - pad * 2).clamp(w * 0.12, w); + final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w); + var availH = slideHeight - (pad + safe.top) - (pad + safe.bottom); + if (hasTitle) { + availH -= _measureTextHeight( + slide.title, + titleSize, + contentW, + bold: true, + ); + availH -= spacing; + } + final leftScale = _bulletsFitScale( + availW: columnW, + availH: availH, + hasTitle: false, + title: '', + bullets: leftBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + maxScale: _kBulletsMaxScale, + ); + final rightScale = _bulletsFitScale( + availW: columnW, + availH: availH, + hasTitle: false, + title: '', + bullets: rightBullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + maxScale: _kBulletsMaxScale, + ); + final scale = leftScale < rightScale ? leftScale : rightScale; + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand( + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: contentW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle) SizedBox(height: spacing), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: columnW, + child: _BulletListColumn( + bullets: leftBullets, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + ), + ), + SizedBox(width: columnGap), + SizedBox( + width: columnW, + child: _BulletListColumn( + bullets: rightBullets, + font: font, + profile: profile, + bulletSize: bulletSize, + bulletGap: bulletGap, + scale: scale, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BulletsImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _BulletsImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final leftPad = w * 0.038; + final verticalPad = w * 0.042; + // Keep the gap between the text column and the image equal to the slide's + // left margin so the layout stays symmetric. + final gap = leftPad; + final safe = slide.showLogo + ? _splitTextLogoSafeInsets(w, profile) + : EdgeInsets.zero; + final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40) + .clamp(0.1, 0.70); + final imgWidth = w * imgFraction; + final bulletSize = w * 0.031; + final titleSize = w * 0.042; + final spacing = verticalPad * 0.32; + final bulletGap = w * 0.005; + final bullets = slide.bullets + .where((b) => b.trimLeft().isNotEmpty) + .toList(); + final hasTitle = slide.title.isNotEmpty; + + // The slide is always rendered 16:9, so the available area for the text + // column is fully determined by the width. Computing it directly (instead + // of via a LayoutBuilder) keeps the widget tree identical to the image + // side and avoids any layout-timing surprises. + final slideHeight = w * 9 / 16; + final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w); + final availH = + slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom); + // Pick the largest font scale (capped at the design size) whose content + // still fits the available height at the full column width. This keeps the + // text as large as possible and lets it span the full width toward the + // image, instead of uniformly shrinking and leaving a wide gap. + final scale = _bulletsFitScale( + availW: availW, + availH: availH, + hasTitle: hasTitle, + title: slide.title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + maxScale: _kBulletsMaxScale, + ); + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + children: [ + Positioned( + top: 0, + right: 0, + bottom: 0, + width: imgWidth, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(slide.imagePath, projectPath), + _captionOverlay( + context, + slide.imageCaption, + w, + right: w * 0.018, + ), + ], + ), + ), + Positioned( + top: 0, + left: 0, + right: imgWidth + gap, + bottom: 0, + child: Padding( + padding: EdgeInsets.fromLTRB( + leftPad, + verticalPad + safe.top, + 0, + verticalPad + safe.bottom, + ), + // FittedBox stays as a safety net for measurement rounding; with + // an accurate scale it renders at scale 1 (full width). + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: availW, + child: _contentColumn( + context: context, + scale: scale, + bullets: bullets, + hasTitle: hasTitle, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _contentColumn({ + required BuildContext context, + required double scale, + required List bullets, + required bool hasTitle, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasTitle) + _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + fontSize: titleSize * scale, + fontWeight: FontWeight.bold, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale), + ...bullets.map((b) { + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = b.substring(level); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return Padding( + padding: EdgeInsets.only( + left: level * bulletSize * 1.05 * scale, + top: bulletGap * scale, + bottom: bulletGap * scale, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_bulletMarkerForLevel(level)} ', + style: TextStyle( + fontSize: fontSize, + color: _hexColor(profile.accentColor), + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: _md( + context, + text, + _applyFont( + font, + TextStyle( + fontSize: fontSize, + height: _kBulletLineHeight, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + ], + ), + ); + }), + ], + ); + } +} + +class _BulletListColumn extends StatelessWidget { + final List bullets; + final String font; + final ThemeProfile profile; + final double bulletSize; + final double bulletGap; + final double scale; + + const _BulletListColumn({ + required this.bullets, + required this.font, + required this.profile, + required this.bulletSize, + required this.bulletGap, + required this.scale, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ...bullets.map((b) { + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = b.substring(level); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + return Padding( + padding: EdgeInsets.only( + left: level * bulletSize * 1.05 * scale, + top: bulletGap * scale, + bottom: bulletGap * scale, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_bulletMarkerForLevel(level)} ', + style: TextStyle( + fontSize: fontSize, + color: _hexColor(profile.accentColor), + fontWeight: FontWeight.bold, + ), + ), + Expanded( + child: _md( + context, + text, + _applyFont( + font, + TextStyle( + fontSize: fontSize, + height: _kBulletLineHeight, + color: _hexColor(profile.textColor), + ), + ), + linkColor: _hexColor(profile.accentColor), + ), + ), + ], + ), + ); + }), + ], + ); + } +} + +/// Upper bound for growing bullet text to fill otherwise empty vertical space. +const double _kBulletsMaxScale = 3.2; + +/// Split slides have a much narrower column, so short bullet lists can stay +/// visually timid unless they are allowed to grow a little further. +const double _kSplitBulletsMaxScale = 4.35; + +/// Line height used for bullet body text, shared by rendering and measuring. +const double _kBulletLineHeight = 1.16; + +String _bulletMarkerForLevel(int level) { + const markers = ['•', '◦', '▪', '▫', '–']; + return markers[level.clamp(0, markers.length - 1)]; +} + +double _bulletLevelScale(int level) { + if (level <= 0) return 1.0; + if (level == 1) return 0.86; + if (level == 2) return 0.80; + return 0.76; +} + +/// Largest scale in [minScale, maxScale] for which the bullet block fits +/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this +/// also grows the text *above* its design size when there is spare vertical +/// room, so short slides use the full height instead of clustering at the top. +double _bulletsFitScale({ + required double availW, + required double availH, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, + double minScale = 0.2, + double maxScale = 1.0, +}) { + if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0; + // 2% safety margin so minor measurement differences never overflow. + final budget = availH * 0.98; + double measure(double scale) => _bulletsBlockHeight( + scale: scale, + availW: availW, + hasTitle: hasTitle, + title: title, + bullets: bullets, + titleSize: titleSize, + bulletSize: bulletSize, + spacing: spacing, + bulletGap: bulletGap, + ); + + // Everything already fits at the largest allowed size → use it. + if (measure(maxScale) <= budget) return maxScale; + + // Otherwise binary-search the largest scale that fits. Search upward from the + // design size when it fits, downward when even the design size overflows. + double lo, hi; + if (maxScale > 1.0 && measure(1.0) <= budget) { + lo = 1.0; + hi = maxScale; + } else { + lo = minScale; + hi = maxScale > 1.0 ? 1.0 : maxScale; + } + for (var i = 0; i < 24; i++) { + final mid = (lo + hi) / 2; + if (measure(mid) <= budget) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +double _bulletsBlockHeight({ + required double scale, + required double availW, + required bool hasTitle, + required String title, + required List bullets, + required double titleSize, + required double bulletSize, + required double spacing, + required double bulletGap, +}) { + var height = 0.0; + if (hasTitle) { + height += _measureTextHeight(title, titleSize * scale, availW, bold: true); + } + if (hasTitle && bullets.isNotEmpty) height += spacing * scale; + for (final b in bullets) { + int level = 0; + while (level < b.length && b[level] == '\t') { + level++; + } + final text = b.substring(level); + final fontSize = bulletSize * _bulletLevelScale(level) * scale; + final indent = level * bulletSize * 1.05 * scale; + final marker = '${_bulletMarkerForLevel(level)} '; + final markerW = _measureTextWidth(marker, fontSize, bold: true); + final wrapW = (availW - indent - markerW).clamp(1.0, availW); + final textH = _measureTextHeight( + text, + fontSize, + wrapW, + lineHeight: _kBulletLineHeight, + ); + final markerH = _measureTextHeight(marker, fontSize, double.infinity); + height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH); + } + return height; +} + +double _measureTextHeight( + String text, + double fontSize, + double maxWidth, { + double? lineHeight, + bool bold = false, +}) { + final painter = TextPainter( + text: TextSpan( + text: stripInlineMarkdown(text), + style: TextStyle( + fontSize: fontSize, + height: lineHeight, + fontWeight: bold ? FontWeight.bold : null, + ), + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity); + return painter.height; +} + +double _measureTextWidth(String text, double fontSize, {bool bold = false}) { + final painter = TextPainter( + text: TextSpan( + text: stripInlineMarkdown(text), + style: TextStyle( + fontSize: fontSize, + fontWeight: bold ? FontWeight.bold : null, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + return painter.width; +} + +class _TwoImagesPreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _TwoImagesPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5) + .clamp(0.1, 0.9); + final leftW = w * splitFraction; + final rightW = w * (1 - splitFraction); + final titleSize = w * 0.032; + + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + // Twee afbeeldingen naast elkaar + Row( + children: [ + SizedBox( + width: leftW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(slide.imagePath, projectPath), + _captionOverlay(context, slide.imageCaption, w), + ], + ), + ), + SizedBox( + width: rightW, + child: Stack( + fit: StackFit.expand, + children: [ + _resolvedImage(slide.imagePath2, projectPath), + _captionOverlay(context, slide.imageCaption2, w), + ], + ), + ), + ], + ), + // Optionele ondertitel + if (slide.title.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: w * 0.04, + child: Container( + color: Colors.black54, + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.015, + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: titleSize, + fontWeight: FontWeight.w500, + ), + ), + linkColor: const Color(0xFF8BB8FF), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + + const _ImagePreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final hasTitle = slide.title.isNotEmpty; + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + // When zoomed out, anchor the image to the top so the bottom title + // banner sits in the freed-up space instead of over the picture. + alignment: hasTitle ? Alignment.topCenter : Alignment.center, + ), + if (slide.title.isNotEmpty) + Positioned( + left: w * 0.06, + right: w * 0.06, + bottom: w * 0.06, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * 0.04, + vertical: w * 0.02, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: _md( + context, + slide.title, + _applyFont( + font, + TextStyle( + color: Colors.white, + fontSize: w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: const Color(0xFF8BB8FF), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _VideoPreview extends StatefulWidget { + final Slide slide; + final double w; + final String? projectPath; + final String font; + final ThemeProfile profile; + final bool autoplay; + + const _VideoPreview({ + required this.slide, + required this.w, + this.projectPath, + required this.font, + required this.profile, + this.autoplay = false, + }); + + @override + State<_VideoPreview> createState() => _VideoPreviewState(); +} + +class _VideoPreviewState extends State<_VideoPreview> { + VideoPlayerController? _controller; + String? _path; + + @override + void initState() { + super.initState(); + _init(); + } + + @override + void didUpdateWidget(_VideoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.slide.videoPath != widget.slide.videoPath || + oldWidget.autoplay != widget.autoplay) { + _init(); + } + } + + Future _init() async { + await _controller?.dispose(); + _controller = null; + _path = _resolvePath(widget.slide.videoPath, widget.projectPath); + if (_path == null) { + if (mounted) setState(() {}); + return; + } + final controller = VideoPlayerController.file(File(_path!)); + _controller = controller; + try { + await controller.initialize(); + await controller.setLooping(widget.autoplay); + if (widget.autoplay) await controller.play(); + } catch (_) { + // Keep the placeholder visible when the platform cannot open the file. + } + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = _controller; + return Container( + color: _hexColor(widget.profile.slideBackgroundColor), + child: Stack( + fit: StackFit.expand, + children: [ + if (controller != null && controller.value.isInitialized) + Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ) + else + _mediaPlaceholder(Icons.movie_outlined, 'Video'), + if (widget.slide.title.isNotEmpty) + Positioned( + left: widget.w * 0.06, + right: widget.w * 0.06, + top: widget.w * 0.04, + child: _md( + context, + widget.slide.title, + _applyFont( + widget.font, + TextStyle( + color: _hexColor(widget.profile.textColor), + fontSize: widget.w * 0.038, + fontWeight: FontWeight.bold, + ), + ), + linkColor: _hexColor(widget.profile.accentColor), + ), + ), + Positioned( + left: widget.w * 0.04, + bottom: widget.w * 0.035, + child: IconButton( + onPressed: controller == null || !controller.value.isInitialized + ? null + : () { + setState(() { + controller.value.isPlaying + ? controller.pause() + : controller.play(); + }); + }, + icon: Icon( + controller?.value.isPlaying == true + ? Icons.pause_circle + : Icons.play_circle, + ), + iconSize: widget.w * 0.045, + ), + ), + ], + ), + ); + } +} + +class _QuotePreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final String? projectPath; + final ThemeProfile profile; + + const _QuotePreview({ + required this.slide, + required this.w, + required this.font, + this.projectPath, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.08; + final hasBg = slide.imagePath.isNotEmpty; + final textColor = hasBg ? Colors.white : _hexColor(profile.textColor); + final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!; + final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor); + + final content = FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.all(pad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: w * 0.008, + height: w * 0.12, + color: accentColor, + margin: EdgeInsets.only(right: pad * 0.4), + ), + Expanded( + child: _md( + context, + slide.quote.isEmpty ? '' : '"${slide.quote}"', + _applyFont( + font, + TextStyle( + fontSize: w * 0.033, + fontStyle: FontStyle.italic, + color: textColor, + height: 1.4, + ), + ), + linkColor: accentColor, + ), + ), + ], + ), + if (slide.quoteAuthor.isNotEmpty) ...[ + SizedBox(height: pad * 0.6), + _md( + context, + '— ${slide.quoteAuthor}', + _applyFont( + font, + TextStyle( + fontSize: w * 0.026, + color: authorColor, + fontWeight: FontWeight.w500, + ), + ), + linkColor: accentColor, + ), + ], + ], + ), + ), + ), + ); + + if (!hasBg) { + return Container( + color: _hexColor(profile.slideBackgroundColor), + child: SizedBox.expand(child: content), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + _zoomedImage( + slide.imagePath, + projectPath, + slide.imageSize, + bgColor: _hexColor(profile.slideBackgroundColor), + ), + Container(color: Colors.black.withValues(alpha: 0.52)), + content, + _captionOverlay(context, slide.imageCaption, w), + ], + ); + } +} + +class _LogoOverlay extends StatelessWidget { + final String logoPath; + final String? projectPath; + final String position; + final double size; + + const _LogoOverlay({ + required this.logoPath, + required this.projectPath, + required this.position, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final horizontalInset = size * 0.28; + final topInset = size * 0.42; + final bottomInset = size * 0.12; + return Positioned( + top: position.startsWith('top') ? topInset : null, + bottom: position.startsWith('bottom') ? bottomInset : null, + left: position.endsWith('left') ? horizontalInset : null, + right: position.endsWith('right') ? horizontalInset : null, + child: SizedBox( + width: size, + height: size, + child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain), + ), + ); + } +} + +class _MarkdownPreview extends StatelessWidget { + final Slide slide; + final double w; + final String font; + final ThemeProfile profile; + + const _MarkdownPreview({ + required this.slide, + required this.w, + required this.font, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final pad = w * 0.07; + final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero; + final lines = slide.customMarkdown.split('\n'); + + return Container( + color: Colors.white, + child: SizedBox.expand( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.topLeft, + child: SizedBox( + width: w, + child: Padding( + padding: EdgeInsets.fromLTRB( + pad, + pad + safe.top, + pad, + pad + safe.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: lines.take(20).map((line) { + final link = _hexColor(profile.accentColor); + if (line.startsWith('# ')) { + return _md( + context, + line.substring(2), + _applyFont( + font, + TextStyle( + fontSize: w * 0.04, + fontWeight: FontWeight.bold, + color: AppTheme.navy, + ), + ), + linkColor: link, + ); + } else if (line.startsWith('## ')) { + return _md( + context, + line.substring(3), + _applyFont( + font, + TextStyle( + fontSize: w * 0.03, + fontWeight: FontWeight.w600, + ), + ), + linkColor: link, + ); + } else if (line.startsWith('- ')) { + return _md( + context, + '• ${line.substring(2)}', + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + } else if (line.isEmpty) { + return SizedBox(height: w * 0.01); + } + return _md( + context, + line, + _applyFont(font, TextStyle(fontSize: w * 0.024)), + linkColor: link, + ); + }).toList(), + ), + ), + ), + ), + ), + ); + } +} + +// ── Shared helper ───────────────────────────────────────────────────────────── + +/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain. +/// imageSize = 0 → cover (Marp-standaard, vult frame, snijdt bij) +/// imageSize = 100 → volledige afbeelding zichtbaar (contain, evt. randen) +/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect +/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain +Widget _zoomedImage( + String imagePath, + String? projectPath, + int imageSize, { + Color bgColor = Colors.black, + Alignment alignment = Alignment.center, +}) { + if (imageSize == 0) { + return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard + } + final scale = imageSize / 100.0; + // Size the image box to `scale` × the available area and let BoxFit.contain + // fit the picture inside it. This produces the same visual result as a + // Transform.scale but without a transform layer, which `RepaintBoundary + // .toImage` (used for exports) captures far more reliably — a scaled + // transform layer would frequently render blank in the exported PNG. + return ClipRect( + child: ColoredBox( + color: bgColor, + child: LayoutBuilder( + builder: (context, constraints) { + final boxW = constraints.maxWidth * scale; + final boxH = constraints.maxHeight * scale; + return Align( + alignment: alignment, + child: SizedBox( + width: boxW, + height: boxH, + // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden + child: _resolvedImage( + imagePath, + projectPath, + fit: BoxFit.contain, + ), + ), + ); + }, + ), + ), + ); +} + +Widget _resolvedImage( + String imagePath, + String? projectPath, { + BoxFit fit = BoxFit.cover, +}) { + if (imagePath.isEmpty) return _imagePlaceholder(); + + final String resolved; + if (imagePath.startsWith('/') || imagePath.contains(':\\')) { + resolved = imagePath; + } else if (projectPath != null) { + resolved = '$projectPath/$imagePath'; + } else { + resolved = imagePath; + } + + return Image.file( + File(resolved), + fit: fit, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) => _imagePlaceholder(), + ); +} + +Widget _captionOverlay( + BuildContext context, + String caption, + double w, { + double? right, + double? bottom, +}) { + final text = caption.trim(); + if (text.isEmpty) return const SizedBox.shrink(); + // Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering + // staat, schuift het bijschrift erboven zodat het niet wordt overschreven. + final lift = _SlideLinkScope.hasBottomTlpOf(context) + ? _tlpVerticalReserve(w) + : 0.0; + return Positioned( + right: right ?? w * 0.018, + bottom: (bottom ?? w * 0.014) + lift, + child: Container( + constraints: BoxConstraints(maxWidth: w * 0.5), + padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.58), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + text, + textAlign: TextAlign.right, + style: TextStyle( + color: Colors.white, + fontSize: w * 0.011, + height: 1.25, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); +} + +String? _resolvePath(String path, String? projectPath) { + if (path.isEmpty) return null; + if (path.startsWith('/') || path.contains(':\\')) return path; + if (projectPath != null) return '$projectPath/$path'; + return path; +} + +// ── TLP-markering: maten gedeeld door de badge en de footer-uitsparing ────── +const double _kTlpFont = 0.018; // × slidebreedte +const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte) +const double _kTlpHPad = 0.011; +const double _kTlpVPad = 0.005; + +/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. +double _tlpBadgeWidth(double w, TlpLevel tlp) => + tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); + +/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). +double _tlpVerticalReserve(double w) => + w * _kTlpFont + 2 * (w * _kTlpVPad) + w * 0.014; + +/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak, +/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. +class _TlpOverlay extends StatelessWidget { + final TlpLevel tlp; + final double w; + final ThemeProfile profile; + + const _TlpOverlay({ + required this.tlp, + required this.w, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + final toLeft = profile.logoPosition == 'bottom-right'; + return Positioned( + bottom: w * 0.022, + left: toLeft ? w * _kTlpEdge : null, + right: toLeft ? null : w * _kTlpEdge, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: w * _kTlpHPad, + vertical: w * _kTlpVPad, + ), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(w * 0.005), + ), + child: Text( + tlp.label, + style: TextStyle( + color: Color(tlp.foreground), + fontSize: w * _kTlpFont, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + fontFamily: 'monospace', + fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'], + height: 1.0, + ), + ), + ), + ); + } +} + +/// Footer onderaan een slide: vrije tekst (links) + paginanummers (rechts), +/// op basis van het stijlprofiel. Verborgen op titel-/sectieslides (daar is +/// een footer ongebruikelijk en valt 'ie weg tegen de donkere achtergrond). +/// Linkermarge waar de inhoud (bullets/tekst) van een slide begint. Wordt +/// gebruikt om een links-uitgelijnde footer ermee te laten uitlijnen, zodat het +/// geheel consistenter oogt. Moet overeenkomen met de `pad`-waarden van de +/// afzonderlijke slide-renderers hierboven. +double _contentLeftInset(Slide slide, double w) { + switch (slide.type) { + case SlideType.bullets: + case SlideType.freeMarkdown: + return w * 0.07; + case SlideType.twoBullets: + return w * 0.065; + case SlideType.table: + return w * 0.06; + case SlideType.bulletsImage: + return w * 0.038; + case SlideType.quote: + return w * 0.08; + default: + // Beeld/video: geen tekstmarge om mee uit te lijnen. + return w * 0.04; + } +} + +class _FooterOverlay extends StatelessWidget { + final Slide slide; + final double w; + final ThemeProfile profile; + final int? slideNumber; + final int? slideCount; + final TlpLevel tlp; + + const _FooterOverlay({ + required this.slide, + required this.w, + required this.profile, + this.slideNumber, + this.slideCount, + this.tlp = TlpLevel.none, + }); + + String _applyTokens(String s) { + final now = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final date = '${two(now.day)}-${two(now.month)}-${now.year}'; + return s + .replaceAll('{page}', slideNumber?.toString() ?? '') + .replaceAll('{total}', slideCount?.toString() ?? '') + .replaceAll('{date}', date) + .replaceAll('{title}', slide.title); + } + + @override + Widget build(BuildContext context) { + if (!slide.showFooter) return const SizedBox.shrink(); + if (slide.type == SlideType.title || slide.type == SlideType.section) { + return const SizedBox.shrink(); + } + + final footerText = _applyTokens(profile.footerText).trim(); + final showPages = profile.footerShowPageNumbers && slideNumber != null; + if (footerText.isEmpty && !showPages) return const SizedBox.shrink(); + + // Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt. + final fontSize = w * 0.0145; + final style = TextStyle( + color: _hexColor(profile.textColor).withValues(alpha: 0.7), + fontSize: fontSize, + // Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft. + shadows: [ + Shadow( + color: Colors.white.withValues(alpha: 0.5), + blurRadius: w * 0.003, + ), + ], + ); + + // Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en + // de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen. + double mx(double a, double b) => a > b ? a : b; + final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo; + final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom'); + final logoOnLeft = profile.logoPosition.endsWith('left'); + final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; + final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; + final tlpOnRight = profile.logoPosition != 'bottom-right'; + final tlpSpan = tlp == TlpLevel.none + ? 0.0 + : w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; + final footerLeftAligned = profile.footerPosition == 'left'; + + // Links uitgelijnd begint de footer waar het logo of de bullets beginnen, + // voor een consistente linkermarge. Anders de standaardmarge. + var left = footerLeftAligned + ? (logoBottom && logoOnLeft + ? logoLeftEdge + : _contentLeftInset(slide, w)) + : w * 0.04; + var right = w * 0.04; + if (logoBottom) { + if (logoOnLeft) { + // Een links-uitgelijnde footer mag bewust met de logo-linkerkant + // uitlijnen; anders schuift 'ie rechts van het logo om overlap te + // voorkomen. + if (!footerLeftAligned) left = mx(left, logoSpan); + } else { + right = mx(right, logoSpan); + } + } + if (tlp != TlpLevel.none) { + if (tlpOnRight) { + right = mx(right, tlpSpan); + } else { + left = mx(left, tlpSpan); + } + } + + final alignment = switch (profile.footerPosition) { + 'left' => Alignment.centerLeft, + 'center' => Alignment.center, + _ => Alignment.centerRight, + }; + final textAlign = switch (profile.footerPosition) { + 'left' => TextAlign.left, + 'center' => TextAlign.center, + _ => TextAlign.right, + }; + + return Positioned( + left: left, + right: right, + bottom: w * 0.02, + child: Align( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: w - left - right), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (footerText.isNotEmpty) + Flexible( + child: Text( + footerText, + style: style, + textAlign: textAlign, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02), + if (showPages) + Text( + '$slideNumber / ${slideCount ?? slideNumber}', + style: style, + ), + ], + ), + ), + ), + ); + } +} + +Widget _mediaPlaceholder(IconData icon, String label) { + return Container( + color: const Color(0xFFE2E8F0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF94A3B8), size: 32), + const SizedBox(height: 6), + Text( + label, + style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), + ), + ], + ), + ), + ); +} + +Widget _imagePlaceholder() { + return Container( + color: const Color(0xFFE2E8F0), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24), + SizedBox(height: 4), + Text( + 'Afbeelding', + style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/slides/slide_thumbnail.dart b/lib/widgets/slides/slide_thumbnail.dart new file mode 100644 index 0000000..eaf23b4 --- /dev/null +++ b/lib/widgets/slides/slide_thumbnail.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/deck.dart'; +import '../../models/settings.dart'; +import '../../models/slide.dart'; +import '../../state/slide_clipboard_provider.dart'; +import '../../theme/app_theme.dart'; +import 'slide_preview.dart'; + +class SlideThumbnail extends ConsumerWidget { + final Slide slide; + final int index; + final bool isSelected; + + /// De actieve slide binnen een meervoudige selectie (de slide die in de + /// editor wordt getoond). Krijgt een iets sterkere markering. + final bool isPrimary; + final String? projectPath; + final ThemeProfile themeProfile; + final int slideCount; + final TlpLevel tlp; + final VoidCallback onTap; + final VoidCallback onDuplicate; + final VoidCallback onDelete; + final VoidCallback onToggleSkip; + final VoidCallback onCopyImage; + + const SlideThumbnail({ + super.key, + required this.slide, + required this.index, + required this.isSelected, + required this.onTap, + required this.onDuplicate, + required this.onDelete, + required this.onToggleSkip, + required this.onCopyImage, + this.isPrimary = true, + this.projectPath, + this.themeProfile = const ThemeProfile(), + this.slideCount = 1, + this.tlp = TlpLevel.none, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final skipped = slide.skipped; + final borderColor = isSelected + ? AppTheme.accent + : skipped + ? const Color(0xFF8A6D3B) + : const Color(0xFF3A3F4B); + // Actieve slide krijgt een dikkere rand dan de overige geselecteerde. + final borderWidth = isSelected ? (isPrimary ? 2.5 : 1.6) : 1.0; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + border: Border.all(color: borderColor, width: borderWidth), + color: isSelected ? const Color(0xFF2A2F3B) : const Color(0xFF252830), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Mini slide preview + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(5), + ), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + // Overgeslagen slides worden gedimd weergegeven. + Opacity( + opacity: skipped ? 0.32 : 1, + child: SlidePreviewWidget( + slide: slide, + projectPath: projectPath, + themeProfile: themeProfile, + slideNumber: index + 1, + slideCount: slideCount, + tlp: tlp, + ), + ), + if (skipped) + Positioned( + top: 4, + left: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xCC8A6D3B), + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.visibility_off_outlined, + size: 10, + color: Colors.white, + ), + SizedBox(width: 3), + Text( + 'Overgeslagen', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + // Footer: slide number, type label, action buttons + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: isSelected + ? AppTheme.accent + : const Color(0xFF4A4F5B), + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + slide.type.label, + style: const TextStyle( + color: Color(0xFF94A3B8), + fontSize: 9, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Drag handle + ReorderableDragStartListener( + index: index, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon( + Icons.drag_handle, + size: 14, + color: Color(0xFF64748B), + ), + ), + ), + // Snelle overslaan-toggle + SizedBox( + width: 20, + height: 20, + child: IconButton( + padding: EdgeInsets.zero, + iconSize: 14, + splashRadius: 12, + tooltip: skipped + ? 'Weer tonen bij presenteren/exporteren' + : 'Overslaan bij presenteren/exporteren', + icon: Icon( + skipped + ? Icons.visibility_off + : Icons.visibility_outlined, + color: skipped + ? const Color(0xFFD4A24E) + : const Color(0xFF64748B), + ), + onPressed: onToggleSkip, + ), + ), + // Context menu + SizedBox( + width: 20, + height: 20, + child: PopupMenuButton( + icon: const Icon( + Icons.more_vert, + color: Color(0xFF64748B), + size: 14, + ), + padding: EdgeInsets.zero, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'copy', + child: Text('Kopiëren'), + ), + const PopupMenuItem( + value: 'copy_image', + child: Text('Kopieer als afbeelding'), + ), + const PopupMenuItem( + value: 'duplicate', + child: Text('Dupliceren'), + ), + PopupMenuItem( + value: 'skip', + child: Text( + skipped ? 'Niet meer overslaan' : 'Overslaan', + ), + ), + const PopupMenuItem( + value: 'delete', + child: Text( + 'Verwijderen', + style: TextStyle(color: Colors.red), + ), + ), + ], + onSelected: (v) { + if (v == 'copy') { + ref.read(slideClipboardProvider.notifier).state = + slide; + } + if (v == 'copy_image') onCopyImage(); + if (v == 'duplicate') onDuplicate(); + if (v == 'skip') onToggleSkip(); + if (v == 'delete') onDelete(); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..c857ae5 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ocideck") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.ocideck") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..144271d --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,31 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_drop_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); + desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); + g_autoptr(FlPluginRegistrar) pasteboard_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); + pasteboard_plugin_register_with_registrar(pasteboard_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..d05f858 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop + pasteboard + screen_retriever_linux + url_launcher_linux + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..59800a0 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + 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, "ocideck"); + 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, "ocideck"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..76d87d9 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_drop +import file_picker +import pasteboard +import screen_retriever_macos +import shared_preferences_foundation +import url_launcher_macos +import video_player_avfoundation +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..1b2ca93 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - desktop_drop (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - screen_retriever_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + +EXTERNAL SOURCES: + desktop_drop: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos + FlutterMacOS: + :path: Flutter/ephemeral + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + +SPEC CHECKSUMS: + desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..25d35e0 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 0A59A7182891F597DD1C0FA8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C69EC388F1CD5A128E7727C /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + A3AF433C0076BBEB53A9E7A4 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A5B1DA8E87E24DF586F1EC4 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1C2BCDD902FE13538FA27319 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* ocideck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ocideck.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3C69EC388F1CD5A128E7727C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 52F1BF0C6D5709D68F13B452 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 57C660DAAD9AD378AF7A50CF /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5A91124CE817558E748A6A4C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 5CD208DCCC019C2511D53E78 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8A5B1DA8E87E24DF586F1EC4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + FA6C605EC7EE8E39B81CE437 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A3AF433C0076BBEB53A9E7A4 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 0A59A7182891F597DD1C0FA8 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 8466EEE5B6C15640EF2F0C7F /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* ocideck.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 8466EEE5B6C15640EF2F0C7F /* Pods */ = { + isa = PBXGroup; + children = ( + 52F1BF0C6D5709D68F13B452 /* Pods-Runner.debug.xcconfig */, + 1C2BCDD902FE13538FA27319 /* Pods-Runner.release.xcconfig */, + 57C660DAAD9AD378AF7A50CF /* Pods-Runner.profile.xcconfig */, + 5CD208DCCC019C2511D53E78 /* Pods-RunnerTests.debug.xcconfig */, + FA6C605EC7EE8E39B81CE437 /* Pods-RunnerTests.release.xcconfig */, + 5A91124CE817558E748A6A4C /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3C69EC388F1CD5A128E7727C /* Pods_Runner.framework */, + 8A5B1DA8E87E24DF586F1EC4 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 9AB25AE1AE40D9B2A8411082 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 17251CA9D70D8B16E5E4ABE0 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 7F5BC1856D7011856DE30C6A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* ocideck.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 17251CA9D70D8B16E5E4ABE0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 7F5BC1856D7011856DE30C6A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9AB25AE1AE40D9B2A8411082 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5CD208DCCC019C2511D53E78 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ocideck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ocideck"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FA6C605EC7EE8E39B81CE437 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ocideck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ocideck"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5A91124CE817558E748A6A4C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ocideck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ocideck"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3110a3 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..879f1f1 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..f259e62 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..3e39eec Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..38b1387 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..358c7a1 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..60b205f Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..f459ae9 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..0df85ba --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d05d24a --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = ocideck + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.ocideck + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..b1d5889 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4dcecfc --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + FLTEnableMergedPlatformUIThread + + FLTEnableImpeller + + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..bdf7f8c --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..fb79c33 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1042 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c + url: "https://pub.dev" + source: hosted + version: "99.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" + url: "https://pub.dev" + source: hosted + version: "12.1.0" + archive: + dependency: "direct main" + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: "direct main" + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + desktop_drop: + dependency: "direct main" + description: + name: desktop_drop + sha256: "03abf1c0443afdd1d65cf8fa589a2f01c67a11da56bbb06f6ea1de79d5628e94" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: "direct dev" + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pasteboard: + dependency: "direct main" + description: + name: pasteboard + sha256: fedbe8da188d2f713aa8b01260737342e6e1087534a3ab26e1a719f8d3e8f32f + url: "https://pub.dev" + source: hosted + version: "0.5.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "5d18d04084cc0cfc7afde39d0a308d4041e8ae6e9d5255bc086c263998dd1201" + url: "https://pub.dev" + source: hosted + version: "2.9.6" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58" + url: "https://pub.dev" + source: hosted + version: "2.9.7" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "16eaed5268c571c31840dc58ef8da5f0cd4db2a98490c3b8f1cf70122546c6e0" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct dev" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d8adf10 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,40 @@ +name: ocideck +description: "Marp Presentation Builder for Desktop" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.12.0 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + flutter_riverpod: ^3.3.1 + file_picker: ^11.0.2 + path_provider: ^2.1.5 + path: ^1.9.1 + uuid: ^4.5.1 + window_manager: ^0.5.1 + shared_preferences: ^2.3.3 + google_fonts: ^8.1.0 + pasteboard: ^0.5.0 + pdf: ^3.12.0 + archive: ^4.0.9 + video_player: ^2.11.1 + characters: ^1.3.0 + url_launcher: ^6.3.0 + desktop_drop: ^0.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + image: ^4.8.0 + xml: ^6.6.1 + +flutter: + uses-material-design: true + assets: + - assets/images/de-winter-wittegeheel.png + - assets/themes/ocideck.css diff --git a/test/bullets_image_preview_test.dart b/test/bullets_image_preview_test.dart new file mode 100644 index 0000000..0eec4f0 --- /dev/null +++ b/test/bullets_image_preview_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Future _writeRedPng(String dir) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 8, 8), + Paint()..color = const Color(0xFFFF0000), + ); + final picture = recorder.endRecording(); + final img = await picture.toImage(8, 8); + final data = await img.toByteData(format: ui.ImageByteFormat.png); + final file = File('$dir/red.png'); + file.writeAsBytesSync(data!.buffer.asUint8List()); + return file; +} + +void main() { + testWidgets('bulletsImage paints the image on the right panel', ( + tester, + ) async { + await tester.runAsync(() async { + final dir = Directory.systemTemp.createTempSync('ocideck_test'); + final redPng = await _writeRedPng(dir.path); + + final slide = Slide( + id: 'x', + type: SlideType.bulletsImage, + title: 'Een titel', + bullets: const [ + 'Eerste bullet met wat tekst', + 'Tweede bullet', + 'Derde bullet met veel tekst zodat de hoogte flink wordt gevuld', + 'Vierde bullet', + 'Vijfde bullet', + ], + imagePath: redPng.path, + imageSize: 40, + ); + + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget(slide: slide), + ), + ), + ), + ), + ), + ); + + // Let the file image decode and paint. + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + final boundary = + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1.0); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + + ({int r, int g, int b}) pixelAt(int x, int y) { + final i = (y * image.width + x) * 4; + return (r: bytes[i], g: bytes[i + 1], b: bytes[i + 2]); + } + + // Right 40% panel must be the (red) image; left panel the white text bg. + final right = pixelAt((image.width * 0.95).round(), image.height ~/ 2); + final left = pixelAt((image.width * 0.05).round(), image.height ~/ 2); + + expect( + right.r > 200 && right.g < 80 && right.b < 80, + isTrue, + reason: 'Image panel should be red but was $right', + ); + expect( + left.r, + greaterThan(200), + reason: 'Text panel background should be white', + ); + + dir.deleteSync(recursive: true); + }); + }); +} diff --git a/test/caption_service_test.dart b/test/caption_service_test.dart new file mode 100644 index 0000000..77d6efe --- /dev/null +++ b/test/caption_service_test.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/services/caption_service.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + final service = CaptionService(); + + setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_cap')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('saves and reads back a caption via the sidecar file', () async { + final img = p.join(tmp.path, 'photo.png'); + await service.saveCaption(img, 'Een onderschrift'); + expect(await service.getCaption(img), 'Een onderschrift'); + }); + + test('returns null when there is no caption', () async { + expect(await service.getCaption(p.join(tmp.path, 'none.png')), isNull); + }); + + test('saving an empty caption removes it', () async { + final img = p.join(tmp.path, 'photo.png'); + await service.saveCaption(img, 'Tekst'); + await service.saveCaption(img, ' '); + expect(await service.getCaption(img), isNull); + }); + + test('captions are stored per image basename', () async { + final a = p.join(tmp.path, 'a.png'); + final b = p.join(tmp.path, 'b.png'); + await service.saveCaption(a, 'Onderschrift A'); + await service.saveCaption(b, 'Onderschrift B'); + expect(await service.getCaption(a), 'Onderschrift A'); + expect(await service.getCaption(b), 'Onderschrift B'); + }); + + test('copyCaption duplicates a caption to another image', () async { + final a = p.join(tmp.path, 'a.png'); + final b = p.join(tmp.path, 'b.png'); + await service.saveCaption(a, 'Gedeeld'); + await service.copyCaption(a, b); + expect(await service.getCaption(b), 'Gedeeld'); + }); +} diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart new file mode 100644 index 0000000..8e6f8d1 --- /dev/null +++ b/test/deck_provider_test.dart @@ -0,0 +1,337 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/file_service.dart'; +import 'package:ocideck/services/image_service.dart'; +import 'package:ocideck/services/markdown_service.dart'; +import 'package:ocideck/state/deck_provider.dart'; + +DeckNotifier _notifier() { + final md = MarkdownService(); + final file = FileService(md, ImageService(), () => const ThemeProfile()); + return DeckNotifier(md, file); +} + +void main() { + test('newDeck creates one title slide and is dirty', () { + final n = _notifier(); + n.newDeck('Mijn deck'); + expect(n.state.isOpen, isTrue); + expect(n.state.deck!.slides, hasLength(1)); + expect(n.state.deck!.slides.single.type, SlideType.title); + expect(n.state.deck!.slides.single.title, 'Mijn deck'); + expect(n.state.isDirty, isTrue); + }); + + test('addSlide inserts right after the given index', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); // appended -> index 1 + n.addSlide(SlideType.image, afterIndex: 0); // inserted at index 1 + final types = n.state.deck!.slides.map((s) => s.type).toList(); + expect(types, [SlideType.title, SlideType.image, SlideType.bullets]); + }); + + test('removeSlide removes a slide but never the last one', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + n.removeSlide(0); + expect(n.state.deck!.slides, hasLength(1)); + // Now only one remains; removing again is a no-op. + n.removeSlide(0); + expect(n.state.deck!.slides, hasLength(1)); + }); + + test('duplicateSlide inserts a copy with a fresh id', () { + final n = _notifier()..newDeck('D'); + final originalId = n.state.deck!.slides.first.id; + n.duplicateSlide(0); + final slides = n.state.deck!.slides; + expect(slides, hasLength(2)); + expect(slides[1].title, slides[0].title); + expect(slides[1].id, isNot(originalId)); + }); + + test('insertSlides duplicates with fresh ids and returns insert index', () { + final n = _notifier()..newDeck('D'); + final source = Slide.create(SlideType.bullets).copyWith(title: 'Bron'); + final at = n.insertSlides([source], afterIndex: 0); + expect(at, 1); + expect(n.state.deck!.slides, hasLength(2)); + expect(n.state.deck!.slides[1].title, 'Bron'); + expect(n.state.deck!.slides[1].id, isNot(source.id)); + }); + + test('reorderSlides moves a slide to a new position', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); // 1 + n.addSlide(SlideType.image); // 2 + n.reorderSlides(2, 0); + expect(n.state.deck!.slides.first.type, SlideType.image); + }); + + test('updateSlide replaces the slide at an index', () { + final n = _notifier()..newDeck('D'); + final updated = n.state.deck!.slides.first.copyWith(title: 'Nieuw'); + n.updateSlide(0, updated); + expect(n.state.deck!.slides.first.title, 'Nieuw'); + }); + + test('updateMeta changes deck title/theme/paginate', () { + final n = _notifier()..newDeck('D'); + n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false); + expect(n.state.deck!.title, 'Andere titel'); + expect(n.state.deck!.theme, 'custom'); + expect(n.state.deck!.paginate, isFalse); + }); + + test('generateMarkdown and applyMarkdown round-trip the deck', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bulletsImage, afterIndex: 0); + n.updateSlide( + 1, + n.state.deck!.slides[1].copyWith( + title: 'Met beeld', + bullets: ['Punt'], + imagePath: 'images/x.png', + ), + ); + + final markdown = n.generateMarkdown(); + expect(n.applyMarkdown(markdown), isTrue); + + final slides = n.state.deck!.slides; + expect(slides, hasLength(2)); + expect(slides[1].type, SlideType.bulletsImage); + expect(slides[1].imagePath, 'images/x.png'); + }); + + test('slide operations are no-ops when no deck is open', () { + final n = _notifier(); + n.addSlide(SlideType.bullets); + n.removeSlide(0); + n.duplicateSlide(0); + expect(n.state.isOpen, isFalse); + }); + + // ── Ongedaan maken / opnieuw uitvoeren ───────────────────────────────────── + + test('a fresh deck has nothing to undo or redo', () { + final n = _notifier()..newDeck('D'); + expect(n.canUndo, isFalse); + expect(n.canRedo, isFalse); + expect(n.state.canUndo, isFalse); + expect(n.state.canRedo, isFalse); + }); + + test('undo reverts the last structural change and redo re-applies it', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + expect(n.state.deck!.slides, hasLength(2)); + expect(n.canUndo, isTrue); + + n.undo(); + expect(n.state.deck!.slides, hasLength(1)); + expect(n.canUndo, isFalse); + expect(n.canRedo, isTrue); + + n.redo(); + expect(n.state.deck!.slides, hasLength(2)); + expect(n.canRedo, isFalse); + }); + + test('undo/redo bumps revision so editors can re-sync', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + final rev0 = n.state.revision; + n.undo(); + expect(n.state.revision, rev0 + 1); + n.redo(); + expect(n.state.revision, rev0 + 2); + }); + + test('rapid edits to the same slide coalesce into one undo step', () { + final n = _notifier()..newDeck('D'); + // Three quick edits to slide 0 within the coalesce window. + n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'A')); + n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'AB')); + n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'ABC')); + expect(n.state.deck!.slides.first.title, 'ABC'); + + // A single undo jumps back past the whole burst to the original title. + n.undo(); + expect(n.state.deck!.slides.first.title, 'D'); + expect(n.canUndo, isFalse); + }); + + test('a new edit clears the redo stack', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + n.undo(); + expect(n.canRedo, isTrue); + n.addSlide(SlideType.image); // diverging edit + expect(n.canRedo, isFalse); + }); + + test('undo and redo are no-ops when their stacks are empty', () { + final n = _notifier()..newDeck('D'); + n.undo(); // nothing to undo + expect(n.state.deck!.slides, hasLength(1)); + n.redo(); // nothing to redo + expect(n.state.deck!.slides, hasLength(1)); + }); + + test('loading a deck clears the history', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + expect(n.canUndo, isTrue); + n.loadDeck(n.state.deck!); + expect(n.canUndo, isFalse); + expect(n.canRedo, isFalse); + }); + + // ── Slides overslaan ─────────────────────────────────────────────────────── + + test('toggleSkip flips a single slide and reports the count', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + expect(n.skippedCount, 0); + + n.toggleSkip(1); + expect(n.state.deck!.slides[1].skipped, isTrue); + expect(n.state.deck!.slides[0].skipped, isFalse); + expect(n.skippedCount, 1); + + n.toggleSkip(1); + expect(n.state.deck!.slides[1].skipped, isFalse); + expect(n.skippedCount, 0); + }); + + test('toggleSkip is undoable', () { + final n = _notifier()..newDeck('D'); + n.toggleSkip(0); + expect(n.state.deck!.slides.first.skipped, isTrue); + n.undo(); + expect(n.state.deck!.slides.first.skipped, isFalse); + }); + + test('clearAllSkips resets every slide in one step', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + n.addSlide(SlideType.image); + n.toggleSkip(0); + n.toggleSkip(2); + expect(n.skippedCount, 2); + + n.clearAllSkips(); + expect(n.skippedCount, 0); + + // One undo brings back the whole skipped set. + n.undo(); + expect(n.skippedCount, 2); + }); + + test('clearAllSkips is a no-op when nothing is skipped', () { + final n = _notifier()..newDeck('D'); + final before = n.canUndo; + n.clearAllSkips(); + expect(n.canUndo, before); // geen nieuwe ongedaan-maken-stap + }); + + // ── Bulk-acties (multi-select) ───────────────────────────────────────────── + + test('removeSlides deletes several at once but keeps at least one', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); // 1 + n.addSlide(SlideType.image); // 2 + n.addSlide(SlideType.quote); // 3 → 4 slides total + n.removeSlides({1, 3}); + final types = n.state.deck!.slides.map((s) => s.type).toList(); + expect(types, [SlideType.title, SlideType.image]); + + // Mag nooit alles verwijderen. + n.removeSlides({0, 1}); + expect(n.state.deck!.slides, hasLength(2)); + }); + + test('removeSlides is one undoable step', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + n.addSlide(SlideType.image); + n.removeSlides({1, 2}); + expect(n.state.deck!.slides, hasLength(1)); + n.undo(); + expect(n.state.deck!.slides, hasLength(3)); + }); + + test('setSkippedForSlides toggles skip on a set in one step', () { + final n = _notifier()..newDeck('D'); + n.addSlide(SlideType.bullets); + n.addSlide(SlideType.image); + n.setSkippedForSlides({0, 2}, true); + expect(n.state.deck!.slides[0].skipped, isTrue); + expect(n.state.deck!.slides[1].skipped, isFalse); + expect(n.state.deck!.slides[2].skipped, isTrue); + expect(n.skippedCount, 2); + + n.setSkippedForSlides({0, 2}, false); + expect(n.skippedCount, 0); + }); + + // ── Zoeken & vervangen ───────────────────────────────────────────────────── + + test('countMatches and replaceAll span all text fields', () { + final n = _notifier()..newDeck('Acme jaarverslag'); + n.updateSlide( + 0, + n.state.deck!.slides.first.copyWith( + title: 'Acme groeit', + bullets: ['Acme wint', 'overig'], + notes: 'Acme intern', + ), + ); + + expect(n.countMatches('Acme'), 3); + + final replaced = n.replaceAll('Acme', 'Globex'); + expect(replaced, 3); + expect(n.countMatches('Acme'), 0); + + final slide = n.state.deck!.slides.first; + expect(slide.title, 'Globex groeit'); + expect(slide.bullets.first, 'Globex wint'); + expect(slide.notes, 'Globex intern'); + }); + + test('replaceAll is case-insensitive by default and exact when asked', () { + final n = _notifier()..newDeck('D'); + n.updateSlide( + 0, + n.state.deck!.slides.first.copyWith(title: 'Test test TEST'), + ); + + // Default: alle drie, ongeacht hoofdletters. + expect(n.countMatches('test'), 3); + // Hoofdlettergevoelig: alleen de exacte 'test'. + expect(n.countMatches('test', caseSensitive: true), 1); + + n.replaceAll('test', 'x', caseSensitive: true); + expect(n.state.deck!.slides.first.title, 'Test x TEST'); + }); + + test('replaceAll is a single undoable step and a no-op without matches', () { + final n = _notifier()..newDeck('D'); + n.updateSlide( + 0, + n.state.deck!.slides.first.copyWith(title: 'Hallo wereld'), + ); + final undosBefore = n.canUndo; + + expect(n.replaceAll('xyz', 'q'), 0); // geen match → niets verandert + expect(n.canUndo, undosBefore); + + n.replaceAll('wereld', 'aarde'); + expect(n.state.deck!.slides.first.title, 'Hallo aarde'); + n.undo(); // één stap terug herstelt de hele vervanging + expect(n.state.deck!.slides.first.title, 'Hallo wereld'); + }); +} diff --git a/test/description_service_test.dart b/test/description_service_test.dart new file mode 100644 index 0000000..e5a22fb --- /dev/null +++ b/test/description_service_test.dart @@ -0,0 +1,45 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/services/description_service.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + final service = DescriptionService(); + + setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_desc')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('saves and reads back a description', () async { + final img = p.join(tmp.path, 'photo.png'); + await service.saveDescription(img, 'Zonsondergang aan zee'); + expect(await service.getDescription(img), 'Zonsondergang aan zee'); + }); + + test('returns null when there is no description', () async { + expect(await service.getDescription(p.join(tmp.path, 'none.png')), isNull); + }); + + test('removing a description deletes the entry', () async { + final img = p.join(tmp.path, 'photo.png'); + await service.saveDescription(img, 'Tekst'); + await service.removeDescription(img); + expect(await service.getDescription(img), isNull); + }); + + test( + 'loadFor returns all descriptions in the relevant directories', + () async { + final a = p.join(tmp.path, 'a.png'); + final b = p.join(tmp.path, 'b.png'); + await service.saveDescription(a, 'Berg'); + await service.saveDescription(b, 'Rivier'); + + final all = await service.loadFor([a, b, p.join(tmp.path, 'c.png')]); + expect(all[a], 'Berg'); + expect(all[b], 'Rivier'); + expect(all.containsKey(p.join(tmp.path, 'c.png')), isFalse); + }, + ); +} diff --git a/test/editor_selection_test.dart b/test/editor_selection_test.dart new file mode 100644 index 0000000..86c5f3d --- /dev/null +++ b/test/editor_selection_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/state/editor_provider.dart'; + +void main() { + group('EditorNotifier multi-selection', () { + test('select is single and clears any multi-selection', () { + final n = EditorNotifier(); + n.selectAll(5); + n.select(2); + expect(n.currentState.selectedIndex, 2); + expect(n.currentState.selection, {2}); + expect(n.currentState.hasMultiSelection, isFalse); + }); + + test('toggleSelect adds and removes, keeping a non-empty set', () { + final n = EditorNotifier()..select(1); + n.toggleSelect(3); + expect(n.currentState.selection, {1, 3}); + expect(n.currentState.selectedIndex, 3); // clicked becomes active + + n.toggleSelect(3); // remove again + expect(n.currentState.selection, {1}); + expect(n.currentState.selectedIndex, 1); + }); + + test('toggleSelect never empties the selection', () { + final n = EditorNotifier()..select(2); + n.toggleSelect(2); // would remove the only one + expect(n.currentState.selection, {2}); + }); + + test('selectRange selects an inclusive range from the anchor', () { + final n = EditorNotifier()..select(2); + n.selectRange(5); + expect(n.currentState.selection, {2, 3, 4, 5}); + expect(n.currentState.selectedIndex, 5); + }); + + test('selectRange works backwards too', () { + final n = EditorNotifier()..select(5); + n.selectRange(2); + expect(n.currentState.selection, {2, 3, 4, 5}); + expect(n.currentState.selectedIndex, 2); + }); + + test('selectAll selects every slide', () { + final n = EditorNotifier()..select(1); + n.selectAll(4); + expect(n.currentState.selection, {0, 1, 2, 3}); + }); + + test('clampIndex prunes selection beyond the new max', () { + final n = EditorNotifier()..selectAll(6); // {0..5} + n.clampIndex(3); + expect(n.currentState.selection, {0, 1, 2, 3}); + expect(n.currentState.selectedIndex, lessThanOrEqualTo(3)); + }); + }); +} diff --git a/test/export_service_test.dart b/test/export_service_test.dart new file mode 100644 index 0000000..3f73458 --- /dev/null +++ b/test/export_service_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:ocideck/services/export_service.dart'; +import 'package:path/path.dart' as p; +import 'package:xml/xml.dart'; + +Uint8List _png() { + final image = img.Image(width: 320, height: 180); + img.fill(image, color: img.ColorRgb8(30, 40, 60)); + return Uint8List.fromList(img.encodePng(image)); +} + +void main() { + late Directory tmp; + late ExportService service; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('ocideck_export'); + service = ExportService(); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + String deckPath() => p.join(tmp.path, 'deck.md'); + + test('exports a PDF that starts with the PDF magic header', () async { + final images = [_png(), _png()]; + final r = await service.export(deckPath(), ExportFormat.pdf, images); + + expect(r.success, isTrue, reason: r.error); + final bytes = await File(r.outputPath!).readAsBytes(); + expect(String.fromCharCodes(bytes.take(4)), '%PDF'); + }); + + test('exports a valid PPTX zip with the expected parts', () async { + final images = [_png(), _png()]; + final r = await service.export(deckPath(), ExportFormat.pptx, images); + + expect(r.success, isTrue, reason: r.error); + expect(p.extension(r.outputPath!), '.pptx'); + + final bytes = await File(r.outputPath!).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + final names = archive.files.map((f) => f.name).toSet(); + + expect(names, contains('[Content_Types].xml')); + expect(names, contains('_rels/.rels')); + expect(names, contains('ppt/presentation.xml')); + expect(names, contains('ppt/slideMasters/slideMaster1.xml')); + expect(names, contains('ppt/slideLayouts/slideLayout1.xml')); + expect(names, contains('ppt/theme/theme1.xml')); + expect(names, contains('ppt/slides/slide1.xml')); + expect(names, contains('ppt/slides/slide2.xml')); + expect(names, contains('ppt/media/image1.png')); + expect(names, contains('ppt/media/image2.png')); + + // Every XML part must be well-formed. + for (final file in archive.files) { + if (file.name.endsWith('.xml') || file.name.endsWith('.rels')) { + final content = utf8.decode(file.content as List); + expect( + () => XmlDocument.parse(content), + returnsNormally, + reason: '${file.name} is not well-formed XML', + ); + } + } + }); + + test('fails gracefully when there are no slides', () async { + final r = await service.export(deckPath(), ExportFormat.pdf, const []); + expect(r.success, isFalse); + }); +} diff --git a/test/file_service_test.dart b/test/file_service_test.dart new file mode 100644 index 0000000..2b55f89 --- /dev/null +++ b/test/file_service_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/file_service.dart'; +import 'package:ocideck/services/image_service.dart'; +import 'package:ocideck/services/markdown_service.dart'; +import 'package:path/path.dart' as p; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('saveDeck copies logo into project logos directory', () async { + final temp = await Directory.systemTemp.createTemp('ocideck_logo_test_'); + addTearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + + final sourceLogo = File(p.join(temp.path, 'client.png')); + await sourceLogo.writeAsBytes([1, 2, 3]); + + final service = FileService( + MarkdownService(), + ImageService(), + () => ThemeProfile(logoPath: sourceLogo.path), + ); + final deck = Deck( + title: 'Logo test', + themeProfile: ThemeProfile(logoPath: sourceLogo.path), + slides: [Slide.create(SlideType.title).copyWith(title: 'Logo test')], + ); + + final saved = await service.saveDeck(deck, p.join(temp.path, 'deck.md')); + + expect(saved.themeProfile.logoPath, 'logos/client.png'); + expect(await File(p.join(temp.path, 'logos', 'client.png')).exists(), true); + }); +} diff --git a/test/footer_preview_test.dart b/test/footer_preview_test.dart new file mode 100644 index 0000000..03e2355 --- /dev/null +++ b/test/footer_preview_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Widget _host(Slide slide, ThemeProfile profile, {int? number, int? count}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget( + slide: slide, + themeProfile: profile, + slideNumber: number, + slideCount: count, + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('footer renders text tokens and page numbers', (tester) async { + const profile = ThemeProfile( + footerText: 'Vertrouwelijk · {page}/{total}', + footerShowPageNumbers: true, + ); + await tester.pumpWidget( + _host( + Slide.create(SlideType.bullets).copyWith(title: 'T', bullets: ['a']), + profile, + number: 2, + count: 5, + ), + ); + await tester.pump(); + + expect(find.text('Vertrouwelijk · 2/5'), findsOneWidget); + expect(find.text('2 / 5'), findsOneWidget); // page-number block + }); + + testWidgets('footer can be hidden on an individual slide', (tester) async { + const profile = ThemeProfile( + footerText: 'Vertrouwelijk', + footerShowPageNumbers: true, + ); + await tester.pumpWidget( + _host( + Slide.create( + SlideType.bullets, + ).copyWith(title: 'T', bullets: ['a'], showFooter: false), + profile, + number: 2, + count: 5, + ), + ); + await tester.pump(); + + expect(find.text('Vertrouwelijk'), findsNothing); + expect(find.text('2 / 5'), findsNothing); + }); + + testWidgets('footer position can be left center or right', (tester) async { + Future footerLeft(String position) async { + await tester.pumpWidget( + _host( + Slide.create(SlideType.bullets).copyWith(title: 'T', bullets: ['a']), + ThemeProfile(footerText: 'Voettekst', footerPosition: position), + number: 1, + count: 3, + ), + ); + await tester.pump(); + return tester.getTopLeft(find.text('Voettekst')).dx; + } + + final left = await footerLeft('left'); + final center = await footerLeft('center'); + final right = await footerLeft('right'); + + expect(left, lessThan(center)); + expect(center, lessThan(right)); + }); + + testWidgets('left footer aligns with the bullet content margin', ( + tester, + ) async { + await tester.pumpWidget( + _host( + Slide.create(SlideType.bullets).copyWith(title: 'T', bullets: ['a']), + const ThemeProfile(footerText: 'Voettekst', footerPosition: 'left'), + number: 1, + count: 3, + ), + ); + await tester.pump(); + + // Bullets beginnen op w*0.07 (w=800 → 56px); de footer lijnt daarmee uit. + final footerX = tester.getTopLeft(find.text('Voettekst')).dx; + expect(footerX, closeTo(800 * 0.07, 2)); + }); + + testWidgets('footer is hidden on title slides', (tester) async { + const profile = ThemeProfile( + footerText: 'Altijd zichtbaar', + footerShowPageNumbers: true, + ); + await tester.pumpWidget( + _host( + Slide.create(SlideType.title).copyWith(title: 'Welkom'), + profile, + number: 1, + count: 4, + ), + ); + await tester.pump(); + + expect(find.text('Altijd zichtbaar'), findsNothing); + }); + + testWidgets('no footer when profile has none configured', (tester) async { + const profile = ThemeProfile(); + await tester.pumpWidget( + _host( + Slide.create(SlideType.bullets).copyWith(title: 'T', bullets: ['a']), + profile, + number: 1, + count: 3, + ), + ); + await tester.pump(); + + expect(find.text('1 / 3'), findsNothing); + }); +} diff --git a/test/fullscreen_presenter_test.dart b/test/fullscreen_presenter_test.dart new file mode 100644 index 0000000..f2a45eb --- /dev/null +++ b/test/fullscreen_presenter_test.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/presentation/fullscreen_presenter.dart'; + +Widget _host(List slides) { + return MaterialApp( + home: FullscreenPresenter( + slides: slides, + projectPath: null, + themeProfile: const ThemeProfile(), + initialIndex: 0, + ), + ); +} + +void main() { + final slides = [ + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Eerste', bullets: ['a'], notes: 'Mijn spiekbriefje'), + Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']), + ]; + + testWidgets('starts in audience view without presenter chrome', ( + tester, + ) async { + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + expect(find.text('1 / 2'), findsOneWidget); // slide counter + expect(find.text('NOTITIES'), findsNothing); // presenter-only + expect(find.text('VOLGENDE'), findsNothing); + + await tester.pumpWidget(const SizedBox()); // dispose → cancel clock timer + }); + + testWidgets('P toggles presenter view with notes and next slide', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyP); + await tester.pump(); + + expect(find.text('NOTITIES'), findsOneWidget); + expect(find.text('Mijn spiekbriefje'), findsOneWidget); + expect(find.text('VOLGENDE'), findsOneWidget); + expect(find.text('Slide 1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('advancing updates the notes shown in presenter view', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyP); + await tester.pump(); + expect(find.text('Mijn spiekbriefje'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + // Slide 2 has no notes → placeholder, and we're on its end-of-deck next. + expect(find.text('Geen notities voor deze slide.'), findsOneWidget); + expect(find.text('Einde van de presentatie'), findsOneWidget); + expect(find.text('Slide 2 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('B blanks the audience screen and toggles back', (tester) async { + await tester.pumpWidget(_host(slides)); + await tester.pump(); + expect(find.text('1 / 2'), findsOneWidget); + + // Blank to black: the slide (and its counter) disappears. + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.pump(); + expect(find.text('1 / 2'), findsNothing); + + // Same key restores the slide. + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.pump(); + expect(find.text('1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a blanked screen resumes on the next navigation press', ( + tester, + ) async { + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.pump(); + expect(find.text('1 / 2'), findsNothing); + + // First arrow un-blanks without advancing (still on slide 1). + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + expect(find.text('1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('G opens the grid and tapping a tile jumps to that slide', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.pump(); + expect(find.text('Slide-overzicht'), findsOneWidget); + + // Tap the tile for slide 2. + await tester.tap(find.text('2')); + await tester.pump(); + + // Grid closed and we jumped to slide 2. + expect(find.text('Slide-overzicht'), findsNothing); + expect(find.text('2 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('End jumps to the last slide and Home back to the first', ( + tester, + ) async { + await tester.pumpWidget(_host(slides)); + await tester.pump(); + expect(find.text('1 / 2'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.end); + await tester.pump(); + expect(find.text('2 / 2'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.home); + await tester.pump(); + expect(find.text('1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('grid arrow keys move a cursor and Enter jumps to it', ( + tester, + ) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.pump(); + expect(find.text('Slide-overzicht'), findsOneWidget); + + // Move the cursor to slide 2 with the arrow key, then choose it. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + + expect(find.text('Slide-overzicht'), findsNothing); + expect(find.text('2 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('typing a number and Enter jumps to that slide', (tester) async { + final three = [ + ...slides, + Slide.create(SlideType.bullets).copyWith(title: 'Derde', bullets: ['c']), + ]; + await tester.pumpWidget(_host(three)); + await tester.pump(); + expect(find.text('1 / 3'), findsOneWidget); + + // Type "3" → a badge appears, Enter jumps to slide 3. + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.pump(); + expect(find.text('3 / 3'), findsOneWidget); // badge " / " + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + // Badge gone, now actually on slide 3 (counter shows it). + expect(find.text('3 / 3'), findsOneWidget); // slide counter now + expect(find.byIcon(Icons.south_east), findsNothing); // badge icon gone + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('? toggles the shortcut cheatsheet', (tester) async { + await tester.pumpWidget(_host(slides)); + await tester.pump(); + expect(find.text('Sneltoetsen'), findsNothing); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyH); + await tester.pump(); + expect(find.text('Sneltoetsen'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.text('Sneltoetsen'), findsNothing); + // Esc closed the help, not the presentation. + expect(find.text('1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Escape closes the grid before it would exit', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(slides)); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.keyG); + await tester.pump(); + expect(find.text('Slide-overzicht'), findsOneWidget); + + // Esc closes the grid and leaves us in the presentation (no exit). + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.text('Slide-overzicht'), findsNothing); + expect(find.text('1 / 2'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/image_service_test.dart b/test/image_service_test.dart new file mode 100644 index 0000000..2555219 --- /dev/null +++ b/test/image_service_test.dart @@ -0,0 +1,124 @@ +import 'dart:typed_data'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/image_service.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + final service = ImageService(); + + setUp(() => tmp = Directory.systemTemp.createTempSync('ocideck_img')); + tearDown(() => tmp.deleteSync(recursive: true)); + + group('resolve', () { + test('returns empty for an empty path', () { + expect(service.resolve('', '/project'), ''); + }); + + test('returns absolute paths unchanged', () { + expect(service.resolve('/abs/pic.png', '/project'), '/abs/pic.png'); + }); + + test('joins relative paths with the project path', () { + expect( + service.resolve('images/pic.png', '/project'), + p.join('/project', 'images/pic.png'), + ); + }); + }); + + group('copyImagesToProject', () { + test( + 'copies an absolute image into images/ and rewrites the path', + () async { + final src = File(p.join(tmp.path, 'photo.png')) + ..writeAsBytesSync([1, 2]); + final project = Directory(p.join(tmp.path, 'project'))..createSync(); + + final out = await service.copyImagesToProject([ + Slide.create(SlideType.image).copyWith(imagePath: src.path), + ], project.path); + + expect(out.single.imagePath, 'images/photo.png'); + expect( + File(p.join(project.path, 'images', 'photo.png')).existsSync(), + isTrue, + ); + }, + ); + + test('leaves already-relative image paths unchanged', () async { + final project = Directory(p.join(tmp.path, 'project'))..createSync(); + final out = await service.copyImagesToProject([ + Slide.create(SlideType.image).copyWith(imagePath: 'images/keep.png'), + ], project.path); + expect(out.single.imagePath, 'images/keep.png'); + }); + + test('leaves the path unchanged when the source file is missing', () async { + final project = Directory(p.join(tmp.path, 'project'))..createSync(); + final missing = p.join(tmp.path, 'does_not_exist.png'); + final out = await service.copyImagesToProject([ + Slide.create(SlideType.image).copyWith(imagePath: missing), + ], project.path); + expect(out.single.imagePath, missing); + }); + + test('copies the second image of a twoImages slide', () async { + final a = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([1]); + final b = File(p.join(tmp.path, 'b.png'))..writeAsBytesSync([2]); + final project = Directory(p.join(tmp.path, 'project'))..createSync(); + + final out = await service.copyImagesToProject([ + Slide.create( + SlideType.twoImages, + ).copyWith(imagePath: a.path, imagePath2: b.path), + ], project.path); + + expect(out.single.imagePath, 'images/a.png'); + expect(out.single.imagePath2, 'images/b.png'); + }); + }); + + group('copyMediaToProject', () { + test( + 'copies absolute video/audio into media/ and rewrites paths', + () async { + final vid = File(p.join(tmp.path, 'clip.mp4'))..writeAsBytesSync([1]); + final aud = File(p.join(tmp.path, 'sound.mp3'))..writeAsBytesSync([2]); + final project = Directory(p.join(tmp.path, 'project'))..createSync(); + + final out = await service.copyMediaToProject([ + Slide.create( + SlideType.video, + ).copyWith(videoPath: vid.path, audioPath: aud.path), + ], project.path); + + expect(out.single.videoPath, 'media/clip.mp4'); + expect(out.single.audioPath, 'media/sound.mp3'); + expect( + File(p.join(project.path, 'media', 'clip.mp4')).existsSync(), + isTrue, + ); + }, + ); + }); + + group('copyImageToClipboard', () { + test('returns false for an empty path', () async { + expect(await service.copyImageToClipboard(''), isFalse); + }); + + test('returns false for a non-existent file', () async { + final missing = p.join(tmp.path, 'nope.png'); + expect(await service.copyImageToClipboard(missing), isFalse); + }); + + test('copyImageBytesToClipboard returns false for empty bytes', () async { + expect(await service.copyImageBytesToClipboard(Uint8List(0)), isFalse); + }); + }); +} diff --git a/test/image_slides_preview_test.dart b/test/image_slides_preview_test.dart new file mode 100644 index 0000000..a2d6790 --- /dev/null +++ b/test/image_slides_preview_test.dart @@ -0,0 +1,197 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +Future _writeSolidPng(String dir, String name, Color color) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect(const Rect.fromLTWH(0, 0, 8, 8), Paint()..color = color); + final picture = recorder.endRecording(); + final img = await picture.toImage(8, 8); + final data = await img.toByteData(format: ui.ImageByteFormat.png); + final file = File('$dir/$name'); + file.writeAsBytesSync(data!.buffer.asUint8List()); + return file; +} + +Future<({int width, int height, Uint8List bytes})> _capture( + WidgetTester tester, + GlobalKey key, + Widget child, +) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: SizedBox(width: 800, height: 450, child: child), + ), + ), + ), + ), + ); + // Let the file images decode and paint before capturing a single frame. + await Future.delayed(const Duration(milliseconds: 300)); + await tester.pump(); + + final boundary = + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1.0); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + return (width: image.width, height: image.height, bytes: bytes); +} + +void main() { + testWidgets('twoImages paints both the left and right images', ( + tester, + ) async { + await tester.runAsync(() async { + final dir = Directory.systemTemp.createTempSync('ocideck_two'); + final red = await _writeSolidPng( + dir.path, + 'red.png', + const Color(0xFFFF0000), + ); + final blue = await _writeSolidPng( + dir.path, + 'blue.png', + const Color(0xFF0000FF), + ); + + final slide = Slide( + id: 'x', + type: SlideType.twoImages, + imagePath: red.path, + imagePath2: blue.path, + ); + + final key = GlobalKey(); + final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide)); + + ({int r, int g, int b}) pixelAt(double fx, double fy) { + final x = (cap.width * fx).round().clamp(0, cap.width - 1); + final y = (cap.height * fy).round().clamp(0, cap.height - 1); + final i = (y * cap.width + x) * 4; + return (r: cap.bytes[i], g: cap.bytes[i + 1], b: cap.bytes[i + 2]); + } + + final left = pixelAt(0.25, 0.5); + final right = pixelAt(0.75, 0.5); + + expect( + left.r > 200 && left.b < 80, + isTrue, + reason: 'Left panel should be the red image but was $left', + ); + expect( + right.b > 200 && right.r < 80, + isTrue, + reason: 'Right panel should be the blue image but was $right', + ); + + dir.deleteSync(recursive: true); + }); + }); + + testWidgets('zoomed (scaled) image still paints in the frame', ( + tester, + ) async { + await tester.runAsync(() async { + final dir = Directory.systemTemp.createTempSync('ocideck_zoom'); + final red = await _writeSolidPng( + dir.path, + 'red.png', + const Color(0xFFFF0000), + ); + + // imageSize 150 = zoomed in past contain; the picture must still render + // (regression: the old Transform.scale rendered blank in toImage). + final slide = Slide( + id: 'z', + type: SlideType.image, + imagePath: red.path, + imageSize: 150, + ); + + final key = GlobalKey(); + final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide)); + + final cx = cap.width ~/ 2; + final cy = cap.height ~/ 2; + final i = (cy * cap.width + cx) * 4; + final r = cap.bytes[i]; + final g = cap.bytes[i + 1]; + final b = cap.bytes[i + 2]; + + expect( + r > 200 && g < 80 && b < 80, + isTrue, + reason: 'Center of a zoomed image should be red but was ($r,$g,$b)', + ); + + dir.deleteSync(recursive: true); + }); + }); + + testWidgets('zoomed-out image with a title anchors to the top', ( + tester, + ) async { + await tester.runAsync(() async { + final dir = Directory.systemTemp.createTempSync('ocideck_top'); + final red = await _writeSolidPng( + dir.path, + 'red.png', + const Color(0xFFFF0000), + ); + + // Zoomed out (60%) with a title: the image should sit at the top so the + // bottom title banner does not overlap it. + final slide = Slide( + id: 't', + type: SlideType.image, + title: 'Onderschrift', + imagePath: red.path, + imageSize: 60, + ); + + final key = GlobalKey(); + final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide)); + + ({int r, int g, int b}) pixelAt(double fx, double fy) { + final x = (cap.width * fx).round().clamp(0, cap.width - 1); + final y = (cap.height * fy).round().clamp(0, cap.height - 1); + final idx = (y * cap.width + x) * 4; + return ( + r: cap.bytes[idx], + g: cap.bytes[idx + 1], + b: cap.bytes[idx + 2], + ); + } + + final top = pixelAt(0.5, 0.08); + final bottom = pixelAt(0.5, 0.92); + + expect( + top.r > 200 && top.g < 80 && top.b < 80, + isTrue, + reason: 'Top of a top-anchored image should be red but was $top', + ); + expect( + bottom.r > 200 && bottom.g < 80 && bottom.b < 80, + isFalse, + reason: 'Bottom should be free for the title, not the image ($bottom)', + ); + + dir.deleteSync(recursive: true); + }); + }); +} diff --git a/test/inline_markdown_test.dart b/test/inline_markdown_test.dart new file mode 100644 index 0000000..9b22e6f --- /dev/null +++ b/test/inline_markdown_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/widgets/slides/inline_markdown.dart'; + +void main() { + group('stripInlineMarkdown', () { + test('removes emphasis markers but keeps the text', () { + expect( + stripInlineMarkdown('Dit is **vet** en *cursief*.'), + 'Dit is vet en cursief.', + ); + expect(stripInlineMarkdown('Een `code` stukje'), 'Een code stukje'); + expect(stripInlineMarkdown('~~weg~~ ermee'), 'weg ermee'); + }); + + test('keeps link text and drops the url', () { + expect( + stripInlineMarkdown('Zie [de site](https://example.com) nu'), + 'Zie de site nu', + ); + }); + + test('leaves plain text untouched and is cheap', () { + expect(stripInlineMarkdown('Gewoon platte tekst'), 'Gewoon platte tekst'); + }); + + test('unterminated markers stay literal', () { + expect(stripInlineMarkdown('2 * 3 = 6'), '2 * 3 = 6'); + expect(stripInlineMarkdown('een **halve vet'), 'een **halve vet'); + }); + + test('escaped markers become literal characters', () { + expect( + stripInlineMarkdown(r'letterlijk \*sterren\*'), + 'letterlijk *sterren*', + ); + }); + }); + + group('parseInlineRuns', () { + test('splits styled and plain runs', () { + final runs = parseInlineRuns('a **b** c'); + expect(runs.map((r) => r.text).toList(), ['a ', 'b', ' c']); + expect(runs[0].bold, isFalse); + expect(runs[1].bold, isTrue); + expect(runs[2].bold, isFalse); + }); + + test('nested emphasis inside a link carries both flags', () { + final runs = parseInlineRuns('[**klik**](https://x.io)'); + expect(runs, hasLength(1)); + expect(runs.single.text, 'klik'); + expect(runs.single.bold, isTrue); + expect(runs.single.link, 'https://x.io'); + }); + + test('code spans are literal (no inner parsing)', () { + final runs = parseInlineRuns('`a*b*c`'); + expect(runs, hasLength(1)); + expect(runs.single.text, 'a*b*c'); + expect(runs.single.code, isTrue); + }); + + test('combines bold and italic when nested', () { + final runs = parseInlineRuns('**_allebei_**'); + expect(runs.single.bold, isTrue); + expect(runs.single.italic, isTrue); + expect(runs.single.text, 'allebei'); + }); + + test('merges adjacent runs with identical styling', () { + // 'a' + escaped '*' + 'b' → één platte run "a*b" + final runs = parseInlineRuns(r'a\*b'); + expect(runs, hasLength(1)); + expect(runs.single.text, 'a*b'); + }); + }); +} diff --git a/test/markdown_round_trip_test.dart b/test/markdown_round_trip_test.dart new file mode 100644 index 0000000..1474e84 --- /dev/null +++ b/test/markdown_round_trip_test.dart @@ -0,0 +1,418 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/markdown_service.dart'; + +/// Serialize a single slide to markdown and parse it straight back. +Slide _roundTrip(Slide slide) { + final service = MarkdownService(); + final markdown = service.generateDeck(Deck(title: 'Demo', slides: [slide])); + final deck = service.parseDeck(markdown); + expect(deck, isNotNull, reason: 'parseDeck returned null for:\n$markdown'); + expect( + deck!.slides, + hasLength(1), + reason: 'Expected exactly one slide from:\n$markdown', + ); + return deck.slides.single; +} + +void main() { + group('markdown round-trip per slide type', () { + test('title slide keeps title, subtitle, image and size', () { + final out = _roundTrip( + Slide.create(SlideType.title).copyWith( + title: 'Welkom', + subtitle: 'Een ondertitel', + imagePath: 'images/cover.png', + imageSize: 80, + imageCaption: 'Bron: archief', + ), + ); + expect(out.type, SlideType.title); + expect(out.title, 'Welkom'); + expect(out.subtitle, 'Een ondertitel'); + expect(out.imagePath, 'images/cover.png'); + expect(out.imageSize, 80); + expect(out.imageCaption, 'Bron: archief'); + }); + + test('section slide keeps title and subtitle', () { + final out = _roundTrip( + Slide.create( + SlideType.section, + ).copyWith(title: 'Hoofdstuk 1', subtitle: 'De inleiding'), + ); + expect(out.type, SlideType.section); + expect(out.title, 'Hoofdstuk 1'); + expect(out.subtitle, 'De inleiding'); + }); + + test('bullets slide keeps title and nested bullets', () { + final out = _roundTrip( + Slide.create(SlideType.bullets).copyWith( + title: 'Agenda', + bullets: [ + 'Punt een', + '\tSubpunt', + '\t\tDiep subpunt', + '\t\t\tNog dieper', + '\t\t\t\tExtra dieper', + 'Punt twee', + ], + ), + ); + expect(out.type, SlideType.bullets); + expect(out.title, 'Agenda'); + expect(out.bullets, [ + 'Punt een', + '\tSubpunt', + '\t\tDiep subpunt', + '\t\t\tNog dieper', + '\t\t\t\tExtra dieper', + 'Punt twee', + ]); + }); + + test('twoBullets slide keeps both bullet columns', () { + final out = _roundTrip( + Slide.create(SlideType.twoBullets).copyWith( + title: 'Vergelijking', + bullets: ['Links punt', '\tLinks subpunt'], + bullets2: ['Rechts punt', '\t\tRechts diep'], + ), + ); + expect(out.type, SlideType.twoBullets); + expect(out.title, 'Vergelijking'); + expect(out.bullets, ['Links punt', '\tLinks subpunt']); + expect(out.bullets2, ['Rechts punt', '\t\tRechts diep']); + }); + + test('bulletsImage slide keeps bullets, image, size and caption', () { + final out = _roundTrip( + Slide.create(SlideType.bulletsImage).copyWith( + title: 'Profiel', + bullets: ['Eerste punt', '\tGenest punt'], + imagePath: 'images/portret.png', + imageSize: 45, + imageCaption: 'Een onderschrift', + ), + ); + expect(out.type, SlideType.bulletsImage); + expect(out.title, 'Profiel'); + expect(out.bullets, ['Eerste punt', '\tGenest punt']); + expect(out.imagePath, 'images/portret.png'); + expect(out.imageSize, 45); + expect(out.imageCaption, 'Een onderschrift'); + }); + + test('twoImages slide keeps both images, split and captions', () { + final out = _roundTrip( + Slide.create(SlideType.twoImages).copyWith( + title: 'Voor en na', + imagePath: 'images/a.png', + imagePath2: 'images/b.png', + imageSize: 60, + imageCaption: 'Links', + imageCaption2: 'Rechts', + ), + ); + expect(out.type, SlideType.twoImages); + expect(out.title, 'Voor en na'); + expect(out.imagePath, 'images/a.png'); + expect(out.imagePath2, 'images/b.png'); + expect(out.imageSize, 60); + expect(out.imageCaption, 'Links'); + expect(out.imageCaption2, 'Rechts'); + }); + + test('image slide keeps title, image and size', () { + final out = _roundTrip( + Slide.create(SlideType.image).copyWith( + title: 'Overzicht', + imagePath: 'images/big.png', + imageSize: 70, + imageCaption: 'Foto', + ), + ); + expect(out.type, SlideType.image); + expect(out.title, 'Overzicht'); + expect(out.imagePath, 'images/big.png'); + expect(out.imageSize, 70); + expect(out.imageCaption, 'Foto'); + }); + + test('video slide keeps video and audio with autoplay flags', () { + final out = _roundTrip( + Slide.create(SlideType.video).copyWith( + title: 'Film', + videoPath: 'media/movie.mp4', + videoAutoplay: true, + audioPath: 'media/narration.mp3', + audioAutoplay: true, + ), + ); + expect(out.type, SlideType.video); + expect(out.title, 'Film'); + expect(out.videoPath, 'media/movie.mp4'); + expect(out.videoAutoplay, isTrue); + expect(out.audioPath, 'media/narration.mp3'); + expect(out.audioAutoplay, isTrue); + }); + + test('quote slide keeps quote, author and background image', () { + final out = _roundTrip( + Slide.create(SlideType.quote).copyWith( + quote: 'Kennis is macht', + quoteAuthor: 'Francis Bacon', + imagePath: 'images/bg.png', + ), + ); + expect(out.type, SlideType.quote); + expect(out.quote, 'Kennis is macht'); + expect(out.quoteAuthor, 'Francis Bacon'); + expect(out.imagePath, 'images/bg.png'); + }); + + test('table slide keeps title, header and rows', () { + final out = _roundTrip( + Slide.create(SlideType.table).copyWith( + title: 'Vergelijking', + tableRows: [ + ['Functie', 'Gratis', 'Pro'], + ['Export', 'Nee', 'Ja'], + ['Support', 'Email', '24/7'], + ], + ), + ); + expect(out.type, SlideType.table); + expect(out.title, 'Vergelijking'); + expect(out.tableRows, [ + ['Functie', 'Gratis', 'Pro'], + ['Export', 'Nee', 'Ja'], + ['Support', 'Email', '24/7'], + ]); + }); + + test('table slide escapes pipes and newlines in cells', () { + final out = _roundTrip( + Slide.create(SlideType.table).copyWith( + title: 'Speciale tekens', + tableRows: [ + ['Kolom A', 'Kolom B'], + ['waarde | met pipe', 'regel een\nregel twee'], + ], + ), + ); + expect(out.type, SlideType.table); + expect(out.tableRows, [ + ['Kolom A', 'Kolom B'], + ['waarde | met pipe', 'regel een\nregel twee'], + ]); + }); + + test('a new table slide starts with empty cells (nothing to delete)', () { + final slide = Slide.create(SlideType.table); + expect( + slide.tableRows.every((row) => row.every((c) => c.isEmpty)), + isTrue, + ); + }); + + test('table slide stays a table even with empty headers', () { + final out = _roundTrip( + Slide.create(SlideType.table).copyWith( + tableRows: const [ + ['', ''], + ['waarde', ''], + ], + ), + ); + expect(out.type, SlideType.table); + expect(out.tableRows.first, ['', '']); + expect(out.tableRows[1].first, 'waarde'); + }); + + test('free markdown slide keeps its raw content', () { + final out = _roundTrip( + Slide.create(SlideType.freeMarkdown).copyWith( + customMarkdown: 'Vrije tekst met **opmaak**.\n\nTweede alinea.', + ), + ); + expect(out.type, SlideType.freeMarkdown); + expect( + out.customMarkdown.trim(), + 'Vrije tekst met **opmaak**.\n\nTweede alinea.', + ); + }); + }); + + group('markdown round-trip cross-cutting fields', () { + test('keeps speaker notes and advance duration', () { + final out = _roundTrip( + Slide.create(SlideType.bullets).copyWith( + title: 'Met notities', + bullets: ['Een punt'], + notes: 'Vergeet de demo niet te tonen.', + advanceDuration: 2.5, + ), + ); + expect(out.notes, 'Vergeet de demo niet te tonen.'); + expect(out.advanceDuration, 2.5); + }); + + test('keeps the skipped flag and does not leak it into notes', () { + final skipped = _roundTrip( + Slide.create(SlideType.bullets).copyWith( + title: 'Backup-slide', + bullets: ['Alleen indien nodig'], + notes: 'Reserve', + skipped: true, + ), + ); + expect(skipped.skipped, isTrue); + expect(skipped.notes, 'Reserve'); // mag geen notitie worden + + final normal = _roundTrip( + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Gewoon', bullets: ['Punt']), + ); + expect(normal.skipped, isFalse); + }); + + test('keeps general presentation metadata in the front matter', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + author: 'Jan Jansen', + organization: 'Vigilis', + version: '1.2', + date: '2026-05-30', + description: 'Een korte: omschrijving met dubbele punt', + keywords: 'kwartaal, cijfers, 2026', + tlp: TlpLevel.amberStrict, + slides: [Slide.create(SlideType.title).copyWith(title: 'Een')], + ), + ); + final deck = service.parseDeck(markdown); + expect(deck, isNotNull); + expect(deck!.author, 'Jan Jansen'); + expect(deck.organization, 'Vigilis'); + expect(deck.version, '1.2'); + expect(deck.date, '2026-05-30'); + expect(deck.description, 'Een korte: omschrijving met dubbele punt'); + expect(deck.keywords, 'kwartaal, cijfers, 2026'); + expect(deck.tlp, TlpLevel.amberStrict); + }); + + test('TLP none is omitted from the front matter and round-trips', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + slides: [Slide.create(SlideType.title).copyWith(title: 'Een')], + ), + ); + expect(markdown.contains('tlp:'), isFalse); + expect(service.parseDeck(markdown)!.tlp, TlpLevel.none); + }); + + test('per-slide logo visibility round-trips when a logo is configured', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + themeProfile: const ThemeProfile(logoPath: '/tmp/logo.png'), + slides: [ + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Met logo', bullets: ['a']), + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Zonder logo', bullets: ['b'], showLogo: false), + ], + ), + ); + final deck = service.parseDeck(markdown); + expect(deck, isNotNull); + expect(deck!.slides[0].showLogo, isTrue); + expect(deck.slides[1].showLogo, isFalse); + }); + + test('per-slide footer visibility round-trips', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + themeProfile: const ThemeProfile( + footerText: 'Vertrouwelijk', + footerPosition: 'center', + ), + slides: [ + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Met footer', bullets: ['a']), + Slide.create(SlideType.bullets).copyWith( + title: 'Zonder footer', + bullets: ['b'], + showFooter: false, + ), + ], + ), + ); + final deck = service.parseDeck(markdown); + expect(deck, isNotNull); + expect(deck!.themeProfile.footerPosition, 'center'); + expect(deck.slides[0].showFooter, isTrue); + expect(deck.slides[1].showFooter, isFalse); + }); + + test('slides default to showing the logo', () { + final out = _roundTrip( + Slide.create(SlideType.bullets).copyWith(title: 'x', bullets: ['y']), + ); + expect(out.showLogo, isTrue); + }); + + test('slides default to showing the footer', () { + final out = _roundTrip( + Slide.create(SlideType.bullets).copyWith(title: 'x', bullets: ['y']), + ); + expect(out.showFooter, isTrue); + }); + + test('preserves slide order and count for a multi-slide deck', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + slides: [ + Slide.create(SlideType.title).copyWith(title: 'Een'), + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Twee', bullets: ['x']), + Slide.create(SlideType.bulletsImage).copyWith( + title: 'Drie', + bullets: ['y'], + imagePath: 'images/c.png', + ), + Slide.create(SlideType.image).copyWith(imagePath: 'images/d.png'), + ], + ), + ); + final deck = service.parseDeck(markdown); + expect(deck, isNotNull); + expect(deck!.slides.map((s) => s.type).toList(), [ + SlideType.title, + SlideType.bullets, + SlideType.bulletsImage, + SlideType.image, + ]); + expect(deck.slides[2].imagePath, 'images/c.png'); + expect(deck.slides[3].imagePath, 'images/d.png'); + }); + }); +} diff --git a/test/markdown_service_test.dart b/test/markdown_service_test.dart new file mode 100644 index 0000000..40f3e9b --- /dev/null +++ b/test/markdown_service_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/markdown_service.dart'; + +void main() { + test('round-trips image slide with title as image slide', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + slides: [ + Slide.create( + SlideType.image, + ).copyWith(title: 'Overlay title', imagePath: 'images/photo.png'), + ], + ), + ); + + final deck = service.parseDeck(markdown); + + expect(deck, isNotNull); + expect(deck!.slides.single.type, SlideType.image); + expect(deck.slides.single.title, 'Overlay title'); + expect(deck.slides.single.imagePath, 'images/photo.png'); + }); + + test('round-trips bulletsImage slide with image and size', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + slides: [ + Slide.create(SlideType.bulletsImage).copyWith( + title: 'Profiel', + bullets: ['Eerste punt', '\tGenest punt'], + imagePath: 'images/portret.png', + imageCaption: 'Een onderschrift', + imageSize: 45, + ), + ], + ), + ); + + final deck = service.parseDeck(markdown); + + expect(deck, isNotNull); + final slide = deck!.slides.single; + expect(slide.type, SlideType.bulletsImage); + expect(slide.title, 'Profiel'); + expect(slide.imagePath, 'images/portret.png'); + expect(slide.imageCaption, 'Een onderschrift'); + expect(slide.imageSize, 45); + expect(slide.bullets, ['Eerste punt', '\tGenest punt']); + }); + + test('keeps a plain image inside free markdown as free markdown', () { + final service = MarkdownService(); + final deck = service.parseDeck( + '---\nmarp: true\ntheme: vigilis\n---\n\n' + '![](images/inline.png)\n\nWat losse tekst.\n', + ); + + expect(deck, isNotNull); + final slide = deck!.slides.single; + expect(slide.type, SlideType.freeMarkdown); + expect(slide.imagePath, isEmpty); + }); + + test('round-trips deck style profile', () { + final service = MarkdownService(); + final profile = const ThemeProfile( + name: 'Klant A', + slideBackgroundColor: '#111827', + textColor: '#F8FAFC', + accentColor: '#F59E0B', + tableTextColor: '#111111', + tableHeaderTextColor: '#EEEEEE', + logoPosition: 'top-left', + logoSize: 120, + fontFamily: 'Georgia', + footerText: 'Vertrouwelijk · {page}/{total}', + footerShowPageNumbers: true, + footerPosition: 'center', + ); + + final markdown = service.generateDeck( + Deck( + title: 'Demo', + themeProfile: profile, + slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')], + ), + ); + + final deck = service.parseDeck(markdown); + + expect(deck, isNotNull); + expect(deck!.themeProfile.name, 'Klant A'); + expect(deck.themeProfile.slideBackgroundColor, '#111827'); + expect(deck.themeProfile.tableTextColor, '#111111'); + expect(deck.themeProfile.tableHeaderTextColor, '#EEEEEE'); + expect(deck.themeProfile.logoPosition, 'top-left'); + expect(deck.themeProfile.logoSize, 120); + expect(deck.themeProfile.fontFamily, 'Georgia'); + expect(deck.themeProfile.footerText, 'Vertrouwelijk · {page}/{total}'); + expect(deck.themeProfile.footerShowPageNumbers, isTrue); + expect(deck.themeProfile.footerPosition, 'center'); + }); + + test('adds logo-safe class when deck profile has logo', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Demo', + themeProfile: const ThemeProfile(logoPath: '/tmp/logo.png'), + slides: [ + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Demo', bullets: ['Een lange bullet']), + ], + ), + ); + + expect(markdown, contains('')); + }); + + test('round-trips video slide with audio attachment', () { + final service = MarkdownService(); + final markdown = service.generateDeck( + Deck( + title: 'Media', + slides: [ + Slide.create(SlideType.video).copyWith( + title: 'Film', + videoPath: 'media/movie.mp4', + videoAutoplay: true, + audioPath: 'media/narration.mp3', + audioAutoplay: true, + ), + ], + ), + ); + + final deck = service.parseDeck(markdown); + + expect(deck, isNotNull); + expect(deck!.slides.single.type, SlideType.video); + expect(deck.slides.single.videoPath, 'media/movie.mp4'); + expect(deck.slides.single.videoAutoplay, isTrue); + expect(deck.slides.single.audioPath, 'media/narration.mp3'); + expect(deck.slides.single.audioAutoplay, isTrue); + }); +} diff --git a/test/package_export_test.dart b/test/package_export_test.dart new file mode 100644 index 0000000..924070c --- /dev/null +++ b/test/package_export_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/services/file_service.dart'; +import 'package:ocideck/services/image_service.dart'; +import 'package:ocideck/services/markdown_service.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + late FileService file; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('ocideck_pkg_test'); + file = FileService( + MarkdownService(), + ImageService(), + () => const ThemeProfile(), + ); + }); + + tearDown(() { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + }); + + test( + 'export then import a package round-trips slides and bundles the image', + () async { + // Bron-afbeelding (absoluut pad, zoals net geplakt/gekozen). + final srcImg = File(p.join(tmp.path, 'pic.png')) + ..writeAsBytesSync([1, 2, 3, 4]); + + final deck = Deck( + title: 'Mijn Deck', + slides: [ + Slide.create( + SlideType.image, + ).copyWith(title: 'Foto', imagePath: srcImg.path), + Slide.create( + SlideType.bullets, + ).copyWith(title: 'Punten', bullets: ['een', 'twee']), + ], + ); + + // Exporteren naar een .ocideck-zip. + final zipPath = p.join(tmp.path, 'deck.ocideck'); + await file.exportPackage(deck, zipPath); + expect(File(zipPath).existsSync(), isTrue); + + // Importeren in een verse map. + final out = Directory(p.join(tmp.path, 'out'))..createSync(); + final bytes = File(zipPath).readAsBytesSync(); + final mdPath = await file.importPackageBytes(bytes, out.path); + expect(mdPath, isNotNull); + expect(File(mdPath!).existsSync(), isTrue); + + // Het uitgepakte deck moet identiek zijn en de afbeelding meebrengen. + final imported = await file.openDeck(mdPath); + expect(imported, isNotNull); + expect(imported!.slides, hasLength(2)); + expect(imported.slides[0].type, SlideType.image); + expect(imported.slides[0].imagePath, 'images/pic.png'); + expect(imported.slides[1].bullets, ['een', 'twee']); + + final extracted = File( + p.join(imported.projectPath!, 'images', 'pic.png'), + ); + expect(extracted.existsSync(), isTrue); + expect(extracted.readAsBytesSync(), [1, 2, 3, 4]); + }, + ); + + test( + 'importing the same package twice does not overwrite the first', + () async { + final deck = Deck( + title: 'Deck', + slides: [ + Slide.create(SlideType.bullets).copyWith(bullets: ['x']), + ], + ); + final zipPath = p.join(tmp.path, 'deck.ocideck'); + await file.exportPackage(deck, zipPath); + final bytes = File(zipPath).readAsBytesSync(); + final out = Directory(p.join(tmp.path, 'out'))..createSync(); + + final first = await file.importPackageBytes(bytes, out.path); + final second = await file.importPackageBytes(bytes, out.path); + + expect(first, isNotNull); + expect(second, isNotNull); + expect(p.dirname(first!), isNot(p.dirname(second!))); // aparte mappen + }, + ); +} diff --git a/test/recovery_service_test.dart b/test/recovery_service_test.dart new file mode 100644 index 0000000..bd86bab --- /dev/null +++ b/test/recovery_service_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/services/recovery_service.dart'; + +void main() { + late Directory tempDir; + late RecoveryService service; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('ocideck_recovery_test'); + service = RecoveryService(baseDir: tempDir); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + RecoverySnapshot snap(String id, {String label = 'Deck'}) { + return RecoverySnapshot( + id: id, + savedAt: DateTime(2026, 5, 31, 12, 0), + filePath: '/tmp/$id.md', + label: label, + markdown: '# $label\n', + ); + } + + test('save then loadAll round-trips a snapshot', () async { + await service.save(snap('a', label: 'Eerste')); + final all = await service.loadAll(); + expect(all, hasLength(1)); + expect(all.single.id, 'a'); + expect(all.single.label, 'Eerste'); + expect(all.single.markdown, '# Eerste\n'); + expect(all.single.filePath, '/tmp/a.md'); + }); + + test('loadAll returns newest first', () async { + await service.save( + RecoverySnapshot( + id: 'old', + savedAt: DateTime(2026, 1, 1), + filePath: null, + label: 'Oud', + markdown: '', + ), + ); + await service.save( + RecoverySnapshot( + id: 'new', + savedAt: DateTime(2026, 5, 1), + filePath: null, + label: 'Nieuw', + markdown: '', + ), + ); + final all = await service.loadAll(); + expect(all.map((s) => s.id).toList(), ['new', 'old']); + }); + + test('discard removes only the given snapshot', () async { + await service.save(snap('a')); + await service.save(snap('b')); + await service.discard('a'); + final all = await service.loadAll(); + expect(all.map((s) => s.id), ['b']); + }); + + test('clearAll empties the recovery directory', () async { + await service.save(snap('a')); + await service.save(snap('b')); + await service.clearAll(); + expect(await service.loadAll(), isEmpty); + }); + + test('loadAll is empty (not throwing) for a fresh directory', () async { + expect(await service.loadAll(), isEmpty); + }); + + test('discard is safe when the file does not exist', () async { + await service.discard('nope'); // mag geen exception gooien + expect(await service.loadAll(), isEmpty); + }); +} diff --git a/test/settings_provider_test.dart b/test/settings_provider_test.dart new file mode 100644 index 0000000..025c06e --- /dev/null +++ b/test/settings_provider_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/state/settings_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Build a notifier and wait for its async [SettingsNotifier] load to settle. +Future _loadedNotifier() async { + SharedPreferences.setMockInitialValues({}); + final notifier = SettingsNotifier(); + // The constructor kicks off an async load; let it complete. + await Future.delayed(const Duration(milliseconds: 50)); + return notifier; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('starts with a single default profile', () async { + final notifier = await _loadedNotifier(); + expect(notifier.state.themeProfiles, hasLength(1)); + expect( + notifier.state.selectedThemeProfileName, + notifier.state.themeProfiles.single.name, + ); + }); + + test('createThemeProfile adds and selects a new profile', () async { + final notifier = await _loadedNotifier(); + final created = await notifier.createThemeProfile(); + expect(notifier.state.themeProfiles, hasLength(2)); + expect(notifier.state.selectedThemeProfileName, created.name); + }); + + test('renaming a profile updates it in place (no duplicate)', () async { + final notifier = await _loadedNotifier(); + final created = await notifier.createThemeProfile(); + + await notifier.saveThemeProfile( + created.copyWith(name: 'Mijn stijl'), + previousName: created.name, + ); + + final names = notifier.state.themeProfiles.map((p) => p.name).toList(); + expect(names, contains('Mijn stijl')); + expect( + names, + isNot(contains(created.name)), + reason: 'The old name should be replaced, not duplicated', + ); + expect(notifier.state.themeProfiles, hasLength(2)); + expect(notifier.state.selectedThemeProfileName, 'Mijn stijl'); + }); + + test('renaming to an existing name gets a unique suffix', () async { + final notifier = await _loadedNotifier(); + final defaultName = notifier.state.themeProfiles.single.name; + final created = await notifier.createThemeProfile(); + + await notifier.saveThemeProfile( + created.copyWith(name: defaultName), + previousName: created.name, + ); + + final names = notifier.state.themeProfiles.map((p) => p.name).toList(); + expect(names, contains(defaultName)); + expect(names, contains('$defaultName 2')); + expect(names.toSet(), hasLength(names.length), reason: 'names are unique'); + }); + + test('editing colors persists without losing the profile name', () async { + final notifier = await _loadedNotifier(); + final created = await notifier.createThemeProfile(); + + await notifier.saveThemeProfile( + created.copyWith(name: 'Klant A', accentColor: '#FF0000'), + previousName: created.name, + ); + + final profile = notifier.state.themeProfiles.firstWhere( + (p) => p.name == 'Klant A', + ); + expect(profile.accentColor, '#FF0000'); + expect(notifier.state.themeProfile.name, 'Klant A'); + }); + + test('deleteThemeProfile removes it and selects another', () async { + final notifier = await _loadedNotifier(); + final created = await notifier.createThemeProfile(); + expect(notifier.state.themeProfiles, hasLength(2)); + + await notifier.deleteThemeProfile(created.name); + + final names = notifier.state.themeProfiles.map((p) => p.name).toList(); + expect(names, isNot(contains(created.name))); + expect(notifier.state.themeProfiles, hasLength(1)); + expect(notifier.state.selectedThemeProfileName, names.single); + }); + + test('never deletes the last remaining profile', () async { + final notifier = await _loadedNotifier(); + final only = notifier.state.themeProfiles.single.name; + await notifier.deleteThemeProfile(only); + expect(notifier.state.themeProfiles, hasLength(1)); + }); +} diff --git a/test/slide_media_gating_test.dart b/test/slide_media_gating_test.dart new file mode 100644 index 0000000..cb93235 --- /dev/null +++ b/test/slide_media_gating_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +/// De audioknop mag alleen verschijnen wanneer media bewust is ingeschakeld. +/// Zo dreunen thumbnails en de export niet door (de kern van "muziek kun je +/// niet uitzetten op een slide"). +void main() { + Future pump(WidgetTester tester, {required bool enableMedia}) async { + final slide = Slide.create( + SlideType.bullets, + ).copyWith(audioPath: '/tmp/does_not_exist.mp3'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 320, + height: 180, + child: SlidePreviewWidget(slide: slide, enableMedia: enableMedia), + ), + ), + ), + ); + } + + testWidgets('no audio control when media is disabled (thumbnails/export)', ( + tester, + ) async { + await pump(tester, enableMedia: false); + expect(find.byTooltip('Audio'), findsNothing); + }); + + testWidgets('audio control appears when media is enabled', (tester) async { + await pump(tester, enableMedia: true); + expect(find.byTooltip('Audio'), findsOneWidget); + }); +} diff --git a/test/slide_text_style_test.dart b/test/slide_text_style_test.dart new file mode 100644 index 0000000..c19665a --- /dev/null +++ b/test/slide_text_style_test.dart @@ -0,0 +1,84 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +void main() { + // Regression: when the slide is rendered outside a Material (as the export + // rasterizer does, mounting it in a bare Overlay), text used to fall back to + // Flutter's broken default style — red letters with a yellow underline. + // SlidePreviewWidget now supplies its own DefaultTextStyle so this can never + // happen again. + testWidgets('bullets render without yellow underline outside a Material', ( + tester, + ) async { + const profile = ThemeProfile( + slideBackgroundColor: '#FFFFFF', + textColor: '#1C2B47', // navy + accentColor: '#1C2B47', + ); + + final slide = Slide( + id: 'b', + type: SlideType.bullets, + title: 'Titel', + bullets: const ['Eerste bullet', 'Tweede bullet', 'Derde bullet'], + ); + + await tester.runAsync(() async { + final key = GlobalKey(); + // Deliberately NO MaterialApp / DefaultTextStyle here — only the minimal + // View that pumpWidget provides — to mimic the export overlay subtree. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget(slide: slide, themeProfile: profile), + ), + ), + ), + ), + ); + await tester.pump(); + + final boundary = + key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1.0); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!.buffer.asUint8List(); + + var yellowCount = 0; + var navyCount = 0; + for (var i = 0; i + 3 < bytes.length; i += 4) { + final r = bytes[i]; + final g = bytes[i + 1]; + final b = bytes[i + 2]; + // Yellow = high red + high green + low blue (broken-default underline). + if (r > 180 && g > 180 && b < 100) yellowCount++; + // Navy-ish dark blue text. + if (r < 80 && g < 90 && b > 50 && b < 160) navyCount++; + } + + expect( + yellowCount, + lessThan(20), + reason: 'Found $yellowCount yellow pixels — broken default text style?', + ); + expect( + navyCount, + greaterThan(50), + reason: 'Expected navy text pixels; found $navyCount', + ); + }); + }); +} diff --git a/test/tlp_test.dart b/test/tlp_test.dart new file mode 100644 index 0000000..bb4998d --- /dev/null +++ b/test/tlp_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/deck.dart'; +import 'package:ocideck/models/slide.dart'; +import 'package:ocideck/widgets/slides/slide_preview.dart'; + +void main() { + group('TlpLevel', () { + test('labels follow the FIRST TLP 2.0 spelling', () { + expect(TlpLevel.none.label, ''); + expect(TlpLevel.clear.label, 'TLP:CLEAR'); + expect(TlpLevel.green.label, 'TLP:GREEN'); + expect(TlpLevel.amber.label, 'TLP:AMBER'); + expect(TlpLevel.amberStrict.label, 'TLP:AMBER+STRICT'); + expect(TlpLevel.red.label, 'TLP:RED'); + }); + + test('menu label shows "Geen" for none', () { + expect(TlpLevel.none.menuLabel, 'Geen'); + expect(TlpLevel.red.menuLabel, 'TLP:RED'); + }); + + test('key round-trips through fromKey for every level', () { + for (final level in TlpLevel.values) { + expect(TlpLevelX.fromKey(level.key), level); + } + }); + + test('fromKey is forgiving and defaults to none', () { + expect(TlpLevelX.fromKey('AMBER+STRICT'), TlpLevel.amberStrict); + expect(TlpLevelX.fromKey('amberstrict'), TlpLevel.amberStrict); + expect(TlpLevelX.fromKey('onzin'), TlpLevel.none); + expect(TlpLevelX.fromKey(''), TlpLevel.none); + }); + }); + + group('TLP marking on slides', () { + Widget host(TlpLevel tlp) => MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 800, + height: 450, + child: SlidePreviewWidget( + slide: Slide.create( + SlideType.bullets, + ).copyWith(title: 'T', bullets: ['a']), + tlp: tlp, + ), + ), + ), + ), + ); + + testWidgets('renders the marking when a level is set', (tester) async { + await tester.pumpWidget(host(TlpLevel.red)); + await tester.pump(); + expect(find.text('TLP:RED'), findsOneWidget); + }); + + testWidgets('renders nothing when none', (tester) async { + await tester.pumpWidget(host(TlpLevel.none)); + await tester.pump(); + expect(find.textContaining('TLP:'), findsNothing); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..4849e6b --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/app.dart'; + +void main() { + testWidgets('Welcome screen shows startup logo', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); + expect( + find.bySemanticsLabel('De Winter Information Solutions'), + findsOneWidget, + ); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..473afd8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + ocideck + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..884953f --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "ocideck", + "short_name": "ocideck", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..805b1f4 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(ocideck LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "ocideck") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..789e6be --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopDropPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopDropPlugin")); + PasteboardPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PasteboardPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..965883b --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_drop + pasteboard + screen_retriever_windows + url_launcher_windows + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..4a4bea9 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "ocideck" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "ocideck" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "ocideck.exe" "\0" + VALUE "ProductName", "ocideck" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + 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()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +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 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); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..870fb62 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"ocideck", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.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_RUNNER_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(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( + 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); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + 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; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(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(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(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(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( + 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)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_