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 <noreply@anthropic.com>
25
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||
45
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
45
.metadata
Normal file
|
|
@ -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'
|
||||
116
Makefile
Normal file
|
|
@ -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."
|
||||
70
README.md
Normal file
|
|
@ -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.)_
|
||||
28
analysis_options.yaml
Normal file
|
|
@ -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
|
||||
14
android/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
45
android/app/build.gradle.kts
Normal file
|
|
@ -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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
45
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="ocideck"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.example.ocideck
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
android/build.gradle.kts
Normal file
|
|
@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
6
android/gradle.properties
Normal file
|
|
@ -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
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -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
|
||||
26
android/settings.gradle.kts
Normal file
|
|
@ -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")
|
||||
BIN
assets/images/de-winter-wittegeheel.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/images/logo-icon.png
Normal file
|
After Width: | Height: | Size: 464 KiB |
BIN
assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
148
assets/themes/ocideck.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
34
ios/.gitignore
vendored
Normal file
|
|
@ -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
|
||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Flutter/Debug.xcconfig
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
1
ios/Flutter/Release.xcconfig
Normal file
|
|
@ -0,0 +1 @@
|
|||
#include "Generated.xcconfig"
|
||||
644
ios/Runner.xcodeproj/project.pbxproj
Normal file
|
|
@ -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 = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -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
|
||||
}
|
||||
119
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal file
|
|
@ -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
|
||||
}
|
||||
16
ios/Runner/AppDelegate.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 54 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
|
|
@ -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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
70
ios/Runner/Info.plist
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ocideck</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>ocideck</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
|
|
@ -0,0 +1 @@
|
|||
#import "GeneratedPluginRegistrant.h"
|
||||
6
ios/Runner/SceneDelegate.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal file
|
|
@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
17
lib/app.dart
Normal file
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
lib/main.dart
Normal file
|
|
@ -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()));
|
||||
}
|
||||
150
lib/models/deck.dart
Normal file
|
|
@ -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<Slide> 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<Slide>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
212
lib/models/settings.dart
Normal file
|
|
@ -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<String, Object?> 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<String, Object?> 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<ThemeProfile> themeProfiles;
|
||||
final String selectedThemeProfileName;
|
||||
final List<String> 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<ThemeProfile>? themeProfiles,
|
||||
String? selectedThemeProfileName,
|
||||
List<String>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
236
lib/models/slide.dart
Normal file
|
|
@ -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<String> bullets;
|
||||
final List<String> 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<List<String>> 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<String>.from(src.bullets),
|
||||
bullets2: List<String>.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<String>.from(r)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Slide copyWith({
|
||||
SlideType? type,
|
||||
String? title,
|
||||
String? subtitle,
|
||||
List<String>? bullets,
|
||||
List<String>? 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<List<String>>? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/services/caption_service.dart
Normal file
|
|
@ -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<String?> 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<void> saveCaption(
|
||||
String imagePath,
|
||||
String caption, {
|
||||
String? basePath,
|
||||
}) async {
|
||||
if (imagePath.isEmpty) return;
|
||||
final resolvedPath = _resolvePath(imagePath, basePath);
|
||||
final file = _sidecarFile(resolvedPath);
|
||||
Map<String, dynamic> data = {};
|
||||
if (file.existsSync()) {
|
||||
try {
|
||||
data = Map<String, dynamic>.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<void> 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>(
|
||||
(_) => CaptionService(),
|
||||
);
|
||||
86
lib/services/description_service.dart
Normal file
|
|
@ -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<String?> 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<void> saveDescription(String imagePath, String description) async {
|
||||
if (imagePath.isEmpty) return;
|
||||
final file = _sidecarFile(imagePath);
|
||||
Map<String, dynamic> data = {};
|
||||
if (file.existsSync()) {
|
||||
try {
|
||||
data = Map<String, dynamic>.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<void> 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<Map<String, String>> loadFor(Iterable<String> imagePaths) async {
|
||||
final dirs = <String>{for (final path in imagePaths) p.dirname(path)};
|
||||
final result = <String, String>{};
|
||||
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>(
|
||||
(_) => DescriptionService(),
|
||||
);
|
||||
355
lib/services/export_service.dart
Normal file
|
|
@ -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<ExportResult> export(
|
||||
String deckPath,
|
||||
ExportFormat format,
|
||||
List<Uint8List> 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<Uint8List> _buildPdf(List<Uint8List> 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<Uint8List> 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<int> utf8Bytes(String s) => utf8.encode(s);
|
||||
|
||||
String _contentTypes(int count) {
|
||||
final overrides = StringBuffer();
|
||||
for (var i = 1; i <= count; i++) {
|
||||
overrides.write(
|
||||
'<Override PartName="/ppt/slides/slide$i.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>',
|
||||
);
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Default Extension="png" ContentType="image/png"/>'
|
||||
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
|
||||
'<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>'
|
||||
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
|
||||
'<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
|
||||
'<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
|
||||
'$overrides'
|
||||
'</Types>';
|
||||
}
|
||||
|
||||
String _rootRels() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _presentationXml(int count) {
|
||||
final sldIds = StringBuffer();
|
||||
for (var i = 0; i < count; i++) {
|
||||
// Slide relationship ids start at rId2 (rId1 = master).
|
||||
sldIds.write('<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>');
|
||||
}
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>'
|
||||
'<p:sldIdLst>$sldIds</p:sldIdLst>'
|
||||
'<p:sldSz cx="$_slideWidthEmu" cy="$_slideHeightEmu" type="screen16x9"/>'
|
||||
'<p:notesSz cx="6858000" cy="9144000"/>'
|
||||
'</p:presentation>';
|
||||
}
|
||||
|
||||
String _presentationRels(int count) {
|
||||
final rels = StringBuffer();
|
||||
rels.write(
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
|
||||
'Target="slideMasters/slideMaster1.xml"/>',
|
||||
);
|
||||
for (var i = 0; i < count; i++) {
|
||||
final n = i + 1;
|
||||
rels.write(
|
||||
'<Relationship Id="rId${i + 2}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
|
||||
'Target="slides/slide$n.xml"/>',
|
||||
);
|
||||
}
|
||||
final presPropsId = 'rId${count + 2}';
|
||||
final themeId = 'rId${count + 3}';
|
||||
rels.write(
|
||||
'<Relationship Id="$presPropsId" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/presProps" '
|
||||
'Target="presProps.xml"/>',
|
||||
);
|
||||
rels.write(
|
||||
'<Relationship Id="$themeId" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
|
||||
'Target="theme/theme1.xml"/>',
|
||||
);
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'$rels'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _presProps() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:presentationPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>';
|
||||
}
|
||||
|
||||
String _slideMaster() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:cSld>'
|
||||
'<p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg>'
|
||||
'${_emptySpTree()}'
|
||||
'</p:cSld>'
|
||||
'<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" '
|
||||
'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" '
|
||||
'accent6="accent6" hlink="hlink" folHlink="folHlink"/>'
|
||||
'<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>'
|
||||
'<p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>'
|
||||
'</p:sldMaster>';
|
||||
}
|
||||
|
||||
String _slideMasterRels() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" '
|
||||
'Target="../slideLayouts/slideLayout1.xml"/>'
|
||||
'<Relationship Id="rId2" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" '
|
||||
'Target="../theme/theme1.xml"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _slideLayout() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" '
|
||||
'type="blank" preserve="1">'
|
||||
'<p:cSld name="Leeg">${_emptySpTree()}</p:cSld>'
|
||||
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
|
||||
'</p:sldLayout>';
|
||||
}
|
||||
|
||||
String _slideLayoutRels() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" '
|
||||
'Target="../slideMasters/slideMaster1.xml"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _slideXml() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
|
||||
'<p:cSld><p:spTree>'
|
||||
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
||||
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
|
||||
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
|
||||
'<p:pic>'
|
||||
'<p:nvPicPr><p:cNvPr id="2" name="Slide"/>'
|
||||
'<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>'
|
||||
'<p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>'
|
||||
'<p:spPr><a:xfrm><a:off x="0" y="0"/>'
|
||||
'<a:ext cx="$_slideWidthEmu" cy="$_slideHeightEmu"/></a:xfrm>'
|
||||
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>'
|
||||
'</p:pic>'
|
||||
'</p:spTree></p:cSld>'
|
||||
'<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>'
|
||||
'</p:sld>';
|
||||
}
|
||||
|
||||
String _slideRels(int n) {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" '
|
||||
'Target="../slideLayouts/slideLayout1.xml"/>'
|
||||
'<Relationship Id="rId2" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
'Target="../media/image$n.png"/>'
|
||||
'</Relationships>';
|
||||
}
|
||||
|
||||
String _emptySpTree() {
|
||||
return '<p:spTree>'
|
||||
'<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>'
|
||||
'<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>'
|
||||
'<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>'
|
||||
'</p:spTree>';
|
||||
}
|
||||
|
||||
String _theme1() {
|
||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office">'
|
||||
'<a:themeElements>'
|
||||
'<a:clrScheme name="Office">'
|
||||
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>'
|
||||
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>'
|
||||
'<a:dk2><a:srgbClr val="44546A"/></a:dk2>'
|
||||
'<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>'
|
||||
'<a:accent1><a:srgbClr val="4472C4"/></a:accent1>'
|
||||
'<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>'
|
||||
'<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>'
|
||||
'<a:accent4><a:srgbClr val="FFC000"/></a:accent4>'
|
||||
'<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>'
|
||||
'<a:accent6><a:srgbClr val="70AD47"/></a:accent6>'
|
||||
'<a:hlink><a:srgbClr val="0563C1"/></a:hlink>'
|
||||
'<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>'
|
||||
'</a:clrScheme>'
|
||||
'<a:fontScheme name="Office">'
|
||||
'<a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>'
|
||||
'<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>'
|
||||
'</a:fontScheme>'
|
||||
'<a:fmtScheme name="Office">'
|
||||
'<a:fillStyleLst>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'</a:fillStyleLst>'
|
||||
'<a:lnStyleLst>'
|
||||
'<a:ln w="6350" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
|
||||
'<a:ln w="12700" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
|
||||
'<a:ln w="19050" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:prstDash val="solid"/></a:ln>'
|
||||
'</a:lnStyleLst>'
|
||||
'<a:effectStyleLst>'
|
||||
'<a:effectStyle><a:effectLst/></a:effectStyle>'
|
||||
'<a:effectStyle><a:effectLst/></a:effectStyle>'
|
||||
'<a:effectStyle><a:effectLst/></a:effectStyle>'
|
||||
'</a:effectStyleLst>'
|
||||
'<a:bgFillStyleLst>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'
|
||||
'</a:bgFillStyleLst>'
|
||||
'</a:fmtScheme>'
|
||||
'</a:themeElements>'
|
||||
'</a:theme>';
|
||||
}
|
||||
}
|
||||
674
lib/services/file_service.dart
Normal file
|
|
@ -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<List<ScannedPresentation>> scanPresentations(
|
||||
String directory, {
|
||||
String? excludePath,
|
||||
int maxDepth = 4,
|
||||
}) async {
|
||||
final root = Directory(directory);
|
||||
if (!await root.exists()) return [];
|
||||
|
||||
final results = <ScannedPresentation>[];
|
||||
Future<void> walk(Directory dir, int depth) async {
|
||||
List<FileSystemEntity> 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<String?> 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<Deck?> 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<String?> 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<Deck> 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<void> exportPackage(Deck deck, String destPath) async {
|
||||
final archive = Archive();
|
||||
final added = <String>{};
|
||||
|
||||
/// Resolve [path] (relatief t.o.v. projectPath of absoluut), voeg het
|
||||
/// bestand toe onder `<subdir>/<bestandsnaam>` 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<String?> _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<String?> importPackageBytes(
|
||||
List<int> 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<int>, 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<String?> importFromUrl(String url, String destParentDir) async {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null || !uri.hasScheme) return null;
|
||||
|
||||
final List<int> 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<String?> 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<String?> pickPackageDestination(Deck deck) async {
|
||||
return FilePicker.saveFile(
|
||||
dialogTitle: 'Pakket exporteren',
|
||||
fileName: '${_safeName(deck.title)}.$packageExtension',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Deck> _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<Deck> _hydrateImageCaptions(Deck deck) async {
|
||||
final slides = <Slide>[];
|
||||
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<void> _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<void> _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<String?> _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;
|
||||
}
|
||||
''';
|
||||
}
|
||||
}
|
||||
171
lib/services/image_service.dart
Normal file
|
|
@ -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<String?> pickImage() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.image,
|
||||
dialogTitle: 'Kies een afbeelding',
|
||||
);
|
||||
return result?.files.single.path;
|
||||
}
|
||||
|
||||
Future<String?> pickVideo() async {
|
||||
final result = await FilePicker.pickFiles(
|
||||
type: FileType.video,
|
||||
dialogTitle: 'Kies een video',
|
||||
);
|
||||
return result?.files.single.path;
|
||||
}
|
||||
|
||||
Future<String?> 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<bool> 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<bool> 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<String?> 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<List<Slide>> copyImagesToProject(
|
||||
List<Slide> slides,
|
||||
String projectPath,
|
||||
) async {
|
||||
final imagesDir = Directory(p.join(projectPath, 'images'));
|
||||
await imagesDir.create(recursive: true);
|
||||
|
||||
final updated = <Slide>[];
|
||||
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<List<Slide>> copyMediaToProject(
|
||||
List<Slide> slides,
|
||||
String projectPath,
|
||||
) async {
|
||||
final mediaDir = Directory(p.join(projectPath, 'media'));
|
||||
await mediaDir.create(recursive: true);
|
||||
|
||||
final updated = <Slide>[];
|
||||
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<String?> _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<String?> _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;
|
||||
}
|
||||
}
|
||||
801
lib/services/markdown_service.dart
Normal file
|
|
@ -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<List<String>> rows) {
|
||||
if (rows.isEmpty) return;
|
||||
final colCount = rows.fold<int>(0, (m, r) => r.length > m ? r.length : m);
|
||||
if (colCount == 0) return;
|
||||
|
||||
String cell(List<String> row, int c) {
|
||||
final v = c < row.length ? row[c] : '';
|
||||
return v
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll('|', r'\|')
|
||||
.replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
String renderRow(List<String> 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<String> _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'(?<!\\)\|'))
|
||||
.map((c) => _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('<br>', '\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('<!-- _class: ${classes.join(' ')} -->');
|
||||
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('');
|
||||
_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(
|
||||
'<!-- _style: --image-width: $pct%; --split-text-scale: ${textScale.toStringAsFixed(2)}; -->',
|
||||
);
|
||||
buf.writeln();
|
||||
buf.writeln(
|
||||
'<div class="split-text" style="font-size: ${textScale.toStringAsFixed(2)}em">',
|
||||
);
|
||||
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('</div>');
|
||||
buf.writeln();
|
||||
buf.writeln('<div class="split-image">');
|
||||
buf.writeln();
|
||||
buf.writeln('');
|
||||
_writeImageCaption(buf, slide.imageCaption);
|
||||
buf.writeln();
|
||||
buf.writeln('</div>');
|
||||
} 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('');
|
||||
}
|
||||
if (slide.imagePath2.isNotEmpty) {
|
||||
buf.writeln('');
|
||||
}
|
||||
_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('');
|
||||
_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(
|
||||
'<video src="${slide.videoPath}" controls$autoplay style="width:100%; max-height:72vh;"></video>',
|
||||
);
|
||||
}
|
||||
|
||||
case SlideType.quote:
|
||||
if (slide.imagePath.isNotEmpty) {
|
||||
final sizeSpec = slide.imageSize > 0 ? '${slide.imageSize}% ' : '';
|
||||
buf.writeln('');
|
||||
_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(
|
||||
'<audio src="${slide.audioPath}" controls$autoplay style="width:100%;"></audio>',
|
||||
);
|
||||
}
|
||||
|
||||
if (slide.advanceDuration > 0) {
|
||||
buf.writeln();
|
||||
buf.writeln(
|
||||
'<!-- advance: ${slide.advanceDuration.toStringAsFixed(1)} -->',
|
||||
);
|
||||
}
|
||||
|
||||
// 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('<!-- skip -->');
|
||||
}
|
||||
|
||||
if (slide.notes.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('<!--');
|
||||
buf.writeln(slide.notes);
|
||||
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<String> left,
|
||||
List<String> right,
|
||||
) {
|
||||
buf.writeln('<!-- ocideck_two_bullets_left: ${_encodeBullets(left)} -->');
|
||||
buf.writeln('<!-- ocideck_two_bullets_right: ${_encodeBullets(right)} -->');
|
||||
buf.writeln(
|
||||
'<div class="ocideck-two-bullets" style="display:grid; grid-template-columns:1fr 1fr; gap:3rem; align-items:start;">',
|
||||
);
|
||||
buf.writeln('<ul style="margin:0; padding-left:1.3em;">');
|
||||
_writeHtmlBulletItems(buf, left);
|
||||
buf.writeln('</ul>');
|
||||
buf.writeln('<ul style="margin:0; padding-left:1.3em;">');
|
||||
_writeHtmlBulletItems(buf, right);
|
||||
buf.writeln('</ul>');
|
||||
buf.writeln('</div>');
|
||||
}
|
||||
|
||||
static String _encodeBullets(List<String> bullets) {
|
||||
return base64Url.encode(utf8.encode(jsonEncode(bullets)));
|
||||
}
|
||||
|
||||
static List<String> _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<String> 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('<li$style>${_escapeHtml(text)}</li>');
|
||||
}
|
||||
}
|
||||
|
||||
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<int>(
|
||||
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(
|
||||
'<div class="image-caption">${const HtmlEscape().convert(text)}</div>',
|
||||
);
|
||||
}
|
||||
|
||||
static String _decodeImageCaption(String line) {
|
||||
return line
|
||||
.replaceFirst('<div class="image-caption">', '')
|
||||
.replaceFirst('</div>', '')
|
||||
.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<String, Object?>.from(jsonDecode(decoded) as Map),
|
||||
);
|
||||
}
|
||||
}
|
||||
content = content.substring(end + 5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
final blocks = content.split(RegExp(r'\n---\n'));
|
||||
final slides = <Slide>[];
|
||||
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'<!--\s*_class:\s*([^>]+?)\s*-->',
|
||||
).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 = <String>[];
|
||||
var bullets2 = <String>[];
|
||||
// bulletsImage slides store their panel width in `<!-- _style:
|
||||
// --image-width: N%; -->`; capture it before the comment is stripped.
|
||||
int styleImageWidth = 0;
|
||||
remaining = remaining.replaceAllMapped(
|
||||
RegExp(r'<!--([\s\S]*?)-->', 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 = <String>[];
|
||||
|
||||
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:  or 
|
||||
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 `` 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('<div class="image-caption">')) {
|
||||
final captionParts = _decodeImageCaption(t).split(' | ');
|
||||
imageCaption = captionParts.isNotEmpty ? captionParts.first : '';
|
||||
imageCaption2 = captionParts.length > 1
|
||||
? captionParts.sublist(1).join(' | ')
|
||||
: '';
|
||||
} else if (t.startsWith('<video')) {
|
||||
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||
if (m != null) videoPath = m.group(1) ?? '';
|
||||
videoAutoplay = t.contains('autoplay');
|
||||
} else if (t.startsWith('<audio')) {
|
||||
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||
if (m != null) audioPath = m.group(1) ?? '';
|
||||
audioAutoplay = t.contains('autoplay');
|
||||
} else if (t.isNotEmpty && h1.isNotEmpty && paragraph.isEmpty) {
|
||||
paragraph = t;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageSize == 0 && styleImageWidth > 0) imageSize = styleImageWidth;
|
||||
|
||||
final tableRows = <List<String>>[];
|
||||
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 [],
|
||||
);
|
||||
}
|
||||
}
|
||||
122
lib/services/recovery_service.dart
Normal file
|
|
@ -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<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'savedAt': savedAt.toIso8601String(),
|
||||
'filePath': filePath,
|
||||
'label': label,
|
||||
'markdown': markdown,
|
||||
};
|
||||
|
||||
static RecoverySnapshot fromJson(Map<String, Object?> 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 `<id>.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<Directory> Function() _resolveDir;
|
||||
|
||||
RecoveryService({Directory? baseDir})
|
||||
: _resolveDir = baseDir != null ? (() async => baseDir) : _defaultDir;
|
||||
|
||||
static Future<Directory> _defaultDir() async {
|
||||
final support = await getApplicationSupportDirectory();
|
||||
return Directory(p.join(support.path, 'recovery'));
|
||||
}
|
||||
|
||||
Future<Directory> _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<void> 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<void> discard(String id) async {
|
||||
try {
|
||||
final file = _file(await _dir(), id);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<List<RecoverySnapshot>> loadAll() async {
|
||||
try {
|
||||
final dir = await _dir();
|
||||
final out = <RecoverySnapshot>[];
|
||||
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<String, Object?>.from(data)));
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return out;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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>(
|
||||
(_) => RecoveryService(),
|
||||
);
|
||||
172
lib/services/slide_rasterizer.dart
Normal file
|
|
@ -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<List<Uint8List>> rasterize({
|
||||
required BuildContext context,
|
||||
required List<Slide> 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 = <Uint8List>[];
|
||||
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<Uint8List> _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<void>.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<void> _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<void> _precachePaths(
|
||||
BuildContext context,
|
||||
List<String?> paths,
|
||||
) async {
|
||||
final unique = paths.whereType<String>().toSet();
|
||||
final futures = <Future<void>>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
514
lib/state/deck_provider.dart
Normal file
|
|
@ -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>(
|
||||
(_) => MarkdownService(),
|
||||
);
|
||||
final imageServiceProvider = Provider<ImageService>((_) => ImageService());
|
||||
final fileServiceProvider = Provider<FileService>((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<DeckState> {
|
||||
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<Deck> _undoStack = [];
|
||||
final List<Deck> _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<void> 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<bool> save({String? initialDirectory}) async {
|
||||
if (state.filePath != null) {
|
||||
return _saveToPath(state.filePath!);
|
||||
} else {
|
||||
return saveAs(initialDirectory: initialDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> 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<bool> _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<Slide>.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<Slide>.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<int> 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<int> 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<Slide> newSlides, {int? afterIndex}) {
|
||||
final deck = state.deck;
|
||||
if (deck == null || newSlides.isEmpty) return -1;
|
||||
final slides = List<Slide>.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<Slide>.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<Slide>.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<Slide>.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<Slide>.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<String> _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<DeckNotifier, DeckState>((ref) {
|
||||
return DeckNotifier(
|
||||
ref.read(markdownServiceProvider),
|
||||
ref.read(fileServiceProvider),
|
||||
);
|
||||
});
|
||||
122
lib/state/editor_provider.dart
Normal file
|
|
@ -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<int> 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<int>? 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<EditorState> {
|
||||
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<int>.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, EditorState>(
|
||||
(_) => EditorNotifier(),
|
||||
);
|
||||
166
lib/state/settings_provider.dart
Normal file
|
|
@ -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<AppSettings> {
|
||||
SettingsNotifier() : super(const AppSettings()) {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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<String, Object?>.from(jsonDecode(themeJson) as Map),
|
||||
),
|
||||
]
|
||||
: (jsonDecode(profilesJson) as List)
|
||||
.map(
|
||||
(item) => ThemeProfile.fromJson(
|
||||
Map<String, Object?>.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<void> 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<void> 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<void> 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<void> 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<ThemeProfile> 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<void> 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<void> _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<ThemeProfile> _uniqueProfiles(List<ThemeProfile> profiles) {
|
||||
final result = <ThemeProfile>[];
|
||||
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<ThemeProfile>? 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, AppSettings>(
|
||||
(_) => SettingsNotifier(),
|
||||
);
|
||||
6
lib/state/slide_clipboard_provider.dart
Normal file
|
|
@ -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<Slide?>((ref) => null);
|
||||
301
lib/state/tabs_provider.dart
Normal file
|
|
@ -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<TabInfo> 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<TabInfo>? tabs, int? selectedIndex}) {
|
||||
return TabsState(
|
||||
tabs: tabs ?? this.tabs,
|
||||
selectedIndex: selectedIndex ?? this.selectedIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tabs notifier ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TabsNotifier extends StateNotifier<TabsState> {
|
||||
final MarkdownService _md;
|
||||
final FileService _file;
|
||||
final SettingsNotifier _settings;
|
||||
final RecoveryService _recovery;
|
||||
final Map<int, StreamSubscription<DeckState>> _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<RecoverySnapshot> snapshots) {
|
||||
final restored = <TabInfo>[];
|
||||
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<void> 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<void> 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<String> _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<bool> 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<bool> 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<TabInfo>.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<TabsNotifier, TabsState>((ref) {
|
||||
return TabsNotifier(
|
||||
ref.read(markdownServiceProvider),
|
||||
ref.read(fileServiceProvider),
|
||||
ref.read(settingsProvider.notifier),
|
||||
ref.read(recoveryServiceProvider),
|
||||
);
|
||||
});
|
||||
68
lib/theme/app_theme.dart
Normal file
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
lib/utils/url_launcher_util.dart
Normal file
|
|
@ -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<void> 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.
|
||||
}
|
||||
}
|
||||
1458
lib/widgets/app_shell.dart
Normal file
110
lib/widgets/dialogs/add_slide_dialog.dart
Normal file
|
|
@ -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<SlideType?> show(BuildContext context) {
|
||||
return showDialog<SlideType>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
lib/widgets/dialogs/export_dialog.dart
Normal file
|
|
@ -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<Slide> 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<void> show(
|
||||
BuildContext context, {
|
||||
required String deckPath,
|
||||
required List<Slide> 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<ExportDialog> createState() => _ExportDialogState();
|
||||
}
|
||||
|
||||
class _ExportDialogState extends State<ExportDialog> {
|
||||
bool _loading = false;
|
||||
String? _result;
|
||||
bool _success = false;
|
||||
String _phase = '';
|
||||
int _done = 0;
|
||||
int _total = 0;
|
||||
|
||||
Future<void> _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;
|
||||
}
|
||||
}
|
||||
}
|
||||
178
lib/widgets/dialogs/find_replace_dialog.dart
Normal file
|
|
@ -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<void> show(
|
||||
BuildContext context, {
|
||||
required MatchCounter countMatches,
|
||||
required ReplaceRunner replaceAll,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
FindReplaceDialog(countMatches: countMatches, replaceAll: replaceAll),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<FindReplaceDialog> createState() => _FindReplaceDialogState();
|
||||
}
|
||||
|
||||
class _FindReplaceDialogState extends State<FindReplaceDialog> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1619
lib/widgets/dialogs/image_carousel_picker.dart
Normal file
466
lib/widgets/dialogs/import_slides_dialog.dart
Normal file
|
|
@ -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<List<Slide>?> show(
|
||||
BuildContext context, {
|
||||
required FileService fileService,
|
||||
required String? initialDirectory,
|
||||
String? excludePath,
|
||||
}) {
|
||||
return showDialog<List<Slide>>(
|
||||
context: context,
|
||||
builder: (_) => ImportSlidesDialog(
|
||||
fileService: fileService,
|
||||
initialDirectory: initialDirectory,
|
||||
excludePath: excludePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ImportSlidesDialog> createState() => _ImportSlidesDialogState();
|
||||
}
|
||||
|
||||
class _ImportSlidesDialogState extends State<ImportSlidesDialog> {
|
||||
String? _directory;
|
||||
bool _loading = false;
|
||||
List<ScannedPresentation> _presentations = const [];
|
||||
final Set<String> _selectedIds = {};
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_directory = widget.initialDirectory;
|
||||
if (_directory != null) _scan();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<Slide>)> _visible() {
|
||||
final q = _query.trim().toLowerCase();
|
||||
final out = <(ScannedPresentation, List<Slide>)>[];
|
||||
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<Slide> _collectSelected() {
|
||||
final result = <Slide>[];
|
||||
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<Slide>)> 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<Slide> slides;
|
||||
final Set<String> selectedIds;
|
||||
final ValueChanged<Slide> onToggle;
|
||||
final ValueChanged<bool> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/widgets/dialogs/new_deck_dialog.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class NewDeckDialog extends StatefulWidget {
|
||||
const NewDeckDialog({super.key});
|
||||
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const NewDeckDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<NewDeckDialog> createState() => _NewDeckDialogState();
|
||||
}
|
||||
|
||||
class _NewDeckDialogState extends State<NewDeckDialog> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
}
|
||||
422
lib/widgets/dialogs/open_presentation_dialog.dart
Normal file
|
|
@ -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<OpenSearchResult?> show(
|
||||
BuildContext context, {
|
||||
required FileService fileService,
|
||||
required String? initialDirectory,
|
||||
}) {
|
||||
return showDialog<OpenSearchResult>(
|
||||
context: context,
|
||||
builder: (_) => OpenPresentationDialog(
|
||||
fileService: fileService,
|
||||
initialDirectory: initialDirectory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<OpenPresentationDialog> createState() => _OpenPresentationDialogState();
|
||||
}
|
||||
|
||||
class _OpenPresentationDialogState extends State<OpenPresentationDialog> {
|
||||
String? _directory;
|
||||
bool _loading = false;
|
||||
List<ScannedPresentation> _presentations = const [];
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_directory = widget.initialDirectory;
|
||||
if (_directory != null) _scan();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _pickDirectory() async {
|
||||
final result = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Map met presentaties kiezen',
|
||||
initialDirectory: _directory,
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() => _directory = result);
|
||||
await _scan();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<int> 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
lib/widgets/dialogs/presentation_info_dialog.dart
Normal file
|
|
@ -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<PresentationInfo?> show(BuildContext context, Deck deck) {
|
||||
return showDialog<PresentationInfo>(
|
||||
context: context,
|
||||
builder: (_) => PresentationInfoDialog(deck: deck),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<PresentationInfoDialog> createState() => _PresentationInfoDialogState();
|
||||
}
|
||||
|
||||
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||