Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Allow builds to find SQLite / Flutter artifacts behind a corporate proxy.
|
||||
# Configure repo/org secrets HTTP_PROXY / HTTPS_PROXY in Gitea if needed.
|
||||
env:
|
||||
FLUTTER_VERSION: "3.41.4"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (Flutter)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Verify formatting
|
||||
run: dart format --output=none --set-exit-if-changed lib test
|
||||
|
||||
- name: Static analysis
|
||||
run: flutter analyze
|
||||
|
||||
test:
|
||||
name: Test (Flutter, Linux)
|
||||
runs-on: ubuntu-latest
|
||||
needs: analyze
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
# The sqlite3 Dart package downloads a precompiled binary from GitHub
|
||||
# releases when building native assets. On Linux CI we instead link the
|
||||
# system libsqlite3 to avoid the download (faster + works offline).
|
||||
# This override is applied only in CI; the committed pubspec.yaml stays
|
||||
# clean so the Windows build downloads the bundled sqlite3.dll normally.
|
||||
- name: Install system SQLite
|
||||
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
|
||||
|
||||
- name: Use system SQLite for native assets (CI only)
|
||||
run: |
|
||||
cat >> pubspec.yaml <<'EOF'
|
||||
|
||||
hooks:
|
||||
user_defines:
|
||||
sqlite3:
|
||||
source: system
|
||||
EOF
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test --reporter expanded
|
||||
|
||||
server:
|
||||
name: Test (Server, optional)
|
||||
runs-on: ubuntu-latest
|
||||
# The server is an optional/experimental backend. Keep it from blocking
|
||||
# the pipeline, but still surface failures.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: server
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: server
|
||||
run: pytest -q
|
||||
57
.gitea/workflows/windows-build.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Windows Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.41.4"
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows (x64)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Enable Windows desktop
|
||||
run: flutter config --enable-windows-desktop
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
# The sqlite3 native asset downloads a precompiled DLL from GitHub
|
||||
# releases. If the runner is behind a firewall, set HTTP_PROXY /
|
||||
# HTTPS_PROXY as repository secrets and they will be honoured here.
|
||||
- name: Build Windows release
|
||||
env:
|
||||
HTTP_PROXY: ${{ secrets.HTTP_PROXY }}
|
||||
HTTPS_PROXY: ${{ secrets.HTTPS_PROXY }}
|
||||
run: flutter build windows --release
|
||||
|
||||
- name: Package artifact
|
||||
run: |
|
||||
$dir = "build\windows\x64\runner\Release"
|
||||
Compress-Archive -Path "$dir\*" -DestinationPath "badnote-windows-x64.zip" -Force
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: badnote-windows-x64
|
||||
path: badnote-windows-x64.zip
|
||||
if-no-files-found: error
|
||||
61
.gitignore
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
# 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
|
||||
|
||||
# OMC orchestration state
|
||||
.omc/
|
||||
|
||||
# Local-only sqlite3 override for offline/firewalled test runs
|
||||
.local-sqlite/
|
||||
|
||||
# Python server artifacts
|
||||
server/.venv/
|
||||
server/.omc/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
server/data/
|
||||
server/*.db
|
||||
server/.env
|
||||
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: "ff37bef603469fb030f2b72995ab929ccfc227f0"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: android
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: ios
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: linux
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: macos
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: web
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
- platform: windows
|
||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||
|
||||
# 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'
|
||||
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# BadNote
|
||||
|
||||
Local-first Surface Pen note-taking app with PDF/PPT annotation.
|
||||
|
||||
All notes, documents, search, and OCR run on your device. No server is required to use the app.
|
||||
|
||||
## Features
|
||||
|
||||
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo)
|
||||
- PDF and PPT import with page-level annotation
|
||||
- Full-text search over note titles, typed text, and OCR results
|
||||
- **Local OCR** — handwriting recognition via Windows built-in OCR (Windows desktop)
|
||||
|
||||
## Build (Windows)
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Flutter SDK (3.10+)
|
||||
- Visual Studio Build Tools with **Desktop development with C++**
|
||||
- Developer Mode enabled (for Flutter plugin symlinks)
|
||||
|
||||
```powershell
|
||||
flutter pub get
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
Output: `build\windows\x64\runner\Release\badnote.exe`
|
||||
|
||||
If native asset downloads fail (e.g. sqlite3), set a proxy before building:
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY="http://127.0.0.1:7890"
|
||||
$env:HTTPS_PROXY="http://127.0.0.1:7890"
|
||||
flutter build windows --release
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/
|
||||
├── screens/ # UI (notes, PDF/PPT annotator, search, settings)
|
||||
├── services/ # Local business logic
|
||||
│ ├── database_service.dart # SQLite + FTS5
|
||||
│ ├── ocr_service.dart # Local OCR orchestration
|
||||
│ ├── stroke_rasterizer.dart # Ink → PNG for OCR
|
||||
│ └── ocr_engine.dart # Platform OCR bridge
|
||||
├── providers/ # Riverpod state
|
||||
└── widgets/ # Ink canvas, toolbars, thumbnails
|
||||
```
|
||||
|
||||
OCR flow on save:
|
||||
|
||||
1. Extract typed text from text-tool strokes
|
||||
2. Rasterize handwriting strokes to PNG
|
||||
3. Run Windows OCR on the PNG
|
||||
4. Merge recognized text into the local FTS index for search
|
||||
|
||||
## Optional server
|
||||
|
||||
The `server/` directory contains an experimental FastAPI backend (sync + EasyOCR). It is **not required** for the desktop app and is kept separately for future multi-device sync experiments. See [server/README.md](server/README.md).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
flutter run -d windows
|
||||
flutter test
|
||||
```
|
||||
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
|
||||
44
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.badnote.badnote"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.badnote.badnote"
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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="badnote"
|
||||
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.badnote.badnote
|
||||
|
||||
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.0 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)
|
||||
}
|
||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
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-8.14-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 "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
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
|
||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.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"
|
||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,616 @@
|
||||
// !$*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 */; };
|
||||
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>"; };
|
||||
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 = (
|
||||
);
|
||||
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 = (
|
||||
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 */,
|
||||
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;
|
||||
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;
|
||||
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 */,
|
||||
);
|
||||
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.badnote.badnote;
|
||||
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.badnote.badnote.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.badnote.badnote.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.badnote.badnote.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.badnote.badnote;
|
||||
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.badnote.badnote;
|
||||
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 */
|
||||
};
|
||||
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>
|
||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal file
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<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>
|
||||
13
ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
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: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 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>
|
||||
49
ios/Runner/Info.plist
Normal file
@@ -0,0 +1,49 @@
|
||||
<?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>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Badnote</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>badnote</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>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>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
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.
|
||||
}
|
||||
|
||||
}
|
||||
55
lib/main.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'services/database_service.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Ensure DB is ready before the app starts so providers can use it eagerly
|
||||
await DatabaseService.getInstance();
|
||||
|
||||
// Initialize SharedPreferences
|
||||
await SharedPreferences.getInstance();
|
||||
|
||||
runApp(const ProviderScope(child: BadNoteApp()));
|
||||
}
|
||||
|
||||
class BadNoteApp extends ConsumerWidget {
|
||||
const BadNoteApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'BadNote',
|
||||
themeMode: settings.themeMode,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: Brightness.light).textTheme,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: settings.colorSchemeSeed,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: Brightness.dark).textTheme,
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/models/bookmark.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'bookmark.freezed.dart';
|
||||
part 'bookmark.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Bookmark with _$Bookmark {
|
||||
const factory Bookmark({
|
||||
required String id,
|
||||
required String documentId,
|
||||
required int pageNumber,
|
||||
@Default('') String label,
|
||||
@Default(0xFF2196F3) int color,
|
||||
required DateTime createdAt,
|
||||
}) = _Bookmark;
|
||||
|
||||
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
||||
_$BookmarkFromJson(json);
|
||||
}
|
||||
290
lib/models/bookmark.freezed.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'bookmark.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
|
||||
return _Bookmark.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Bookmark {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get documentId => throw _privateConstructorUsedError;
|
||||
int get pageNumber => throw _privateConstructorUsedError;
|
||||
String get label => throw _privateConstructorUsedError;
|
||||
int get color => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this Bookmark to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$BookmarkCopyWith<Bookmark> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $BookmarkCopyWith<$Res> {
|
||||
factory $BookmarkCopyWith(Bookmark value, $Res Function(Bookmark) then) =
|
||||
_$BookmarkCopyWithImpl<$Res, Bookmark>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String label,
|
||||
int color,
|
||||
DateTime createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
||||
implements $BookmarkCopyWith<$Res> {
|
||||
_$BookmarkCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? documentId = null,
|
||||
Object? pageNumber = null,
|
||||
Object? label = null,
|
||||
Object? color = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
documentId: null == documentId
|
||||
? _value.documentId
|
||||
: documentId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
pageNumber: null == pageNumber
|
||||
? _value.pageNumber
|
||||
: pageNumber // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
label: null == label
|
||||
? _value.label
|
||||
: label // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
color: null == color
|
||||
? _value.color
|
||||
: color // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$BookmarkImplCopyWith<$Res>
|
||||
implements $BookmarkCopyWith<$Res> {
|
||||
factory _$$BookmarkImplCopyWith(
|
||||
_$BookmarkImpl value,
|
||||
$Res Function(_$BookmarkImpl) then,
|
||||
) = __$$BookmarkImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String label,
|
||||
int color,
|
||||
DateTime createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$BookmarkImplCopyWithImpl<$Res>
|
||||
extends _$BookmarkCopyWithImpl<$Res, _$BookmarkImpl>
|
||||
implements _$$BookmarkImplCopyWith<$Res> {
|
||||
__$$BookmarkImplCopyWithImpl(
|
||||
_$BookmarkImpl _value,
|
||||
$Res Function(_$BookmarkImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? documentId = null,
|
||||
Object? pageNumber = null,
|
||||
Object? label = null,
|
||||
Object? color = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$BookmarkImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
documentId: null == documentId
|
||||
? _value.documentId
|
||||
: documentId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
pageNumber: null == pageNumber
|
||||
? _value.pageNumber
|
||||
: pageNumber // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
label: null == label
|
||||
? _value.label
|
||||
: label // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
color: null == color
|
||||
? _value.color
|
||||
: color // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$BookmarkImpl implements _Bookmark {
|
||||
const _$BookmarkImpl({
|
||||
required this.id,
|
||||
required this.documentId,
|
||||
required this.pageNumber,
|
||||
this.label = '',
|
||||
this.color = 0xFF2196F3,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$BookmarkImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String documentId;
|
||||
@override
|
||||
final int pageNumber;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String label;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int color;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$BookmarkImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.documentId, documentId) ||
|
||||
other.documentId == documentId) &&
|
||||
(identical(other.pageNumber, pageNumber) ||
|
||||
other.pageNumber == pageNumber) &&
|
||||
(identical(other.label, label) || other.label == label) &&
|
||||
(identical(other.color, color) || other.color == color) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
documentId,
|
||||
pageNumber,
|
||||
label,
|
||||
color,
|
||||
createdAt,
|
||||
);
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$BookmarkImplCopyWith<_$BookmarkImpl> get copyWith =>
|
||||
__$$BookmarkImplCopyWithImpl<_$BookmarkImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$BookmarkImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _Bookmark implements Bookmark {
|
||||
const factory _Bookmark({
|
||||
required final String id,
|
||||
required final String documentId,
|
||||
required final int pageNumber,
|
||||
final String label,
|
||||
final int color,
|
||||
required final DateTime createdAt,
|
||||
}) = _$BookmarkImpl;
|
||||
|
||||
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
||||
_$BookmarkImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get documentId;
|
||||
@override
|
||||
int get pageNumber;
|
||||
@override
|
||||
String get label;
|
||||
@override
|
||||
int get color;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$BookmarkImplCopyWith<_$BookmarkImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
27
lib/models/bookmark.g.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bookmark.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
|
||||
_$BookmarkImpl(
|
||||
id: json['id'] as String,
|
||||
documentId: json['documentId'] as String,
|
||||
pageNumber: (json['pageNumber'] as num).toInt(),
|
||||
label: json['label'] as String? ?? '',
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'documentId': instance.documentId,
|
||||
'pageNumber': instance.pageNumber,
|
||||
'label': instance.label,
|
||||
'color': instance.color,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
21
lib/models/document.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'document.freezed.dart';
|
||||
part 'document.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Document with _$Document {
|
||||
const factory Document({
|
||||
required String id,
|
||||
required String filename,
|
||||
required String docType,
|
||||
required String filePath,
|
||||
@Default(0) int pageCount,
|
||||
@Default(0) int rotation,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
}) = _Document;
|
||||
|
||||
factory Document.fromJson(Map<String, dynamic> json) =>
|
||||
_$DocumentFromJson(json);
|
||||
}
|
||||
335
lib/models/document.freezed.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'document.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
Document _$DocumentFromJson(Map<String, dynamic> json) {
|
||||
return _Document.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Document {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get filename => throw _privateConstructorUsedError;
|
||||
String get docType => throw _privateConstructorUsedError;
|
||||
String get filePath => throw _privateConstructorUsedError;
|
||||
int get pageCount => throw _privateConstructorUsedError;
|
||||
int get rotation => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this Document to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of Document
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$DocumentCopyWith<Document> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $DocumentCopyWith<$Res> {
|
||||
factory $DocumentCopyWith(Document value, $Res Function(Document) then) =
|
||||
_$DocumentCopyWithImpl<$Res, Document>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String filename,
|
||||
String docType,
|
||||
String filePath,
|
||||
int pageCount,
|
||||
int rotation,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$DocumentCopyWithImpl<$Res, $Val extends Document>
|
||||
implements $DocumentCopyWith<$Res> {
|
||||
_$DocumentCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of Document
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? filename = null,
|
||||
Object? docType = null,
|
||||
Object? filePath = null,
|
||||
Object? pageCount = null,
|
||||
Object? rotation = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
filename: null == filename
|
||||
? _value.filename
|
||||
: filename // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
docType: null == docType
|
||||
? _value.docType
|
||||
: docType // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
filePath: null == filePath
|
||||
? _value.filePath
|
||||
: filePath // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
pageCount: null == pageCount
|
||||
? _value.pageCount
|
||||
: pageCount // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
rotation: null == rotation
|
||||
? _value.rotation
|
||||
: rotation // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$DocumentImplCopyWith<$Res>
|
||||
implements $DocumentCopyWith<$Res> {
|
||||
factory _$$DocumentImplCopyWith(
|
||||
_$DocumentImpl value,
|
||||
$Res Function(_$DocumentImpl) then,
|
||||
) = __$$DocumentImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String filename,
|
||||
String docType,
|
||||
String filePath,
|
||||
int pageCount,
|
||||
int rotation,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$DocumentImplCopyWithImpl<$Res>
|
||||
extends _$DocumentCopyWithImpl<$Res, _$DocumentImpl>
|
||||
implements _$$DocumentImplCopyWith<$Res> {
|
||||
__$$DocumentImplCopyWithImpl(
|
||||
_$DocumentImpl _value,
|
||||
$Res Function(_$DocumentImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of Document
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? filename = null,
|
||||
Object? docType = null,
|
||||
Object? filePath = null,
|
||||
Object? pageCount = null,
|
||||
Object? rotation = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$DocumentImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
filename: null == filename
|
||||
? _value.filename
|
||||
: filename // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
docType: null == docType
|
||||
? _value.docType
|
||||
: docType // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
filePath: null == filePath
|
||||
? _value.filePath
|
||||
: filePath // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
pageCount: null == pageCount
|
||||
? _value.pageCount
|
||||
: pageCount // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
rotation: null == rotation
|
||||
? _value.rotation
|
||||
: rotation // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$DocumentImpl implements _Document {
|
||||
const _$DocumentImpl({
|
||||
required this.id,
|
||||
required this.filename,
|
||||
required this.docType,
|
||||
required this.filePath,
|
||||
this.pageCount = 0,
|
||||
this.rotation = 0,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory _$DocumentImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$DocumentImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String filename;
|
||||
@override
|
||||
final String docType;
|
||||
@override
|
||||
final String filePath;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int pageCount;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int rotation;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
final DateTime updatedAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Document(id: $id, filename: $filename, docType: $docType, filePath: $filePath, pageCount: $pageCount, rotation: $rotation, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$DocumentImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.filename, filename) ||
|
||||
other.filename == filename) &&
|
||||
(identical(other.docType, docType) || other.docType == docType) &&
|
||||
(identical(other.filePath, filePath) ||
|
||||
other.filePath == filePath) &&
|
||||
(identical(other.pageCount, pageCount) ||
|
||||
other.pageCount == pageCount) &&
|
||||
(identical(other.rotation, rotation) ||
|
||||
other.rotation == rotation) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
filename,
|
||||
docType,
|
||||
filePath,
|
||||
pageCount,
|
||||
rotation,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
);
|
||||
|
||||
/// Create a copy of Document
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$DocumentImplCopyWith<_$DocumentImpl> get copyWith =>
|
||||
__$$DocumentImplCopyWithImpl<_$DocumentImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$DocumentImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _Document implements Document {
|
||||
const factory _Document({
|
||||
required final String id,
|
||||
required final String filename,
|
||||
required final String docType,
|
||||
required final String filePath,
|
||||
final int pageCount,
|
||||
final int rotation,
|
||||
required final DateTime createdAt,
|
||||
required final DateTime updatedAt,
|
||||
}) = _$DocumentImpl;
|
||||
|
||||
factory _Document.fromJson(Map<String, dynamic> json) =
|
||||
_$DocumentImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get filename;
|
||||
@override
|
||||
String get docType;
|
||||
@override
|
||||
String get filePath;
|
||||
@override
|
||||
int get pageCount;
|
||||
@override
|
||||
int get rotation;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
DateTime get updatedAt;
|
||||
|
||||
/// Create a copy of Document
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$DocumentImplCopyWith<_$DocumentImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
31
lib/models/document.g.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'document.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$DocumentImpl _$$DocumentImplFromJson(Map<String, dynamic> json) =>
|
||||
_$DocumentImpl(
|
||||
id: json['id'] as String,
|
||||
filename: json['filename'] as String,
|
||||
docType: json['docType'] as String,
|
||||
filePath: json['filePath'] as String,
|
||||
pageCount: (json['pageCount'] as num?)?.toInt() ?? 0,
|
||||
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$DocumentImplToJson(_$DocumentImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'filename': instance.filename,
|
||||
'docType': instance.docType,
|
||||
'filePath': instance.filePath,
|
||||
'pageCount': instance.pageCount,
|
||||
'rotation': instance.rotation,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||
};
|
||||
21
lib/models/ink_point.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'pointer_device_kind.dart';
|
||||
|
||||
part 'ink_point.freezed.dart';
|
||||
part 'ink_point.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class InkPoint with _$InkPoint {
|
||||
const factory InkPoint({
|
||||
required double x,
|
||||
required double y,
|
||||
@Default(0.5) double pressure,
|
||||
@Default(0.0) double tilt,
|
||||
required int timestamp,
|
||||
@Default(InputDeviceKind.unknown) InputDeviceKind pointerDeviceKind,
|
||||
}) = _InkPoint;
|
||||
|
||||
factory InkPoint.fromJson(Map<String, dynamic> json) =>
|
||||
_$InkPointFromJson(json);
|
||||
}
|
||||
291
lib/models/ink_point.freezed.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'ink_point.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
InkPoint _$InkPointFromJson(Map<String, dynamic> json) {
|
||||
return _InkPoint.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$InkPoint {
|
||||
double get x => throw _privateConstructorUsedError;
|
||||
double get y => throw _privateConstructorUsedError;
|
||||
double get pressure => throw _privateConstructorUsedError;
|
||||
double get tilt => throw _privateConstructorUsedError;
|
||||
int get timestamp => throw _privateConstructorUsedError;
|
||||
InputDeviceKind get pointerDeviceKind => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this InkPoint to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of InkPoint
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$InkPointCopyWith<InkPoint> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $InkPointCopyWith<$Res> {
|
||||
factory $InkPointCopyWith(InkPoint value, $Res Function(InkPoint) then) =
|
||||
_$InkPointCopyWithImpl<$Res, InkPoint>;
|
||||
@useResult
|
||||
$Res call({
|
||||
double x,
|
||||
double y,
|
||||
double pressure,
|
||||
double tilt,
|
||||
int timestamp,
|
||||
InputDeviceKind pointerDeviceKind,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$InkPointCopyWithImpl<$Res, $Val extends InkPoint>
|
||||
implements $InkPointCopyWith<$Res> {
|
||||
_$InkPointCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of InkPoint
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? x = null,
|
||||
Object? y = null,
|
||||
Object? pressure = null,
|
||||
Object? tilt = null,
|
||||
Object? timestamp = null,
|
||||
Object? pointerDeviceKind = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
x: null == x
|
||||
? _value.x
|
||||
: x // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
y: null == y
|
||||
? _value.y
|
||||
: y // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
pressure: null == pressure
|
||||
? _value.pressure
|
||||
: pressure // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
tilt: null == tilt
|
||||
? _value.tilt
|
||||
: tilt // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
timestamp: null == timestamp
|
||||
? _value.timestamp
|
||||
: timestamp // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
pointerDeviceKind: null == pointerDeviceKind
|
||||
? _value.pointerDeviceKind
|
||||
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
|
||||
as InputDeviceKind,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$InkPointImplCopyWith<$Res>
|
||||
implements $InkPointCopyWith<$Res> {
|
||||
factory _$$InkPointImplCopyWith(
|
||||
_$InkPointImpl value,
|
||||
$Res Function(_$InkPointImpl) then,
|
||||
) = __$$InkPointImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
double x,
|
||||
double y,
|
||||
double pressure,
|
||||
double tilt,
|
||||
int timestamp,
|
||||
InputDeviceKind pointerDeviceKind,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$InkPointImplCopyWithImpl<$Res>
|
||||
extends _$InkPointCopyWithImpl<$Res, _$InkPointImpl>
|
||||
implements _$$InkPointImplCopyWith<$Res> {
|
||||
__$$InkPointImplCopyWithImpl(
|
||||
_$InkPointImpl _value,
|
||||
$Res Function(_$InkPointImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of InkPoint
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? x = null,
|
||||
Object? y = null,
|
||||
Object? pressure = null,
|
||||
Object? tilt = null,
|
||||
Object? timestamp = null,
|
||||
Object? pointerDeviceKind = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$InkPointImpl(
|
||||
x: null == x
|
||||
? _value.x
|
||||
: x // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
y: null == y
|
||||
? _value.y
|
||||
: y // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
pressure: null == pressure
|
||||
? _value.pressure
|
||||
: pressure // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
tilt: null == tilt
|
||||
? _value.tilt
|
||||
: tilt // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
timestamp: null == timestamp
|
||||
? _value.timestamp
|
||||
: timestamp // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
pointerDeviceKind: null == pointerDeviceKind
|
||||
? _value.pointerDeviceKind
|
||||
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
|
||||
as InputDeviceKind,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$InkPointImpl implements _InkPoint {
|
||||
const _$InkPointImpl({
|
||||
required this.x,
|
||||
required this.y,
|
||||
this.pressure = 0.5,
|
||||
this.tilt = 0.0,
|
||||
required this.timestamp,
|
||||
this.pointerDeviceKind = InputDeviceKind.unknown,
|
||||
});
|
||||
|
||||
factory _$InkPointImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$InkPointImplFromJson(json);
|
||||
|
||||
@override
|
||||
final double x;
|
||||
@override
|
||||
final double y;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double pressure;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double tilt;
|
||||
@override
|
||||
final int timestamp;
|
||||
@override
|
||||
@JsonKey()
|
||||
final InputDeviceKind pointerDeviceKind;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'InkPoint(x: $x, y: $y, pressure: $pressure, tilt: $tilt, timestamp: $timestamp, pointerDeviceKind: $pointerDeviceKind)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$InkPointImpl &&
|
||||
(identical(other.x, x) || other.x == x) &&
|
||||
(identical(other.y, y) || other.y == y) &&
|
||||
(identical(other.pressure, pressure) ||
|
||||
other.pressure == pressure) &&
|
||||
(identical(other.tilt, tilt) || other.tilt == tilt) &&
|
||||
(identical(other.timestamp, timestamp) ||
|
||||
other.timestamp == timestamp) &&
|
||||
(identical(other.pointerDeviceKind, pointerDeviceKind) ||
|
||||
other.pointerDeviceKind == pointerDeviceKind));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
x,
|
||||
y,
|
||||
pressure,
|
||||
tilt,
|
||||
timestamp,
|
||||
pointerDeviceKind,
|
||||
);
|
||||
|
||||
/// Create a copy of InkPoint
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$InkPointImplCopyWith<_$InkPointImpl> get copyWith =>
|
||||
__$$InkPointImplCopyWithImpl<_$InkPointImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$InkPointImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _InkPoint implements InkPoint {
|
||||
const factory _InkPoint({
|
||||
required final double x,
|
||||
required final double y,
|
||||
final double pressure,
|
||||
final double tilt,
|
||||
required final int timestamp,
|
||||
final InputDeviceKind pointerDeviceKind,
|
||||
}) = _$InkPointImpl;
|
||||
|
||||
factory _InkPoint.fromJson(Map<String, dynamic> json) =
|
||||
_$InkPointImpl.fromJson;
|
||||
|
||||
@override
|
||||
double get x;
|
||||
@override
|
||||
double get y;
|
||||
@override
|
||||
double get pressure;
|
||||
@override
|
||||
double get tilt;
|
||||
@override
|
||||
int get timestamp;
|
||||
@override
|
||||
InputDeviceKind get pointerDeviceKind;
|
||||
|
||||
/// Create a copy of InkPoint
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$InkPointImplCopyWith<_$InkPointImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
42
lib/models/ink_point.g.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ink_point.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$InkPointImpl _$$InkPointImplFromJson(Map<String, dynamic> json) =>
|
||||
_$InkPointImpl(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
|
||||
tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0,
|
||||
timestamp: (json['timestamp'] as num).toInt(),
|
||||
pointerDeviceKind:
|
||||
$enumDecodeNullable(
|
||||
_$InputDeviceKindEnumMap,
|
||||
json['pointerDeviceKind'],
|
||||
) ??
|
||||
InputDeviceKind.unknown,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$InkPointImplToJson(
|
||||
_$InkPointImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'x': instance.x,
|
||||
'y': instance.y,
|
||||
'pressure': instance.pressure,
|
||||
'tilt': instance.tilt,
|
||||
'timestamp': instance.timestamp,
|
||||
'pointerDeviceKind': _$InputDeviceKindEnumMap[instance.pointerDeviceKind]!,
|
||||
};
|
||||
|
||||
const _$InputDeviceKindEnumMap = {
|
||||
InputDeviceKind.touch: 'touch',
|
||||
InputDeviceKind.mouse: 'mouse',
|
||||
InputDeviceKind.stylus: 'stylus',
|
||||
InputDeviceKind.invertedStylus: 'invertedStylus',
|
||||
InputDeviceKind.trackpad: 'trackpad',
|
||||
InputDeviceKind.unknown: 'unknown',
|
||||
};
|
||||
25
lib/models/ink_stroke.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'ink_point.dart';
|
||||
import 'pen_tool.dart';
|
||||
|
||||
part 'ink_stroke.freezed.dart';
|
||||
part 'ink_stroke.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class InkStroke with _$InkStroke {
|
||||
const factory InkStroke({
|
||||
required String id,
|
||||
required List<InkPoint> points,
|
||||
@Default(PenTool.pen) PenTool tool,
|
||||
@Default(0xFF000000) int color,
|
||||
@Default(2.0) double strokeWidth,
|
||||
required DateTime createdAt,
|
||||
@Default(false) bool filled,
|
||||
String? textContent,
|
||||
@Default(14.0) double fontSize,
|
||||
}) = _InkStroke;
|
||||
|
||||
factory InkStroke.fromJson(Map<String, dynamic> json) =>
|
||||
_$InkStrokeFromJson(json);
|
||||
}
|
||||
363
lib/models/ink_stroke.freezed.dart
Normal file
@@ -0,0 +1,363 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'ink_stroke.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
InkStroke _$InkStrokeFromJson(Map<String, dynamic> json) {
|
||||
return _InkStroke.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$InkStroke {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
List<InkPoint> get points => throw _privateConstructorUsedError;
|
||||
PenTool get tool => throw _privateConstructorUsedError;
|
||||
int get color => throw _privateConstructorUsedError;
|
||||
double get strokeWidth => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
bool get filled => throw _privateConstructorUsedError;
|
||||
String? get textContent => throw _privateConstructorUsedError;
|
||||
double get fontSize => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this InkStroke to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of InkStroke
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$InkStrokeCopyWith<InkStroke> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $InkStrokeCopyWith<$Res> {
|
||||
factory $InkStrokeCopyWith(InkStroke value, $Res Function(InkStroke) then) =
|
||||
_$InkStrokeCopyWithImpl<$Res, InkStroke>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
int color,
|
||||
double strokeWidth,
|
||||
DateTime createdAt,
|
||||
bool filled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$InkStrokeCopyWithImpl<$Res, $Val extends InkStroke>
|
||||
implements $InkStrokeCopyWith<$Res> {
|
||||
_$InkStrokeCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of InkStroke
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? points = null,
|
||||
Object? tool = null,
|
||||
Object? color = null,
|
||||
Object? strokeWidth = null,
|
||||
Object? createdAt = null,
|
||||
Object? filled = null,
|
||||
Object? textContent = freezed,
|
||||
Object? fontSize = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
points: null == points
|
||||
? _value.points
|
||||
: points // ignore: cast_nullable_to_non_nullable
|
||||
as List<InkPoint>,
|
||||
tool: null == tool
|
||||
? _value.tool
|
||||
: tool // ignore: cast_nullable_to_non_nullable
|
||||
as PenTool,
|
||||
color: null == color
|
||||
? _value.color
|
||||
: color // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
strokeWidth: null == strokeWidth
|
||||
? _value.strokeWidth
|
||||
: strokeWidth // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
filled: null == filled
|
||||
? _value.filled
|
||||
: filled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
textContent: freezed == textContent
|
||||
? _value.textContent
|
||||
: textContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fontSize: null == fontSize
|
||||
? _value.fontSize
|
||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$InkStrokeImplCopyWith<$Res>
|
||||
implements $InkStrokeCopyWith<$Res> {
|
||||
factory _$$InkStrokeImplCopyWith(
|
||||
_$InkStrokeImpl value,
|
||||
$Res Function(_$InkStrokeImpl) then,
|
||||
) = __$$InkStrokeImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
int color,
|
||||
double strokeWidth,
|
||||
DateTime createdAt,
|
||||
bool filled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$InkStrokeImplCopyWithImpl<$Res>
|
||||
extends _$InkStrokeCopyWithImpl<$Res, _$InkStrokeImpl>
|
||||
implements _$$InkStrokeImplCopyWith<$Res> {
|
||||
__$$InkStrokeImplCopyWithImpl(
|
||||
_$InkStrokeImpl _value,
|
||||
$Res Function(_$InkStrokeImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of InkStroke
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? points = null,
|
||||
Object? tool = null,
|
||||
Object? color = null,
|
||||
Object? strokeWidth = null,
|
||||
Object? createdAt = null,
|
||||
Object? filled = null,
|
||||
Object? textContent = freezed,
|
||||
Object? fontSize = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$InkStrokeImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
points: null == points
|
||||
? _value._points
|
||||
: points // ignore: cast_nullable_to_non_nullable
|
||||
as List<InkPoint>,
|
||||
tool: null == tool
|
||||
? _value.tool
|
||||
: tool // ignore: cast_nullable_to_non_nullable
|
||||
as PenTool,
|
||||
color: null == color
|
||||
? _value.color
|
||||
: color // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
strokeWidth: null == strokeWidth
|
||||
? _value.strokeWidth
|
||||
: strokeWidth // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
filled: null == filled
|
||||
? _value.filled
|
||||
: filled // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
textContent: freezed == textContent
|
||||
? _value.textContent
|
||||
: textContent // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
fontSize: null == fontSize
|
||||
? _value.fontSize
|
||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$InkStrokeImpl implements _InkStroke {
|
||||
const _$InkStrokeImpl({
|
||||
required this.id,
|
||||
required final List<InkPoint> points,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = 0xFF000000,
|
||||
this.strokeWidth = 2.0,
|
||||
required this.createdAt,
|
||||
this.filled = false,
|
||||
this.textContent,
|
||||
this.fontSize = 14.0,
|
||||
}) : _points = points;
|
||||
|
||||
factory _$InkStrokeImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$InkStrokeImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
final List<InkPoint> _points;
|
||||
@override
|
||||
List<InkPoint> get points {
|
||||
if (_points is EqualUnmodifiableListView) return _points;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_points);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final PenTool tool;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int color;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double strokeWidth;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool filled;
|
||||
@override
|
||||
final String? textContent;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'InkStroke(id: $id, points: $points, tool: $tool, color: $color, strokeWidth: $strokeWidth, createdAt: $createdAt, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$InkStrokeImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
const DeepCollectionEquality().equals(other._points, _points) &&
|
||||
(identical(other.tool, tool) || other.tool == tool) &&
|
||||
(identical(other.color, color) || other.color == color) &&
|
||||
(identical(other.strokeWidth, strokeWidth) ||
|
||||
other.strokeWidth == strokeWidth) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.filled, filled) || other.filled == filled) &&
|
||||
(identical(other.textContent, textContent) ||
|
||||
other.textContent == textContent) &&
|
||||
(identical(other.fontSize, fontSize) ||
|
||||
other.fontSize == fontSize));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
const DeepCollectionEquality().hash(_points),
|
||||
tool,
|
||||
color,
|
||||
strokeWidth,
|
||||
createdAt,
|
||||
filled,
|
||||
textContent,
|
||||
fontSize,
|
||||
);
|
||||
|
||||
/// Create a copy of InkStroke
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$InkStrokeImplCopyWith<_$InkStrokeImpl> get copyWith =>
|
||||
__$$InkStrokeImplCopyWithImpl<_$InkStrokeImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$InkStrokeImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _InkStroke implements InkStroke {
|
||||
const factory _InkStroke({
|
||||
required final String id,
|
||||
required final List<InkPoint> points,
|
||||
final PenTool tool,
|
||||
final int color,
|
||||
final double strokeWidth,
|
||||
required final DateTime createdAt,
|
||||
final bool filled,
|
||||
final String? textContent,
|
||||
final double fontSize,
|
||||
}) = _$InkStrokeImpl;
|
||||
|
||||
factory _InkStroke.fromJson(Map<String, dynamic> json) =
|
||||
_$InkStrokeImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
List<InkPoint> get points;
|
||||
@override
|
||||
PenTool get tool;
|
||||
@override
|
||||
int get color;
|
||||
@override
|
||||
double get strokeWidth;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
bool get filled;
|
||||
@override
|
||||
String? get textContent;
|
||||
@override
|
||||
double get fontSize;
|
||||
|
||||
/// Create a copy of InkStroke
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$InkStrokeImplCopyWith<_$InkStrokeImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
47
lib/models/ink_stroke.g.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ink_stroke.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$InkStrokeImpl _$$InkStrokeImplFromJson(Map<String, dynamic> json) =>
|
||||
_$InkStrokeImpl(
|
||||
id: json['id'] as String,
|
||||
points: (json['points'] as List<dynamic>)
|
||||
.map((e) => InkPoint.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
tool: $enumDecodeNullable(_$PenToolEnumMap, json['tool']) ?? PenTool.pen,
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
||||
strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 2.0,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
filled: json['filled'] as bool? ?? false,
|
||||
textContent: json['textContent'] as String?,
|
||||
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$InkStrokeImplToJson(_$InkStrokeImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'points': instance.points,
|
||||
'tool': _$PenToolEnumMap[instance.tool]!,
|
||||
'color': instance.color,
|
||||
'strokeWidth': instance.strokeWidth,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'filled': instance.filled,
|
||||
'textContent': instance.textContent,
|
||||
'fontSize': instance.fontSize,
|
||||
};
|
||||
|
||||
const _$PenToolEnumMap = {
|
||||
PenTool.pen: 'pen',
|
||||
PenTool.marker: 'marker',
|
||||
PenTool.eraser: 'eraser',
|
||||
PenTool.highlighter: 'highlighter',
|
||||
PenTool.rectangle: 'rectangle',
|
||||
PenTool.ellipse: 'ellipse',
|
||||
PenTool.line: 'line',
|
||||
PenTool.arrow: 'arrow',
|
||||
PenTool.text: 'text',
|
||||
};
|
||||
20
lib/models/note.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'ink_stroke.dart';
|
||||
|
||||
part 'note.freezed.dart';
|
||||
part 'note.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class Note with _$Note {
|
||||
const factory Note({
|
||||
required String id,
|
||||
@Default('Untitled') String title,
|
||||
@Default([]) List<InkStroke> strokes,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
@Default([]) List<String> tags,
|
||||
}) = _Note;
|
||||
|
||||
factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
|
||||
}
|
||||
297
lib/models/note.freezed.dart
Normal file
@@ -0,0 +1,297 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'note.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
Note _$NoteFromJson(Map<String, dynamic> json) {
|
||||
return _Note.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$Note {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get title => throw _privateConstructorUsedError;
|
||||
List<InkStroke> get strokes => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||
List<String> get tags => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this Note to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of Note
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$NoteCopyWith<Note> get copyWith => throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $NoteCopyWith<$Res> {
|
||||
factory $NoteCopyWith(Note value, $Res Function(Note) then) =
|
||||
_$NoteCopyWithImpl<$Res, Note>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
List<InkStroke> strokes,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
List<String> tags,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$NoteCopyWithImpl<$Res, $Val extends Note>
|
||||
implements $NoteCopyWith<$Res> {
|
||||
_$NoteCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of Note
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? strokes = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? tags = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
strokes: null == strokes
|
||||
? _value.strokes
|
||||
: strokes // ignore: cast_nullable_to_non_nullable
|
||||
as List<InkStroke>,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
tags: null == tags
|
||||
? _value.tags
|
||||
: tags // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$NoteImplCopyWith<$Res> implements $NoteCopyWith<$Res> {
|
||||
factory _$$NoteImplCopyWith(
|
||||
_$NoteImpl value,
|
||||
$Res Function(_$NoteImpl) then,
|
||||
) = __$$NoteImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String id,
|
||||
String title,
|
||||
List<InkStroke> strokes,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
List<String> tags,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$NoteImplCopyWithImpl<$Res>
|
||||
extends _$NoteCopyWithImpl<$Res, _$NoteImpl>
|
||||
implements _$$NoteImplCopyWith<$Res> {
|
||||
__$$NoteImplCopyWithImpl(_$NoteImpl _value, $Res Function(_$NoteImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of Note
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? title = null,
|
||||
Object? strokes = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? tags = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$NoteImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: null == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
strokes: null == strokes
|
||||
? _value._strokes
|
||||
: strokes // ignore: cast_nullable_to_non_nullable
|
||||
as List<InkStroke>,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
tags: null == tags
|
||||
? _value._tags
|
||||
: tags // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$NoteImpl implements _Note {
|
||||
const _$NoteImpl({
|
||||
required this.id,
|
||||
this.title = 'Untitled',
|
||||
final List<InkStroke> strokes = const [],
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
final List<String> tags = const [],
|
||||
}) : _strokes = strokes,
|
||||
_tags = tags;
|
||||
|
||||
factory _$NoteImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$NoteImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
@JsonKey()
|
||||
final String title;
|
||||
final List<InkStroke> _strokes;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<InkStroke> get strokes {
|
||||
if (_strokes is EqualUnmodifiableListView) return _strokes;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_strokes);
|
||||
}
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
final DateTime updatedAt;
|
||||
final List<String> _tags;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<String> get tags {
|
||||
if (_tags is EqualUnmodifiableListView) return _tags;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_tags);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Note(id: $id, title: $title, strokes: $strokes, createdAt: $createdAt, updatedAt: $updatedAt, tags: $tags)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$NoteImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.title, title) || other.title == title) &&
|
||||
const DeepCollectionEquality().equals(other._strokes, _strokes) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt) &&
|
||||
const DeepCollectionEquality().equals(other._tags, _tags));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
title,
|
||||
const DeepCollectionEquality().hash(_strokes),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
const DeepCollectionEquality().hash(_tags),
|
||||
);
|
||||
|
||||
/// Create a copy of Note
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$NoteImplCopyWith<_$NoteImpl> get copyWith =>
|
||||
__$$NoteImplCopyWithImpl<_$NoteImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$NoteImplToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _Note implements Note {
|
||||
const factory _Note({
|
||||
required final String id,
|
||||
final String title,
|
||||
final List<InkStroke> strokes,
|
||||
required final DateTime createdAt,
|
||||
required final DateTime updatedAt,
|
||||
final List<String> tags,
|
||||
}) = _$NoteImpl;
|
||||
|
||||
factory _Note.fromJson(Map<String, dynamic> json) = _$NoteImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
String get title;
|
||||
@override
|
||||
List<InkStroke> get strokes;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
DateTime get updatedAt;
|
||||
@override
|
||||
List<String> get tags;
|
||||
|
||||
/// Create a copy of Note
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$NoteImplCopyWith<_$NoteImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
32
lib/models/note.g.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'note.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$NoteImpl _$$NoteImplFromJson(Map<String, dynamic> json) => _$NoteImpl(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String? ?? 'Untitled',
|
||||
strokes:
|
||||
(json['strokes'] as List<dynamic>?)
|
||||
?.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
tags:
|
||||
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$NoteImplToJson(_$NoteImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'strokes': instance.strokes,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'updatedAt': instance.updatedAt.toIso8601String(),
|
||||
'tags': instance.tags,
|
||||
};
|
||||
11
lib/models/pen_tool.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum PenTool {
|
||||
pen,
|
||||
marker,
|
||||
eraser,
|
||||
highlighter,
|
||||
rectangle,
|
||||
ellipse,
|
||||
line,
|
||||
arrow,
|
||||
text,
|
||||
}
|
||||
16
lib/models/pointer_device_kind.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
enum InputDeviceKind {
|
||||
@JsonValue('touch')
|
||||
touch,
|
||||
@JsonValue('mouse')
|
||||
mouse,
|
||||
@JsonValue('stylus')
|
||||
stylus,
|
||||
@JsonValue('invertedStylus')
|
||||
invertedStylus,
|
||||
@JsonValue('trackpad')
|
||||
trackpad,
|
||||
@JsonValue('unknown')
|
||||
unknown,
|
||||
}
|
||||
62
lib/models/pressure_curve.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Predefined pressure curve types.
|
||||
enum PressureCurveType { linear, soft, hard, custom }
|
||||
|
||||
/// Maps raw pen pressure [0,1] to effective pressure [0,1] using a power curve.
|
||||
///
|
||||
/// - **Linear**: identity (p)
|
||||
/// - **Soft**: `pow(p, 1.5)` — light touch produces small lines, needs more pressure
|
||||
/// - **Hard**: `pow(p, 0.5)` — light touch already produces thick lines
|
||||
/// - **Custom**: `pow(p, exponent)` where exponent is derived from [softness]
|
||||
class PressureCurve {
|
||||
final PressureCurveType type;
|
||||
final double softness;
|
||||
|
||||
const PressureCurve({
|
||||
this.type = PressureCurveType.linear,
|
||||
this.softness = 0.5,
|
||||
});
|
||||
|
||||
/// Predefined linear curve (identity).
|
||||
static const linear = PressureCurve(type: PressureCurveType.linear);
|
||||
|
||||
/// Predefined soft curve — needs more pressure to ramp up.
|
||||
static const soft = PressureCurve(
|
||||
type: PressureCurveType.soft,
|
||||
softness: 0.3,
|
||||
);
|
||||
|
||||
/// Predefined hard curve — light touch already produces thick lines.
|
||||
static const hard = PressureCurve(
|
||||
type: PressureCurveType.hard,
|
||||
softness: 0.7,
|
||||
);
|
||||
|
||||
/// Maps raw pressure [0,1] to effective pressure [0,1].
|
||||
double apply(double rawPressure) {
|
||||
final p = rawPressure.clamp(0.0, 1.0);
|
||||
switch (type) {
|
||||
case PressureCurveType.linear:
|
||||
return p;
|
||||
case PressureCurveType.soft:
|
||||
return pow(p, 1.5).toDouble();
|
||||
case PressureCurveType.hard:
|
||||
return pow(p, 0.5).toDouble();
|
||||
case PressureCurveType.custom:
|
||||
final exponent = softness * 2 + 0.2;
|
||||
return pow(p, exponent).toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PressureCurve &&
|
||||
runtimeType == other.runtimeType &&
|
||||
type == other.type &&
|
||||
softness == other.softness;
|
||||
|
||||
@override
|
||||
int get hashCode => type.hashCode ^ softness.hashCode;
|
||||
}
|
||||
62
lib/providers/document_provider.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/document.dart';
|
||||
import '../services/database_service.dart';
|
||||
import 'note_provider.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
final documentListProvider =
|
||||
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
||||
DocumentListNotifier.new,
|
||||
);
|
||||
|
||||
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Document>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllDocuments();
|
||||
}
|
||||
|
||||
/// Reloads documents from the database and publishes the result to [state]
|
||||
/// so the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<void> loadDocuments() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final db = await _db;
|
||||
return db.getAllDocuments();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Document> addDocument({
|
||||
required String filename,
|
||||
required String docType,
|
||||
required String filePath,
|
||||
int pageCount = 0,
|
||||
}) async {
|
||||
final db = await _db;
|
||||
final now = DateTime.now();
|
||||
final document = Document(
|
||||
id: _uuid.v4(),
|
||||
filename: filename,
|
||||
docType: docType,
|
||||
filePath: filePath,
|
||||
pageCount: pageCount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertDocument(document);
|
||||
state = AsyncData([document, ...state.value ?? []]);
|
||||
return document;
|
||||
}
|
||||
|
||||
Future<void> removeDocument(String id) async {
|
||||
final db = await _db;
|
||||
await db.deleteDocument(id);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.where((d) => d.id != id).toList());
|
||||
}
|
||||
}
|
||||
68
lib/providers/note_provider.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/note.dart';
|
||||
import '../services/database_service.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
|
||||
return DatabaseService.getInstance();
|
||||
});
|
||||
|
||||
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
|
||||
NoteListNotifier.new,
|
||||
);
|
||||
|
||||
class NoteListNotifier extends AsyncNotifier<List<Note>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Note>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
}
|
||||
|
||||
/// Reloads notes from the database and publishes the result to [state] so
|
||||
/// the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<void> loadNotes() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Note> createNote({String title = 'Untitled'}) async {
|
||||
final db = await _db;
|
||||
final now = DateTime.now();
|
||||
final note = Note(
|
||||
id: _uuid.v4(),
|
||||
title: title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertNote(note);
|
||||
state = AsyncData([note, ...state.value ?? []]);
|
||||
return note;
|
||||
}
|
||||
|
||||
Future<void> updateNote(Note note) async {
|
||||
final db = await _db;
|
||||
await db.updateNote(note);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
|
||||
}
|
||||
|
||||
Future<void> deleteNote(String id) async {
|
||||
final db = await _db;
|
||||
await db.deleteNote(id);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.where((n) => n.id != id).toList());
|
||||
}
|
||||
}
|
||||
|
||||
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
|
||||
final db = await ref.watch(databaseServiceProvider.future);
|
||||
return db.getNoteById(id);
|
||||
});
|
||||
43
lib/providers/ocr_provider.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/ocr_service.dart';
|
||||
|
||||
enum OcrStatus { none, processing, done, failed }
|
||||
|
||||
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
|
||||
|
||||
/// Tracks local OCR processing status per note ID.
|
||||
///
|
||||
/// This map only ever holds an entry per note that has had OCR triggered in
|
||||
/// the current session. To keep it from growing without bound over a long
|
||||
/// session, prune terminal/stale entries via [OcrStatusX] (e.g. remove an
|
||||
/// entry once its result has been surfaced, or call [OcrStatusX.pruneOcr]
|
||||
/// after a sweep). Kept as a [StateProvider] so existing call sites that
|
||||
/// assign `ocrStatusProvider.notifier.state` continue to work.
|
||||
final ocrStatusProvider = StateProvider<Map<String, OcrStatus>>((ref) => {});
|
||||
|
||||
/// Pruning helpers for [ocrStatusProvider] that keep its backing map bounded.
|
||||
extension OcrStatusX on Ref {
|
||||
/// Removes the tracked status for [noteId] (e.g. when its note is deleted
|
||||
/// or its result has been consumed by the UI).
|
||||
void clearOcr(String noteId) {
|
||||
final current = read(ocrStatusProvider);
|
||||
if (!current.containsKey(noteId)) return;
|
||||
read(ocrStatusProvider.notifier).state = Map<String, OcrStatus>.from(
|
||||
current,
|
||||
)..remove(noteId);
|
||||
}
|
||||
|
||||
/// Drops all completed/failed entries, keeping only in-flight work so the
|
||||
/// map stays bounded.
|
||||
void pruneOcr() {
|
||||
final current = read(ocrStatusProvider);
|
||||
final next = <String, OcrStatus>{
|
||||
for (final entry in current.entries)
|
||||
if (entry.value == OcrStatus.processing) entry.key: entry.value,
|
||||
};
|
||||
if (next.length != current.length) {
|
||||
read(ocrStatusProvider.notifier).state = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
lib/providers/search_provider.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/document.dart';
|
||||
import '../models/note.dart';
|
||||
import 'note_provider.dart';
|
||||
|
||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
/// A search result that can be either a note hit or a document hit.
|
||||
sealed class SearchResult {
|
||||
const SearchResult();
|
||||
}
|
||||
|
||||
class NoteSearchHit extends SearchResult {
|
||||
final Note note;
|
||||
final String snippet;
|
||||
const NoteSearchHit({required this.note, this.snippet = ''});
|
||||
}
|
||||
|
||||
class DocumentSearchHit extends SearchResult {
|
||||
final String documentId;
|
||||
final String filename;
|
||||
final String filePath;
|
||||
final int pageNumber;
|
||||
final String snippet;
|
||||
const DocumentSearchHit({
|
||||
required this.documentId,
|
||||
required this.filename,
|
||||
required this.filePath,
|
||||
required this.pageNumber,
|
||||
this.snippet = '',
|
||||
});
|
||||
}
|
||||
|
||||
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
// Obtain the DB through the provider graph so this participates in
|
||||
// initialization and disposal like every other consumer.
|
||||
final db = await ref.watch(databaseServiceProvider.future);
|
||||
|
||||
// Run the note and document searches concurrently.
|
||||
final searches = await Future.wait([
|
||||
db.searchNotes(query),
|
||||
db.searchDocuments(query),
|
||||
]);
|
||||
final noteHits = searches[0] as List<Note>;
|
||||
final docHits = searches[1] as List<Map<String, dynamic>>;
|
||||
|
||||
final results = <SearchResult>[];
|
||||
|
||||
// Add note results.
|
||||
for (final note in noteHits) {
|
||||
results.add(NoteSearchHit(note: note, snippet: note.title));
|
||||
}
|
||||
|
||||
// Resolve document metadata without an N+1 loop: collect the distinct
|
||||
// document ids referenced by the hits, look each up exactly once, then
|
||||
// build the result list from the cached lookups.
|
||||
final docIds = <String>{
|
||||
for (final hit in docHits)
|
||||
if (hit['document_id'] is String) hit['document_id'] as String,
|
||||
};
|
||||
final docEntries = await Future.wait(
|
||||
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
|
||||
);
|
||||
final docsById = <String, Document>{
|
||||
for (final entry in docEntries)
|
||||
if (entry.value != null) entry.key: entry.value!,
|
||||
};
|
||||
|
||||
for (final hit in docHits) {
|
||||
final documentId = hit['document_id'];
|
||||
if (documentId is! String) continue;
|
||||
final doc = docsById[documentId];
|
||||
if (doc == null) continue;
|
||||
|
||||
final pageNumber = hit['page_number'];
|
||||
final content = hit['content'];
|
||||
results.add(
|
||||
DocumentSearchHit(
|
||||
documentId: documentId,
|
||||
filename: doc.filename,
|
||||
filePath: doc.filePath,
|
||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
||||
snippet: content is String ? content : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
146
lib/providers/settings_provider.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
final settingsProvider = ChangeNotifierProvider<SettingsNotifier>(
|
||||
(ref) => SettingsNotifier(),
|
||||
);
|
||||
|
||||
/// Persists user settings across sessions using SharedPreferences.
|
||||
class SettingsNotifier extends ChangeNotifier {
|
||||
late SharedPreferences _prefs;
|
||||
|
||||
/// Completes once [_load] has assigned [_prefs]. Setters await this before
|
||||
/// touching [_prefs] to avoid a LateInitializationError when invoked before
|
||||
/// the fire-and-forget load from the constructor finishes.
|
||||
late final Future<void> _ready;
|
||||
|
||||
PenTool _defaultTool = PenTool.pen;
|
||||
Color _defaultColor = Colors.black;
|
||||
double _defaultStrokeWidth = 2.0;
|
||||
PressureCurveType _defaultPressureCurve = PressureCurveType.linear;
|
||||
StabilizationLevel _defaultStabilization = StabilizationLevel.none;
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
Color _colorSchemeSeed = Colors.blue;
|
||||
|
||||
SettingsNotifier() {
|
||||
_ready = _load();
|
||||
}
|
||||
|
||||
PenTool get defaultTool => _defaultTool;
|
||||
Color get defaultColor => _defaultColor;
|
||||
double get defaultStrokeWidth => _defaultStrokeWidth;
|
||||
PressureCurveType get defaultPressureCurve => _defaultPressureCurve;
|
||||
StabilizationLevel get defaultStabilization => _defaultStabilization;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
Color get colorSchemeSeed => _colorSchemeSeed;
|
||||
|
||||
Future<void> setDefaultTool(PenTool tool) async {
|
||||
_defaultTool = tool;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setString('defaultTool', tool.name);
|
||||
}
|
||||
|
||||
Future<void> setDefaultColor(Color color) async {
|
||||
_defaultColor = color;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setInt('defaultColor', color.toARGB32());
|
||||
}
|
||||
|
||||
Future<void> setDefaultStrokeWidth(double width) async {
|
||||
_defaultStrokeWidth = width;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setDouble('defaultStrokeWidth', width);
|
||||
}
|
||||
|
||||
Future<void> setDefaultPressureCurve(PressureCurveType curve) async {
|
||||
_defaultPressureCurve = curve;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setString('defaultPressureCurve', curve.name);
|
||||
}
|
||||
|
||||
Future<void> setDefaultStabilization(StabilizationLevel level) async {
|
||||
_defaultStabilization = level;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setString('defaultStabilization', level.name);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
_themeMode = mode;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setString('themeMode', mode.name);
|
||||
}
|
||||
|
||||
Future<void> setColorSchemeSeed(Color color) async {
|
||||
_colorSchemeSeed = color;
|
||||
notifyListeners();
|
||||
await _ready;
|
||||
await _prefs.setInt('colorSchemeSeed', color.toARGB32());
|
||||
}
|
||||
|
||||
Future<void> clearAllData() async {
|
||||
await _ready;
|
||||
await _prefs.clear();
|
||||
_defaultTool = PenTool.pen;
|
||||
_defaultColor = Colors.black;
|
||||
_defaultStrokeWidth = 2.0;
|
||||
_defaultPressureCurve = PressureCurveType.linear;
|
||||
_defaultStabilization = StabilizationLevel.none;
|
||||
_themeMode = ThemeMode.system;
|
||||
_colorSchemeSeed = Colors.blue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final toolName = _prefs.getString('defaultTool');
|
||||
if (toolName != null) {
|
||||
_defaultTool = PenTool.values.asNameMap()[toolName] ?? PenTool.pen;
|
||||
}
|
||||
|
||||
final colorValue = _prefs.getInt('defaultColor');
|
||||
if (colorValue != null) {
|
||||
_defaultColor = Color(colorValue);
|
||||
}
|
||||
|
||||
_defaultStrokeWidth =
|
||||
_prefs.getDouble('defaultStrokeWidth') ?? _defaultStrokeWidth;
|
||||
|
||||
final curveName = _prefs.getString('defaultPressureCurve');
|
||||
if (curveName != null) {
|
||||
_defaultPressureCurve =
|
||||
PressureCurveType.values.asNameMap()[curveName] ??
|
||||
PressureCurveType.linear;
|
||||
}
|
||||
|
||||
final stabName = _prefs.getString('defaultStabilization');
|
||||
if (stabName != null) {
|
||||
_defaultStabilization =
|
||||
StabilizationLevel.values.asNameMap()[stabName] ??
|
||||
StabilizationLevel.none;
|
||||
}
|
||||
|
||||
final themeName = _prefs.getString('themeMode');
|
||||
if (themeName != null) {
|
||||
_themeMode = ThemeMode.values.asNameMap()[themeName] ?? ThemeMode.system;
|
||||
}
|
||||
|
||||
final seedValue = _prefs.getInt('colorSchemeSeed');
|
||||
if (seedValue != null) {
|
||||
_colorSchemeSeed = Color(seedValue);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
743
lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,743 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/document.dart';
|
||||
import '../models/note.dart';
|
||||
import '../providers/document_provider.dart';
|
||||
import '../providers/note_provider.dart';
|
||||
import '../providers/ocr_provider.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../services/pptx_service.dart';
|
||||
import 'note_editor_screen.dart';
|
||||
import 'pdf_annotator_screen.dart';
|
||||
import 'ppt_annotator_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'split_view_screen.dart';
|
||||
|
||||
// [M1] Relative date helper — no new package dependencies.
|
||||
String _formatDate(DateTime d) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(d);
|
||||
if (diff.inSeconds < 60) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notesAsync = ref.watch(noteListProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('BadNote'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: 'Import PDF',
|
||||
onPressed: () => _importPdf(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.slideshow),
|
||||
tooltip: 'Import PPT',
|
||||
onPressed: () => _importPptx(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _createAndOpenNote(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: notesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (notes) {
|
||||
final documentsAsync = ref.watch(documentListProvider);
|
||||
final documents = documentsAsync.valueOrNull ?? [];
|
||||
|
||||
if (notes.isEmpty && documents.isEmpty) {
|
||||
return _buildEmptyState(context, ref);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
ref.read(noteListProvider.notifier).loadNotes(),
|
||||
ref.read(documentListProvider.notifier).loadDocuments(),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Notes section header always shown when documents exist
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Notes',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (notes.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _NoteTile(note: notes[index]),
|
||||
childCount: notes.length,
|
||||
),
|
||||
)
|
||||
else
|
||||
// [M2] Per-section empty hint when documents exist but notes don't
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No ink notes yet — tap + to create one',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Documents section header always shown when notes exist
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Recent Documents',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (documents.isNotEmpty)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_DocumentTile(document: documents[index]),
|
||||
childCount: documents.length,
|
||||
),
|
||||
)
|
||||
else
|
||||
// [M2] Per-section empty hint when notes exist but documents don't
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No documents yet — import a PDF or PPT',
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 80)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
||||
final note = await ref.read(noteListProvider.notifier).createNote();
|
||||
if (context.mounted) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPdf(BuildContext context) async {
|
||||
final pdfService = PdfService();
|
||||
final filePath = await pdfService.pickPdfFile();
|
||||
if (filePath != null && context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPptx(BuildContext context) async {
|
||||
final pptxService = PptxService();
|
||||
final filePath = await pptxService.openPptxFile();
|
||||
if (filePath == null || !context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
|
||||
}
|
||||
|
||||
final slideImages = await pptxService.convertToImages(filePath);
|
||||
final extractedText = await pptxService.extractText(filePath);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
filePath: filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note,
|
||||
size: 80,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'No notes yet',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Create your first note',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _createAndOpenNote(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Note'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPdf(context),
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: const Text('Import PDF'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPptx(context),
|
||||
icon: const Icon(Icons.slideshow),
|
||||
label: const Text('Import PPT'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoteTile extends ConsumerStatefulWidget {
|
||||
final Note note;
|
||||
const _NoteTile({required this.note});
|
||||
|
||||
@override
|
||||
ConsumerState<_NoteTile> createState() => _NoteTileState();
|
||||
}
|
||||
|
||||
class _NoteTileState extends ConsumerState<_NoteTile> {
|
||||
bool _hovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final note = widget.note;
|
||||
// [M1] Use relative date helper
|
||||
final dateStr = _formatDate(note.updatedAt);
|
||||
|
||||
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
||||
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
||||
|
||||
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
// [H2] Right-click context menu via GestureDetector + MouseRegion for hover
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showContextMenu(context, details.globalPosition),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title.isEmpty ? 'Untitled' : note.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
_OcrStatusBadge(status: ocrStatus),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (note.tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
children: note.tags
|
||||
.map(
|
||||
(t) => Chip(
|
||||
label: Text(
|
||||
t,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// [H2] Trailing delete button — always visible with subdued color, brighter on hover
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.error
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Delete note',
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _confirmDelete(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context, Offset position) async {
|
||||
final result = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy,
|
||||
position.dx + 1,
|
||||
position.dy + 1,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: 'open',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.edit_outlined),
|
||||
SizedBox(width: 8),
|
||||
Text('Open'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result == 'open') {
|
||||
Navigator.of(this.context).push(
|
||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
|
||||
);
|
||||
} else if (result == 'delete') {
|
||||
_confirmDelete(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete note?'),
|
||||
content: Text(
|
||||
'Delete "${widget.note.title.isEmpty ? 'Untitled' : widget.note.title}"?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(noteListProvider.notifier).deleteNote(widget.note.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentTile extends ConsumerStatefulWidget {
|
||||
final Document document;
|
||||
const _DocumentTile({required this.document});
|
||||
|
||||
@override
|
||||
ConsumerState<_DocumentTile> createState() => _DocumentTileState();
|
||||
}
|
||||
|
||||
class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
bool _hovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final document = widget.document;
|
||||
// [M1] Use relative date helper
|
||||
final dateStr = _formatDate(document.updatedAt);
|
||||
final isPdf = document.docType == 'pdf';
|
||||
|
||||
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
// [H2] Right-click context menu + MouseRegion + trailing action buttons
|
||||
return GestureDetector(
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showContextMenu(context, details.globalPosition),
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isPdf ? Icons.picture_as_pdf : Icons.slideshow,
|
||||
color: isPdf ? Colors.red : Colors.orange,
|
||||
),
|
||||
title: Text(
|
||||
document.filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
// [H2] Trailing row: split-view (PDF only) + remove
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPdf)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.vertical_split,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Open in Split View',
|
||||
onPressed: () => _openSplitView(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.error
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Remove document',
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
// [L2] Routing bug fix: route by docType
|
||||
onTap: () => _openDocument(context),
|
||||
onLongPress: () => _showDocumentMenu(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// [L2] Route by docType: pdf → PdfAnnotatorScreen, ppt/pptx → PptAnnotatorScreen
|
||||
Future<void> _openDocument(BuildContext context) async {
|
||||
final document = widget.document;
|
||||
final isPdf = document.docType == 'pdf';
|
||||
|
||||
if (isPdf) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// PPT/PPTX: convert to images then push PptAnnotatorScreen
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Processing presentation...')),
|
||||
);
|
||||
}
|
||||
final pptxService = PptxService();
|
||||
final slideImages = await pptxService.convertToImages(document.filePath);
|
||||
final extractedText = await pptxService.extractText(document.filePath);
|
||||
if (!mounted) return;
|
||||
if (slideImages.isEmpty) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not open presentation.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(this.context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
filePath: document.filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _openSplitView(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SplitViewScreen(
|
||||
filePath: widget.document.filePath,
|
||||
documentId: widget.document.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context, Offset position) async {
|
||||
final isPdf = widget.document.docType == 'pdf';
|
||||
final result = await showMenu<String>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy,
|
||||
position.dx + 1,
|
||||
position.dy + 1,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem(
|
||||
value: 'open',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.open_in_new),
|
||||
SizedBox(width: 8),
|
||||
Text('Open'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isPdf)
|
||||
PopupMenuItem(
|
||||
value: 'split',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.vertical_split),
|
||||
SizedBox(width: 8),
|
||||
Text('Open in Split View'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (result == 'open') {
|
||||
_openDocument(this.context);
|
||||
} else if (result == 'split') {
|
||||
_openSplitView(this.context);
|
||||
} else if (result == 'remove') {
|
||||
_confirmDelete(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDocumentMenu(BuildContext context) {
|
||||
final isPdf = widget.document.docType == 'pdf';
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPdf)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.vertical_split),
|
||||
title: const Text('Open in Split View'),
|
||||
subtitle: const Text('PDF reference + scratchpad'),
|
||||
onTap: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_openSplitView(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text(
|
||||
'Remove document',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_confirmDelete(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Remove document?'),
|
||||
content: Text(
|
||||
'Remove "${widget.document.filename}" from recent documents?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(documentListProvider.notifier)
|
||||
.removeDocument(widget.document.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// [M3] OCR status badge with semantic theme colors and tooltips
|
||||
class _OcrStatusBadge extends StatelessWidget {
|
||||
final OcrStatus status;
|
||||
const _OcrStatusBadge({required this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (status) {
|
||||
case OcrStatus.none:
|
||||
return const SizedBox.shrink();
|
||||
case OcrStatus.processing:
|
||||
return Tooltip(
|
||||
message: 'Processing OCR…',
|
||||
child: const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
);
|
||||
case OcrStatus.done:
|
||||
return Tooltip(
|
||||
message: 'OCR complete',
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
);
|
||||
case OcrStatus.failed:
|
||||
return Tooltip(
|
||||
message: 'OCR failed',
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
303
lib/screens/note_editor_screen.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' hide UndoManager;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../providers/note_provider.dart';
|
||||
import '../providers/ocr_provider.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
class NoteEditorScreen extends ConsumerStatefulWidget {
|
||||
final Note? note;
|
||||
|
||||
const NoteEditorScreen({super.key, this.note});
|
||||
|
||||
@override
|
||||
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
|
||||
}
|
||||
|
||||
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
|
||||
final UndoManager _undoManager = UndoManager();
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
String _title = 'Untitled';
|
||||
final TextEditingController _titleController = TextEditingController();
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
final TransformationController _zoomController = TransformationController();
|
||||
double _zoomLevel = 1.0;
|
||||
|
||||
bool _isDirty = false;
|
||||
|
||||
Note? get _existingNote => widget.note;
|
||||
|
||||
PressureCurve get _pressureCurve {
|
||||
switch (_pressureCurveType) {
|
||||
case PressureCurveType.linear:
|
||||
return PressureCurve.linear;
|
||||
case PressureCurveType.soft:
|
||||
return PressureCurve.soft;
|
||||
case PressureCurveType.hard:
|
||||
return PressureCurve.hard;
|
||||
case PressureCurveType.custom:
|
||||
return const PressureCurve(type: PressureCurveType.custom);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_existingNote != null) {
|
||||
_title = _existingNote!.title;
|
||||
for (final stroke in _existingNote!.strokes) {
|
||||
_undoManager.addStroke(stroke);
|
||||
}
|
||||
}
|
||||
_titleController.text = _title;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_zoomController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_undoManager.addStroke(stroke);
|
||||
_isDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final original = _undoManager.currentStrokes
|
||||
.where((s) => s.id == strokeId)
|
||||
.firstOrNull;
|
||||
if (original != null) {
|
||||
_undoManager.removeStroke(original, replacements: replacements);
|
||||
_isDirty = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
_undoManager.undo();
|
||||
_isDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() {
|
||||
_undoManager.redo();
|
||||
_isDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final notifier = ref.read(noteListProvider.notifier);
|
||||
final now = DateTime.now();
|
||||
|
||||
Note savedNote;
|
||||
if (_existingNote != null) {
|
||||
final updated = _existingNote!.copyWith(
|
||||
title: _title,
|
||||
strokes: _undoManager.currentStrokes.toList(),
|
||||
updatedAt: now,
|
||||
);
|
||||
await notifier.updateNote(updated);
|
||||
savedNote = updated;
|
||||
} else {
|
||||
final note = await notifier.createNote(title: _title);
|
||||
final updated = note.copyWith(
|
||||
strokes: _undoManager.currentStrokes.toList(),
|
||||
);
|
||||
await notifier.updateNote(updated);
|
||||
savedNote = updated;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDirty = false;
|
||||
});
|
||||
|
||||
_runLocalOcr(savedNote);
|
||||
}
|
||||
|
||||
/// Run local OCR and index results for search.
|
||||
void _runLocalOcr(Note note) {
|
||||
final noteId = note.id;
|
||||
ref.read(ocrStatusProvider.notifier).state = {
|
||||
...ref.read(ocrStatusProvider),
|
||||
noteId: OcrStatus.processing,
|
||||
};
|
||||
|
||||
ref
|
||||
.read(ocrServiceProvider)
|
||||
.processNote(note)
|
||||
.then((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(ocrStatusProvider.notifier).state = {
|
||||
...ref.read(ocrStatusProvider),
|
||||
noteId: OcrStatus.done,
|
||||
};
|
||||
})
|
||||
.catchError((_) {
|
||||
if (!mounted) return;
|
||||
ref.read(ocrStatusProvider.notifier).state = {
|
||||
...ref.read(ocrStatusProvider),
|
||||
noteId: OcrStatus.failed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void _zoomIn() {
|
||||
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
|
||||
_applyZoom(newLevel);
|
||||
}
|
||||
|
||||
void _zoomOut() {
|
||||
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
|
||||
_applyZoom(newLevel);
|
||||
}
|
||||
|
||||
void _zoomReset() {
|
||||
_applyZoom(1.0);
|
||||
}
|
||||
|
||||
void _applyZoom(double level) {
|
||||
setState(() => _zoomLevel = level);
|
||||
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
|
||||
}
|
||||
|
||||
Future<void> _saveAndNotify() async {
|
||||
await _save();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Saved')));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop && _isDirty) _save();
|
||||
},
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
|
||||
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
|
||||
const SingleActivator(
|
||||
LogicalKeyboardKey.keyZ,
|
||||
control: true,
|
||||
shift: true,
|
||||
): _redo,
|
||||
SingleActivator(LogicalKeyboardKey.keyS, control: true):
|
||||
_saveAndNotify,
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: SizedBox(
|
||||
height: 40,
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Note title...',
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
suffix: _isDirty
|
||||
? const Text(
|
||||
' •',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_title = value;
|
||||
setState(() => _isDirty = true);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
tooltip: 'Save',
|
||||
onPressed: _saveAndNotify,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _undoManager.canUndo,
|
||||
canRedo: _undoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) =>
|
||||
setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
onZoomIn: _zoomIn,
|
||||
onZoomOut: _zoomOut,
|
||||
onZoomFitWidth: _zoomReset,
|
||||
zoomLabel: '${(_zoomLevel * 100).round()}%',
|
||||
),
|
||||
Expanded(
|
||||
child: InteractiveViewer(
|
||||
transformationController: _zoomController,
|
||||
minScale: 0.5,
|
||||
maxScale: 5.0,
|
||||
child: InkCanvas(
|
||||
strokes: _undoManager.currentStrokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: _pressureCurve,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
981
lib/screens/pdf_annotator_screen.dart
Normal file
@@ -0,0 +1,981 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' hide UndoManager;
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/document.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/camera_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../services/thumbnail_service.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
import '../widgets/page_thumbnail_sidebar.dart';
|
||||
import '../widgets/pdf_annotation_layer.dart';
|
||||
import 'pdf_text_search.dart';
|
||||
import 'split_view_screen.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// Actions available in the AppBar overflow menu.
|
||||
enum _OverflowAction { pageManagement, cameraInsert, export }
|
||||
|
||||
/// Full-screen PDF viewer with ink annotation overlay.
|
||||
///
|
||||
/// Displays a PDF page-by-page with a transparent [PdfAnnotationLayer]
|
||||
/// on top for pen/marker/eraser annotations. Annotations are stored
|
||||
/// per page in normalized [0, 1] coordinates and exported via [PdfService].
|
||||
/// Annotations and bookmarks are persisted to the database.
|
||||
class PdfAnnotatorScreen extends StatefulWidget {
|
||||
final String filePath;
|
||||
final int initialPage;
|
||||
|
||||
const PdfAnnotatorScreen({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
this.initialPage = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PdfAnnotatorScreen> createState() => _PdfAnnotatorScreenState();
|
||||
}
|
||||
|
||||
class _PdfAnnotatorScreenState extends State<PdfAnnotatorScreen> {
|
||||
final PdfService _pdfService = PdfService();
|
||||
final CameraService _cameraService = CameraService();
|
||||
final PdfViewerController _viewerController = PdfViewerController();
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
int _currentPage = 0;
|
||||
int _pageCount = 0;
|
||||
int _pdfMutationVersion = 0;
|
||||
String _fileName = '';
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
InteractionMode _interactionMode = InteractionMode.draw;
|
||||
double _zoomLevel = 1.0;
|
||||
bool _showThumbnails = false;
|
||||
|
||||
String? _currentDocumentId;
|
||||
final Map<int, UndoManager> _undoManagers = {};
|
||||
final Map<int, List<InkStroke>> _annotations = {};
|
||||
|
||||
List<Bookmark> _bookmarks = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPage = widget.initialPage;
|
||||
_loadPdfInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadPdfInfo() async {
|
||||
final info = await _pdfService.getPdfInfo(widget.filePath);
|
||||
final count = await _pdfService.getPageCount(widget.filePath);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_fileName = info['fileName'] as String;
|
||||
_pageCount = count;
|
||||
});
|
||||
await _ensureDocumentExists();
|
||||
await _loadAllAnnotations();
|
||||
await _loadBookmarks();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureDocumentExists() async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
final existing = await db.getDocumentByPath(widget.filePath);
|
||||
if (existing == null) {
|
||||
final now = DateTime.now();
|
||||
final newDoc = Document(
|
||||
id: _uuid.v4(),
|
||||
filename: _fileName,
|
||||
docType: 'pdf',
|
||||
filePath: widget.filePath,
|
||||
pageCount: _pageCount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertDocument(newDoc);
|
||||
_currentDocumentId = newDoc.id;
|
||||
} else {
|
||||
_currentDocumentId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAllAnnotations() async {
|
||||
if (_currentDocumentId == null) return;
|
||||
final db = await DatabaseService.getInstance();
|
||||
for (int i = 0; i < _pageCount; i++) {
|
||||
final json = await db.getAnnotations(_currentDocumentId!, i);
|
||||
if (json != null && json.isNotEmpty) {
|
||||
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
|
||||
_annotations[i] = list
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
_undoManagers[i] = UndoManager();
|
||||
for (final stroke in _annotations[i]!) {
|
||||
_undoManagers[i]!.addStroke(stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _saveCurrentPageAnnotations({int? page}) async {
|
||||
if (_currentDocumentId == null) return;
|
||||
// Capture the page index and serialize its strokes SYNCHRONOUSLY, before
|
||||
// any await. Otherwise a concurrent navigation could change _currentPage
|
||||
// while this is suspended, causing the wrong page's data to be saved.
|
||||
final targetPage = page ?? _currentPage;
|
||||
final documentId = _currentDocumentId!;
|
||||
final strokesJson = jsonEncode(
|
||||
_annotations[targetPage]?.map((s) => s.toJson()).toList() ?? [],
|
||||
);
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.saveAnnotations(documentId, targetPage, strokesJson);
|
||||
}
|
||||
|
||||
void _onPageChanged(int page) {
|
||||
// Save the page we are leaving, not the one we are navigating to.
|
||||
_saveCurrentPageAnnotations(page: _currentPage);
|
||||
setState(() {
|
||||
_currentPage = page;
|
||||
});
|
||||
}
|
||||
|
||||
UndoManager _getUndoManager(int page) {
|
||||
return _undoManagers.putIfAbsent(page, UndoManager.new);
|
||||
}
|
||||
|
||||
List<InkStroke> _getCurrentStrokes() {
|
||||
return _annotations[_currentPage] ?? [];
|
||||
}
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_annotations.putIfAbsent(_currentPage, () => []);
|
||||
_annotations[_currentPage]!.add(stroke);
|
||||
_getUndoManager(_currentPage).addStroke(stroke);
|
||||
});
|
||||
_saveCurrentPageAnnotations();
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final pageStrokes = _annotations[_currentPage];
|
||||
if (pageStrokes == null) return;
|
||||
final original = pageStrokes.where((s) => s.id == strokeId).firstOrNull;
|
||||
if (original != null) {
|
||||
_getUndoManager(
|
||||
_currentPage,
|
||||
).removeStroke(original, replacements: replacements);
|
||||
_annotations[_currentPage] = _getUndoManager(
|
||||
_currentPage,
|
||||
).currentStrokes.toList();
|
||||
}
|
||||
});
|
||||
_saveCurrentPageAnnotations();
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
_getUndoManager(_currentPage).undo();
|
||||
_annotations[_currentPage] = _getUndoManager(
|
||||
_currentPage,
|
||||
).currentStrokes.toList();
|
||||
});
|
||||
_saveCurrentPageAnnotations();
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() {
|
||||
_getUndoManager(_currentPage).redo();
|
||||
_annotations[_currentPage] = _getUndoManager(
|
||||
_currentPage,
|
||||
).currentStrokes.toList();
|
||||
});
|
||||
_saveCurrentPageAnnotations();
|
||||
}
|
||||
|
||||
// -- Bookmarks --
|
||||
|
||||
bool get _isCurrentPageBookmarked =>
|
||||
_bookmarks.any((b) => b.pageNumber == _currentPage);
|
||||
|
||||
Future<void> _loadBookmarks() async {
|
||||
if (_currentDocumentId == null) return;
|
||||
final db = await DatabaseService.getInstance();
|
||||
final bookmarks = await db.getBookmarks(_currentDocumentId!);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bookmarks = bookmarks;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleBookmark() async {
|
||||
if (_currentDocumentId == null) return;
|
||||
final db = await DatabaseService.getInstance();
|
||||
|
||||
if (_isCurrentPageBookmarked) {
|
||||
final existing = _bookmarks.firstWhere(
|
||||
(b) => b.pageNumber == _currentPage,
|
||||
);
|
||||
await db.deleteBookmark(existing.id);
|
||||
setState(() {
|
||||
_bookmarks.removeWhere((b) => b.id == existing.id);
|
||||
});
|
||||
} else {
|
||||
final label = await _showBookmarkDialog();
|
||||
if (label == null) return;
|
||||
|
||||
final bookmark = Bookmark(
|
||||
id: _uuid.v4(),
|
||||
documentId: _currentDocumentId!,
|
||||
pageNumber: _currentPage,
|
||||
label: label,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
await db.insertBookmark(bookmark);
|
||||
setState(() {
|
||||
_bookmarks.add(bookmark);
|
||||
_bookmarks.sort((a, b) => a.pageNumber.compareTo(b.pageNumber));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _showBookmarkDialog() async {
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Bookmark'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Label (optional)',
|
||||
labelText: 'Bookmark label',
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text),
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteBookmark(Bookmark bookmark) async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.deleteBookmark(bookmark.id);
|
||||
setState(() {
|
||||
_bookmarks.removeWhere((b) => b.id == bookmark.id);
|
||||
});
|
||||
}
|
||||
|
||||
void _jumpToPage(int page) {
|
||||
_saveCurrentPageAnnotations();
|
||||
_viewerController.jumpToPage(page + 1);
|
||||
}
|
||||
|
||||
// -- Zoom --
|
||||
|
||||
void _zoomIn() {
|
||||
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
|
||||
_viewerController.zoomLevel = newLevel;
|
||||
setState(() => _zoomLevel = newLevel);
|
||||
}
|
||||
|
||||
void _zoomOut() {
|
||||
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
|
||||
_viewerController.zoomLevel = newLevel;
|
||||
setState(() => _zoomLevel = newLevel);
|
||||
}
|
||||
|
||||
void _zoomFitWidth() {
|
||||
_viewerController.zoomLevel = 1.0;
|
||||
setState(() => _zoomLevel = 1.0);
|
||||
}
|
||||
|
||||
// -- Search --
|
||||
|
||||
void _openSearch() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => PdfTextSearchDialog(viewerController: _viewerController),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Export --
|
||||
|
||||
Future<void> _exportPdf() async {
|
||||
try {
|
||||
final outputPath = await _pdfService.exportAnnotatedPdf(
|
||||
widget.filePath,
|
||||
_annotations,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Exported to: $outputPath')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Page Management --
|
||||
|
||||
void _showPageManagementSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final canDelete = _pageCount > 1;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.rotate_right),
|
||||
title: const Text('Rotate Page 90\u00B0'),
|
||||
subtitle: Text('Page ${_currentPage + 1}'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_rotateCurrentPage();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
color: canDelete ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Delete Page',
|
||||
style: TextStyle(color: canDelete ? null : Colors.grey),
|
||||
),
|
||||
subtitle: Text(
|
||||
canDelete
|
||||
? 'Page ${_currentPage + 1}'
|
||||
: 'Cannot delete the only page',
|
||||
),
|
||||
enabled: canDelete,
|
||||
onTap: canDelete
|
||||
? () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteCurrentPage();
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.note_add_outlined),
|
||||
title: const Text('Insert Blank Page After Current'),
|
||||
subtitle: Text('After page ${_currentPage + 1}'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_insertBlankPageAfterCurrent();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Transform a stroke's normalized [0,1] points to match a 90° clockwise
|
||||
/// page rotation: a point at (x, y) maps to (1 - y, x). Used to keep
|
||||
/// existing annotations glued to the page content after the page itself is
|
||||
/// physically rotated (PDF /Rotate).
|
||||
InkStroke _rotateStroke90CW(InkStroke stroke) {
|
||||
return stroke.copyWith(
|
||||
points: stroke.points
|
||||
.map((p) => p.copyWith(x: 1.0 - p.y, y: p.x))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rotateCurrentPage() async {
|
||||
final rotatedPage = _currentPage;
|
||||
final success = await _pdfService.rotatePage(widget.filePath, rotatedPage);
|
||||
if (!success || !mounted) return;
|
||||
// Invalidate thumbnail for the rotated page.
|
||||
if (_currentDocumentId != null) {
|
||||
await ThumbnailService.invalidatePage(_currentDocumentId!, rotatedPage);
|
||||
}
|
||||
setState(() {
|
||||
_pdfMutationVersion++;
|
||||
// The page is physically rotated 90° CW, so transform existing stored
|
||||
// annotations the same way to keep them aligned with the page content.
|
||||
// New strokes drawn afterwards are already captured in the rotated frame.
|
||||
final existing = _annotations[rotatedPage];
|
||||
if (existing != null && existing.isNotEmpty) {
|
||||
_annotations[rotatedPage] = existing.map(_rotateStroke90CW).toList();
|
||||
// Undo history holds pre-rotation coordinates; reset it for this page
|
||||
// so undo/redo cannot reintroduce misaligned strokes.
|
||||
_undoManagers.remove(rotatedPage);
|
||||
}
|
||||
});
|
||||
// Persist the transformed annotations for the rotated page.
|
||||
_saveCurrentPageAnnotations(page: rotatedPage);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Rotated page ${_currentPage + 1}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCurrentPage() async {
|
||||
// Confirm.
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Page'),
|
||||
content: Text(
|
||||
'Delete page ${_currentPage + 1}? This cannot be undone.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
final success = await _pdfService.deletePage(widget.filePath, _currentPage);
|
||||
if (!success || !mounted) return;
|
||||
|
||||
if (_currentDocumentId != null) {
|
||||
final db = await DatabaseService.getInstance();
|
||||
// Delete annotation/bookmark/ocr data for the removed page.
|
||||
await db.deletePageData(_currentDocumentId!, _currentPage);
|
||||
// Remap higher-indexed data down by 1.
|
||||
await db.remapAnnotationsAfterDelete(_currentDocumentId!, _currentPage);
|
||||
await db.remapBookmarksAfterDelete(_currentDocumentId!, _currentPage);
|
||||
// Update stored page count.
|
||||
final newCount = _pageCount - 1;
|
||||
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
|
||||
// Invalidate all thumbnails (page indices shifted).
|
||||
await ThumbnailService.invalidateAll(_currentDocumentId!);
|
||||
}
|
||||
|
||||
// Shift in-memory annotations down.
|
||||
final newAnnotations = <int, List<InkStroke>>{};
|
||||
for (final entry in _annotations.entries) {
|
||||
if (entry.key < _currentPage) {
|
||||
newAnnotations[entry.key] = entry.value;
|
||||
} else if (entry.key > _currentPage) {
|
||||
newAnnotations[entry.key - 1] = entry.value;
|
||||
}
|
||||
// entry.key == _currentPage is dropped.
|
||||
}
|
||||
_annotations
|
||||
..clear()
|
||||
..addAll(newAnnotations);
|
||||
|
||||
// Shift undo managers.
|
||||
final newUndoManagers = <int, UndoManager>{};
|
||||
for (final entry in _undoManagers.entries) {
|
||||
if (entry.key < _currentPage) {
|
||||
newUndoManagers[entry.key] = entry.value;
|
||||
} else if (entry.key > _currentPage) {
|
||||
newUndoManagers[entry.key - 1] = entry.value;
|
||||
}
|
||||
}
|
||||
_undoManagers
|
||||
..clear()
|
||||
..addAll(newUndoManagers);
|
||||
|
||||
// Shift bookmarks in memory.
|
||||
_bookmarks.removeWhere((b) => b.pageNumber == _currentPage);
|
||||
for (int i = 0; i < _bookmarks.length; i++) {
|
||||
if (_bookmarks[i].pageNumber > _currentPage) {
|
||||
_bookmarks[i] = Bookmark(
|
||||
id: _bookmarks[i].id,
|
||||
documentId: _bookmarks[i].documentId,
|
||||
pageNumber: _bookmarks[i].pageNumber - 1,
|
||||
label: _bookmarks[i].label,
|
||||
color: _bookmarks[i].color,
|
||||
createdAt: _bookmarks[i].createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_pageCount = _pageCount - 1;
|
||||
if (_currentPage >= _pageCount) {
|
||||
_currentPage = _pageCount - 1;
|
||||
}
|
||||
_pdfMutationVersion++;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Page deleted')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _insertBlankPageAfterCurrent() async {
|
||||
final success = await _pdfService.insertBlankPage(
|
||||
widget.filePath,
|
||||
_currentPage,
|
||||
);
|
||||
if (!success || !mounted) return;
|
||||
|
||||
final insertedIndex = _currentPage + 1;
|
||||
if (_currentDocumentId != null) {
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.remapAnnotationsAfterInsert(_currentDocumentId!, insertedIndex);
|
||||
await db.remapBookmarksAfterInsert(_currentDocumentId!, insertedIndex);
|
||||
final newCount = _pageCount + 1;
|
||||
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
|
||||
await ThumbnailService.invalidateAll(_currentDocumentId!);
|
||||
}
|
||||
|
||||
// Shift in-memory annotations up by 1 for pages >= insertedIndex.
|
||||
final newAnnotations = <int, List<InkStroke>>{};
|
||||
for (final entry in _annotations.entries) {
|
||||
if (entry.key < insertedIndex) {
|
||||
newAnnotations[entry.key] = entry.value;
|
||||
} else {
|
||||
newAnnotations[entry.key + 1] = entry.value;
|
||||
}
|
||||
}
|
||||
_annotations
|
||||
..clear()
|
||||
..addAll(newAnnotations);
|
||||
|
||||
final newUndoManagers = <int, UndoManager>{};
|
||||
for (final entry in _undoManagers.entries) {
|
||||
if (entry.key < insertedIndex) {
|
||||
newUndoManagers[entry.key] = entry.value;
|
||||
} else {
|
||||
newUndoManagers[entry.key + 1] = entry.value;
|
||||
}
|
||||
}
|
||||
_undoManagers
|
||||
..clear()
|
||||
..addAll(newUndoManagers);
|
||||
|
||||
// Shift bookmarks in memory.
|
||||
for (int i = 0; i < _bookmarks.length; i++) {
|
||||
if (_bookmarks[i].pageNumber >= insertedIndex) {
|
||||
_bookmarks[i] = Bookmark(
|
||||
id: _bookmarks[i].id,
|
||||
documentId: _bookmarks[i].documentId,
|
||||
pageNumber: _bookmarks[i].pageNumber + 1,
|
||||
label: _bookmarks[i].label,
|
||||
color: _bookmarks[i].color,
|
||||
createdAt: _bookmarks[i].createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_pageCount = _pageCount + 1;
|
||||
_pdfMutationVersion++;
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Blank page inserted after page ${_currentPage + 1}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Camera Insert --
|
||||
|
||||
Future<void> _showCameraInsertDialog() async {
|
||||
final source = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
title: const Text('Insert Image'),
|
||||
children: [
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop('camera'),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.camera_alt),
|
||||
title: Text('Camera'),
|
||||
),
|
||||
),
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop('gallery'),
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.photo_library),
|
||||
title: Text('Gallery'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final String? imagePath;
|
||||
if (source == 'camera') {
|
||||
imagePath = await _cameraService.capturePhoto();
|
||||
} else {
|
||||
imagePath = await _cameraService.pickFromGallery();
|
||||
}
|
||||
if (imagePath == null || !mounted) return;
|
||||
|
||||
final result = await _pdfService.insertImageOnPage(
|
||||
widget.filePath,
|
||||
_currentPage,
|
||||
imagePath,
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
if (_currentDocumentId != null) {
|
||||
await ThumbnailService.invalidatePage(
|
||||
_currentDocumentId!,
|
||||
_currentPage,
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_pdfMutationVersion++;
|
||||
});
|
||||
// Save current annotations so they overlay the image.
|
||||
_saveCurrentPageAnnotations();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Image inserted on page ${_currentPage + 1}')),
|
||||
);
|
||||
}
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Failed to insert image')));
|
||||
}
|
||||
}
|
||||
|
||||
// -- Bookmark drawer --
|
||||
|
||||
Widget _buildBookmarkDrawer() {
|
||||
return Drawer(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Bookmarks',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _bookmarks.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_border,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'No bookmarks yet',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Tap the bookmark icon in the toolbar\nto bookmark the current page.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _bookmarks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bookmark = _bookmarks[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Color(bookmark.color),
|
||||
radius: 6,
|
||||
),
|
||||
title: Text(
|
||||
bookmark.label.isEmpty
|
||||
? 'Page ${bookmark.pageNumber + 1}'
|
||||
: bookmark.label,
|
||||
),
|
||||
subtitle: Text('Page ${bookmark.pageNumber + 1}'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Delete bookmark',
|
||||
onPressed: () => _deleteBookmark(bookmark),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
_jumpToPage(bookmark.pageNumber);
|
||||
},
|
||||
onLongPress: () => _deleteBookmark(bookmark),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- UI --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final undoManager = _getUndoManager(_currentPage);
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: AppBar(
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.vertical_split),
|
||||
tooltip: 'Open in Split View',
|
||||
onPressed: () {
|
||||
if (_currentDocumentId == null) return;
|
||||
_saveCurrentPageAnnotations();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SplitViewScreen(
|
||||
filePath: widget.filePath,
|
||||
documentId: _currentDocumentId!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search in PDF (Ctrl+F)',
|
||||
onPressed: _openSearch,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isCurrentPageBookmarked ? Icons.bookmark : Icons.bookmark_border,
|
||||
),
|
||||
tooltip: 'Toggle bookmark',
|
||||
onPressed: _toggleBookmark,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_book),
|
||||
tooltip: 'Bookmarks',
|
||||
onPressed: () => _scaffoldKey.currentState?.openEndDrawer(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showThumbnails
|
||||
? Icons.view_sidebar
|
||||
: Icons.view_sidebar_outlined,
|
||||
),
|
||||
tooltip: 'Toggle page thumbnails',
|
||||
onPressed: () => setState(() => _showThumbnails = !_showThumbnails),
|
||||
),
|
||||
PopupMenuButton<_OverflowAction>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'More actions',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _OverflowAction.pageManagement:
|
||||
_showPageManagementSheet();
|
||||
case _OverflowAction.cameraInsert:
|
||||
_showCameraInsertDialog();
|
||||
case _OverflowAction.export:
|
||||
_exportPdf();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: _OverflowAction.pageManagement,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.pages),
|
||||
title: Text('Page Management'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _OverflowAction.cameraInsert,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.camera_alt),
|
||||
title: Text('Insert Image'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _OverflowAction.export,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.save_alt),
|
||||
title: Text('Export PDF'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
endDrawer: _buildBookmarkDrawer(),
|
||||
body: CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
|
||||
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
|
||||
const SingleActivator(
|
||||
LogicalKeyboardKey.keyZ,
|
||||
control: true,
|
||||
shift: true,
|
||||
): _redo,
|
||||
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
|
||||
_openSearch,
|
||||
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
|
||||
_saveCurrentPageAnnotations,
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
if (_interactionMode != InteractionMode.navigate) {
|
||||
setState(() => _interactionMode = InteractionMode.navigate);
|
||||
}
|
||||
},
|
||||
},
|
||||
child: Focus(
|
||||
autofocus: true,
|
||||
child: Column(
|
||||
children: [
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: undoManager.canUndo,
|
||||
canRedo: undoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) =>
|
||||
setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
onPreviousPage: _currentPage > 0
|
||||
? () => _viewerController.previousPage()
|
||||
: null,
|
||||
onNextPage: _currentPage < _pageCount - 1
|
||||
? () => _viewerController.nextPage()
|
||||
: null,
|
||||
pageInfo: '${_currentPage + 1} / $_pageCount',
|
||||
interactionMode: _interactionMode,
|
||||
onInteractionModeChanged: (mode) =>
|
||||
setState(() => _interactionMode = mode),
|
||||
onZoomIn: _zoomIn,
|
||||
onZoomOut: _zoomOut,
|
||||
onZoomFitWidth: _zoomFitWidth,
|
||||
zoomLabel: '${(_zoomLevel * 100).round()}%',
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (_showThumbnails && _currentDocumentId != null)
|
||||
PageThumbnailSidebar(
|
||||
documentId: _currentDocumentId!,
|
||||
filePath: widget.filePath,
|
||||
pageCount: _pageCount,
|
||||
currentPage: _currentPage,
|
||||
onPageTap: _jumpToPage,
|
||||
bookmarkedPages: _bookmarks
|
||||
.map((b) => b.pageNumber)
|
||||
.toSet(),
|
||||
),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
SfPdfViewer.file(
|
||||
File(widget.filePath),
|
||||
key: ValueKey('pdf-$_pdfMutationVersion'),
|
||||
controller: _viewerController,
|
||||
initialPageNumber: _currentPage + 1,
|
||||
onPageChanged: (PdfPageChangedDetails details) {
|
||||
_onPageChanged(details.newPageNumber - 1);
|
||||
},
|
||||
),
|
||||
Positioned.fill(
|
||||
child: _interactionMode == InteractionMode.navigate
|
||||
? IgnorePointer(
|
||||
child: PdfAnnotationLayer(
|
||||
strokes: _getCurrentStrokes(),
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
interactionMode: _interactionMode,
|
||||
),
|
||||
)
|
||||
: PdfAnnotationLayer(
|
||||
strokes: _getCurrentStrokes(),
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
interactionMode: _interactionMode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveCurrentPageAnnotations();
|
||||
_viewerController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
141
lib/screens/pdf_text_search.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
/// A dialog for searching text within a PDF using SfPdfViewer's built-in search.
|
||||
class PdfTextSearchDialog extends StatefulWidget {
|
||||
final PdfViewerController viewerController;
|
||||
|
||||
const PdfTextSearchDialog({super.key, required this.viewerController});
|
||||
|
||||
@override
|
||||
State<PdfTextSearchDialog> createState() => _PdfTextSearchDialogState();
|
||||
}
|
||||
|
||||
class _PdfTextSearchDialogState extends State<PdfTextSearchDialog> {
|
||||
final TextEditingController _queryController = TextEditingController();
|
||||
PdfTextSearchResult? _searchResult;
|
||||
String _statusText = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_queryController.dispose();
|
||||
_searchResult?.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _search() {
|
||||
final query = _queryController.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
|
||||
final result = widget.viewerController.searchText(query);
|
||||
setState(() {
|
||||
_searchResult = result;
|
||||
_updateStatus();
|
||||
});
|
||||
}
|
||||
|
||||
void _nextMatch() {
|
||||
_searchResult?.nextInstance();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
void _previousMatch() {
|
||||
_searchResult?.previousInstance();
|
||||
_updateStatus();
|
||||
}
|
||||
|
||||
void _updateStatus() {
|
||||
final result = _searchResult;
|
||||
if (result == null || result.totalInstanceCount == 0) {
|
||||
setState(() => _statusText = 'No matches');
|
||||
} else {
|
||||
setState(() {
|
||||
_statusText =
|
||||
'${result.currentInstanceIndex} of ${result.totalInstanceCount} matches';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _queryController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search in PDF...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: _search,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_statusText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_up),
|
||||
tooltip: 'Previous match',
|
||||
onPressed:
|
||||
_searchResult != null &&
|
||||
_searchResult!.totalInstanceCount > 0
|
||||
? _previousMatch
|
||||
: null,
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
tooltip: 'Next match',
|
||||
onPressed:
|
||||
_searchResult != null &&
|
||||
_searchResult!.totalInstanceCount > 0
|
||||
? _nextMatch
|
||||
: null,
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Clear search',
|
||||
onPressed: () {
|
||||
_searchResult?.clear();
|
||||
setState(() {
|
||||
_queryController.clear();
|
||||
_searchResult = null;
|
||||
_statusText = '';
|
||||
});
|
||||
},
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
528
lib/screens/ppt_annotator_screen.dart
Normal file
@@ -0,0 +1,528 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Per-slide annotation state. The [UndoManager] is the single source of
|
||||
/// truth for a slide's strokes; [strokes] reflects its current contents so
|
||||
/// the live canvas and the PDF export always render what was actually drawn.
|
||||
class _SlideAnnotations {
|
||||
final UndoManager undoManager = UndoManager();
|
||||
List<InkStroke> get strokes => undoManager.currentStrokes;
|
||||
}
|
||||
|
||||
/// Screen that displays PPTX slides with an ink annotation overlay.
|
||||
///
|
||||
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
|
||||
/// sits on top of each slide so the user can annotate freely. Annotations are
|
||||
/// stored per-slide and can be exported as a PDF.
|
||||
class PptAnnotatorScreen extends StatefulWidget {
|
||||
final String filePath;
|
||||
final List<String> slideImagePaths;
|
||||
final String? extractedText;
|
||||
|
||||
const PptAnnotatorScreen({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.slideImagePaths,
|
||||
this.extractedText,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
|
||||
}
|
||||
|
||||
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
|
||||
late final PageController _pageController;
|
||||
late final Map<int, _SlideAnnotations> _annotations;
|
||||
int _currentPage = 0;
|
||||
bool _isDrawing = false;
|
||||
bool _showTextPanel = false;
|
||||
// Set to true once the unsaved-annotations warning SnackBar has been shown.
|
||||
bool _hasShownUnsavedWarning = false;
|
||||
|
||||
// Toolbar state
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
|
||||
// Derived
|
||||
late final String _fileName;
|
||||
late final int _slideCount;
|
||||
late final String _extractedText;
|
||||
|
||||
PressureCurve get _pressureCurve {
|
||||
switch (_pressureCurveType) {
|
||||
case PressureCurveType.linear:
|
||||
return PressureCurve.linear;
|
||||
case PressureCurveType.soft:
|
||||
return PressureCurve.soft;
|
||||
case PressureCurveType.hard:
|
||||
return PressureCurve.hard;
|
||||
case PressureCurveType.custom:
|
||||
return const PressureCurve(type: PressureCurveType.custom);
|
||||
}
|
||||
}
|
||||
|
||||
UndoManager get _currentUndoManager =>
|
||||
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fileName = p.basename(widget.filePath);
|
||||
_slideCount = widget.slideImagePaths.length;
|
||||
_extractedText = widget.extractedText ?? '';
|
||||
|
||||
_pageController = PageController();
|
||||
_annotations = {};
|
||||
for (var i = 0; i < _slideCount; i++) {
|
||||
_annotations[i] = _SlideAnnotations();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- Drawing callbacks --
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_currentUndoManager.addStroke(stroke);
|
||||
});
|
||||
// Warn once per session that PPT annotations are not auto-saved.
|
||||
if (!_hasShownUnsavedWarning) {
|
||||
_hasShownUnsavedWarning = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final original = _currentUndoManager.currentStrokes
|
||||
.where((s) => s.id == strokeId)
|
||||
.firstOrNull;
|
||||
if (original != null) {
|
||||
_currentUndoManager.removeStroke(original, replacements: replacements);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Export --
|
||||
|
||||
Future<void> _exportPdf() async {
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
|
||||
|
||||
try {
|
||||
final bytes = await _buildPdfBytes();
|
||||
if (!mounted) return;
|
||||
|
||||
final dir = await _getExportDir();
|
||||
final baseName = p.basenameWithoutExtension(_fileName);
|
||||
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
|
||||
await File(outPath).writeAsBytes(bytes);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Directory> _getExportDir() async {
|
||||
try {
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home != null) {
|
||||
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
} catch (_) {}
|
||||
return Directory.current;
|
||||
}
|
||||
|
||||
Future<Uint8List> _buildPdfBytes() async {
|
||||
final doc = PdfDocument();
|
||||
doc.pageSettings.margins.all = 0;
|
||||
|
||||
for (var i = 0; i < _slideCount; i++) {
|
||||
final page = doc.pages.add();
|
||||
final pageSize = page.getClientSize();
|
||||
|
||||
// Draw slide image
|
||||
final imgPath = widget.slideImagePaths[i];
|
||||
try {
|
||||
final imgBytes = await File(imgPath).readAsBytes();
|
||||
final bitmap = PdfBitmap(imgBytes);
|
||||
|
||||
final imgW = bitmap.width.toDouble();
|
||||
final imgH = bitmap.height.toDouble();
|
||||
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
|
||||
final drawW = imgW * scale;
|
||||
final drawH = imgH * scale;
|
||||
final offX = (pageSize.width - drawW) / 2;
|
||||
final offY = (pageSize.height - drawH) / 2;
|
||||
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||
|
||||
page.graphics.drawImage(bitmap, imgRect);
|
||||
|
||||
// Draw ink strokes
|
||||
final annots = _annotations[i];
|
||||
if (annots != null && annots.strokes.isNotEmpty) {
|
||||
// KNOWN LIMITATION: strokes are captured in the live viewer's
|
||||
// full-fill pixel space (the InkCanvas is Positioned.fill over the
|
||||
// whole slide area, while the slide image is BoxFit.contain inside
|
||||
// it). The scale below is derived from the PDF page layout, not the
|
||||
// live widget size, so exported ink can be misaligned/scaled wrong.
|
||||
// A correct fix normalizes strokes to [0,1] of the *rendered image
|
||||
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
|
||||
// to the PDF draw rect here. Requires on-device visual verification.
|
||||
final imgAspect = imgW / imgH;
|
||||
final pageAspect = pageSize.width / pageSize.height;
|
||||
double widgetW, widgetH;
|
||||
if (imgAspect > pageAspect) {
|
||||
widgetW = pageSize.width;
|
||||
widgetH = pageSize.width / imgAspect;
|
||||
} else {
|
||||
widgetH = pageSize.height;
|
||||
widgetW = pageSize.height * imgAspect;
|
||||
}
|
||||
final scaleX = drawW / widgetW;
|
||||
final scaleY = drawH / widgetH;
|
||||
|
||||
for (final stroke in annots.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (stroke.points.length < 2) continue;
|
||||
|
||||
final r = (stroke.color >> 16) & 0xFF;
|
||||
final g = (stroke.color >> 8) & 0xFF;
|
||||
final b = stroke.color & 0xFF;
|
||||
final pdfColor = PdfColor(r, g, b);
|
||||
|
||||
final path = PdfPath();
|
||||
path.startFigure();
|
||||
for (var j = 0; j < stroke.points.length - 1; j++) {
|
||||
final pt1 = stroke.points[j];
|
||||
final pt2 = stroke.points[j + 1];
|
||||
path.addLine(
|
||||
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
|
||||
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
|
||||
);
|
||||
}
|
||||
|
||||
page.graphics.drawPath(
|
||||
path,
|
||||
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
page.graphics.drawRectangle(
|
||||
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
|
||||
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final bytes = await doc.save();
|
||||
doc.dispose();
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
// -- UI --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// No slides to annotate: show an empty state and skip the toolbar, which
|
||||
// would otherwise dereference a non-existent slide's annotation state.
|
||||
if (_slideCount == 0) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
body: const Center(child: Text('No slides to display')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
|
||||
actions: [
|
||||
if (_extractedText.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showTextPanel
|
||||
? Icons.text_snippet
|
||||
: Icons.text_snippet_outlined,
|
||||
),
|
||||
tooltip: 'Toggle extracted text',
|
||||
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: 'Export as PDF',
|
||||
onPressed: _exportPdf,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _currentUndoManager.canUndo,
|
||||
canRedo: _currentUndoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) => setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildSlideViewer()),
|
||||
if (_showTextPanel) _buildTextPanel(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildPageIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlideViewer() {
|
||||
if (_slideCount == 0) {
|
||||
return const Center(child: Text('No slides to display'));
|
||||
}
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (_) => setState(() => _isDrawing = true),
|
||||
onPointerUp: (_) => setState(() => _isDrawing = false),
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
|
||||
itemCount: _slideCount,
|
||||
onPageChanged: (page) => setState(() => _currentPage = page),
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Slide image (background)
|
||||
Positioned.fill(
|
||||
child: Image.file(
|
||||
File(widget.slideImagePaths[index]),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => Container(
|
||||
color: Colors.grey.shade200,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Slide ${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Ink annotation overlay (foreground)
|
||||
Positioned.fill(
|
||||
child: InkCanvas(
|
||||
strokes: _annotations[index]?.strokes ?? [],
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: _pressureCurve,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageIndicator() {
|
||||
if (_slideCount == 0) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Previous button — always present for both modes.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: _currentPage > 0
|
||||
? () => _pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// Dot row for small decks; compact text counter for large decks.
|
||||
if (_slideCount <= 12)
|
||||
...List.generate(_slideCount, (i) {
|
||||
final isActive = i == _currentPage;
|
||||
return GestureDetector(
|
||||
onTap: () => _pageController.animateToPage(
|
||||
i,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
child: Container(
|
||||
width: isActive ? 12 : 8,
|
||||
height: isActive ? 12 : 8,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
else
|
||||
Text(
|
||||
'${_currentPage + 1} / $_slideCount',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
// Next button — always present for both modes.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: _currentPage < _slideCount - 1
|
||||
? () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const Spacer(),
|
||||
// Slide counter is always shown at the trailing end for dot mode;
|
||||
// the compact text above already serves this role for large decks.
|
||||
if (_slideCount <= 12)
|
||||
Text(
|
||||
'${_currentPage + 1} / $_slideCount',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextPanel() {
|
||||
return SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.text_fields, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Extracted Text',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
onPressed: () => setState(() => _showTextPanel = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
_extractedText,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Dialogs --
|
||||
|
||||
void _undo() {
|
||||
setState(() => _currentUndoManager.undo());
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() => _currentUndoManager.redo());
|
||||
}
|
||||
}
|
||||
286
lib/screens/search_screen.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/note.dart';
|
||||
import '../providers/search_provider.dart';
|
||||
import 'note_editor_screen.dart';
|
||||
import 'pdf_annotator_screen.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onQueryChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(searchQueryProvider.notifier).state = value.trim();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final results = ref.watch(searchResultsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search notes and documents...',
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
),
|
||||
onChanged: _onQueryChanged,
|
||||
onSubmitted: (value) {
|
||||
_debounce?.cancel();
|
||||
ref.read(searchQueryProvider.notifier).state = value.trim();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_controller.clear();
|
||||
ref.read(searchQueryProvider.notifier).state = '';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: results.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Search error: $e')),
|
||||
data: (hits) {
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
if (query.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Type to search your notes and documents',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hits.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No results for "$query"',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final noteHits = hits.whereType<NoteSearchHit>().toList();
|
||||
final docHits = hits.whereType<DocumentSearchHit>().toList();
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (noteHits.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Notes',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...noteHits.map(
|
||||
(hit) => _NoteSearchResultTile(note: hit.note, query: query),
|
||||
),
|
||||
],
|
||||
if (docHits.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Documents',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...docHits.map(
|
||||
(hit) => _DocumentSearchResultTile(
|
||||
documentId: hit.documentId,
|
||||
filename: hit.filename,
|
||||
filePath: hit.filePath,
|
||||
pageNumber: hit.pageNumber,
|
||||
snippet: hit.snippet,
|
||||
query: query,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoteSearchResultTile extends StatelessWidget {
|
||||
final Note note;
|
||||
final String query;
|
||||
|
||||
const _NoteSearchResultTile({required this.note, required this.query});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = note.updatedAt;
|
||||
final dateStr =
|
||||
'${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.edit_note),
|
||||
title: _HighlightedText(text: note.title, query: query),
|
||||
subtitle: Text(
|
||||
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentSearchResultTile extends StatelessWidget {
|
||||
final String documentId;
|
||||
final String filename;
|
||||
final String filePath;
|
||||
final int pageNumber;
|
||||
final String snippet;
|
||||
final String query;
|
||||
|
||||
const _DocumentSearchResultTile({
|
||||
required this.documentId,
|
||||
required this.filename,
|
||||
required this.filePath,
|
||||
required this.pageNumber,
|
||||
required this.snippet,
|
||||
required this.query,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.picture_as_pdf, color: Colors.red),
|
||||
title: _HighlightedText(text: filename, query: query),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Page ${pageNumber + 1}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (snippet.isNotEmpty)
|
||||
_HighlightedText(text: snippet, query: query, maxLines: 2),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlights matching portions of [text] that match [query].
|
||||
class _HighlightedText extends StatelessWidget {
|
||||
final String text;
|
||||
final String query;
|
||||
final int maxLines;
|
||||
|
||||
const _HighlightedText({
|
||||
required this.text,
|
||||
required this.query,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (query.isEmpty || text.isEmpty) {
|
||||
return Text(text, maxLines: maxLines, overflow: TextOverflow.ellipsis);
|
||||
}
|
||||
|
||||
final lowerText = text.toLowerCase();
|
||||
final lowerQuery = query.toLowerCase();
|
||||
final spans = <TextSpan>[];
|
||||
int start = 0;
|
||||
|
||||
while (true) {
|
||||
final index = lowerText.indexOf(lowerQuery, start);
|
||||
if (index < 0) {
|
||||
if (start < text.length) {
|
||||
spans.add(TextSpan(text: text.substring(start)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (index > start) {
|
||||
spans.add(TextSpan(text: text.substring(start, index)));
|
||||
}
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: text.substring(index, index + query.length),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
start = index + query.length;
|
||||
}
|
||||
|
||||
return RichText(
|
||||
maxLines: maxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style,
|
||||
children: spans,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
344
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,344 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
/// Material 3 settings screen for BadNote.
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
void _showColorPicker(
|
||||
BuildContext context,
|
||||
Color current,
|
||||
ValueChanged<Color> onPicked,
|
||||
) {
|
||||
Color pickerColor = current;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Pick a color'),
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onPicked(pickerColor);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmClearData(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear all local settings?'),
|
||||
content: const Text(
|
||||
'This will reset pen defaults and appearance settings. '
|
||||
'Notes and documents are not affected.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider).clearAllData();
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Settings reset to defaults')),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: ListView(
|
||||
children: [
|
||||
_SectionHeader(title: 'Defaults', icon: Icons.tune),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Default Tool',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<PenTool>(
|
||||
initialValue: settings.defaultTool,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: PenTool.values.map((tool) {
|
||||
return DropdownMenuItem(
|
||||
value: tool,
|
||||
child: Text(tool.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (tool) {
|
||||
if (tool != null) settings.setDefaultTool(tool);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Default Color',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _showColorPicker(
|
||||
context,
|
||||
settings.defaultColor,
|
||||
settings.setDefaultColor,
|
||||
),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: settings.defaultColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'#${settings.defaultColor.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Default Stroke Width',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Slider(
|
||||
value: settings.defaultStrokeWidth,
|
||||
min: 1.0,
|
||||
max: 20.0,
|
||||
divisions: 19,
|
||||
label: settings.defaultStrokeWidth.toStringAsFixed(1),
|
||||
onChanged: settings.setDefaultStrokeWidth,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Pressure Curve',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<PressureCurveType>(
|
||||
initialValue: settings.defaultPressureCurve,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: PressureCurveType.values.map((curve) {
|
||||
return DropdownMenuItem(
|
||||
value: curve,
|
||||
child: Text(curve.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (curve) {
|
||||
if (curve != null) {
|
||||
settings.setDefaultPressureCurve(curve);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Stabilization',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<StabilizationLevel>(
|
||||
initialValue: settings.defaultStabilization,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: StabilizationLevel.values.map((level) {
|
||||
return DropdownMenuItem(
|
||||
value: level,
|
||||
child: Text(level.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (level) {
|
||||
if (level != null) settings.setDefaultStabilization(level);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(title: 'Appearance', icon: Icons.palette),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Theme Mode',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('System'),
|
||||
icon: Icon(Icons.brightness_auto),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Light'),
|
||||
icon: Icon(Icons.light_mode),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Dark'),
|
||||
icon: Icon(Icons.dark_mode),
|
||||
),
|
||||
],
|
||||
selected: {settings.themeMode},
|
||||
onSelectionChanged: (modes) {
|
||||
settings.setThemeMode(modes.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Color Scheme Seed',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _showColorPicker(
|
||||
context,
|
||||
settings.colorSchemeSeed,
|
||||
settings.setColorSchemeSeed,
|
||||
),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: settings.colorSchemeSeed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.outline),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Seed color for Material 3 theme'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(title: 'About', icon: Icons.info),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'BadNote v0.1.0',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Local-first Surface Pen note-taking with PDF/PPT annotation. '
|
||||
'OCR and search run entirely on your device.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmClearData(context, ref),
|
||||
icon: const Icon(Icons.delete_forever, color: Colors.red),
|
||||
label: const Text(
|
||||
'Clear All Local Settings',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
|
||||
const _SectionHeader({required this.title, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
469
lib/screens/split_view_screen.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
|
||||
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
||||
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
||||
class SplitViewScreen extends StatefulWidget {
|
||||
final String filePath;
|
||||
final String documentId;
|
||||
|
||||
const SplitViewScreen({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.documentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SplitViewScreen> createState() => _SplitViewState();
|
||||
}
|
||||
|
||||
class _SplitViewState extends State<SplitViewScreen> {
|
||||
// -- PDF (left pane) --
|
||||
final PdfViewerController _pdfController = PdfViewerController();
|
||||
int _currentPage = 0;
|
||||
int _pageCount = 0;
|
||||
String _fileName = '';
|
||||
|
||||
// -- Split divider --
|
||||
double _leftPaneFraction = 0.5;
|
||||
bool _isDraggingDivider = false;
|
||||
|
||||
// -- Scratchpad (right pane) --
|
||||
final UndoManager _undoManager = UndoManager();
|
||||
List<InkStroke> _strokes = [];
|
||||
double _canvasWidth = 4000;
|
||||
double _canvasHeight = 4000;
|
||||
|
||||
// -- Tool state --
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
|
||||
// -- Auto-save debounce --
|
||||
Timer? _saveTimer;
|
||||
bool _dirty = false;
|
||||
|
||||
// -- Page link markers (optional feature) --
|
||||
final List<_PageLink> _pageLinks = [];
|
||||
|
||||
static const double _edgeThreshold = 200.0;
|
||||
static const double _expandAmount = 1000.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadScratchpad();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveTimer?.cancel();
|
||||
_saveImmediate();
|
||||
_pdfController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// -- Persistence --
|
||||
|
||||
Future<void> _loadScratchpad() async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
final strokes = await db.loadScratchpad(widget.documentId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_strokes = strokes;
|
||||
for (final s in strokes) {
|
||||
_undoManager.addStroke(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleSave() {
|
||||
_dirty = true;
|
||||
_saveTimer?.cancel();
|
||||
_saveTimer = Timer(const Duration(seconds: 3), _saveImmediate);
|
||||
}
|
||||
|
||||
Future<void> _saveImmediate() async {
|
||||
if (!_dirty) return;
|
||||
_dirty = false;
|
||||
final db = await DatabaseService.getInstance();
|
||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
||||
await db.saveScratchpad(widget.documentId, json);
|
||||
}
|
||||
|
||||
// -- Scratchpad stroke callbacks --
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
setState(() {
|
||||
_strokes.add(stroke);
|
||||
_undoManager.addStroke(stroke);
|
||||
_checkCanvasExpansion(stroke);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
setState(() {
|
||||
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
|
||||
if (original != null) {
|
||||
_undoManager.removeStroke(original, replacements: replacements);
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
}
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _undo() {
|
||||
setState(() {
|
||||
_undoManager.undo();
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _redo() {
|
||||
setState(() {
|
||||
_undoManager.redo();
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
// -- Auto-expand canvas --
|
||||
|
||||
void _checkCanvasExpansion(InkStroke stroke) {
|
||||
double maxRight = 0;
|
||||
double maxBottom = 0;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x > maxRight) maxRight = p.x;
|
||||
if (p.y > maxBottom) maxBottom = p.y;
|
||||
}
|
||||
bool expanded = false;
|
||||
if (maxRight > _canvasWidth - _edgeThreshold) {
|
||||
_canvasWidth += _expandAmount;
|
||||
expanded = true;
|
||||
}
|
||||
if (maxBottom > _canvasHeight - _edgeThreshold) {
|
||||
_canvasHeight += _expandAmount;
|
||||
expanded = true;
|
||||
}
|
||||
if (expanded) setState(() {});
|
||||
}
|
||||
|
||||
// -- Divider drag --
|
||||
|
||||
void _onDividerDragStart(DragStartDetails details) {
|
||||
setState(() => _isDraggingDivider = true);
|
||||
}
|
||||
|
||||
void _onDividerDragUpdate(
|
||||
DragUpdateDetails details,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
final renderWidth = constraints.maxWidth;
|
||||
if (renderWidth <= 0) return;
|
||||
final delta = details.delta.dx / renderWidth;
|
||||
setState(() {
|
||||
_leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDividerDragEnd(DragEndDetails details) {
|
||||
setState(() => _isDraggingDivider = false);
|
||||
}
|
||||
|
||||
// -- PDF page navigation --
|
||||
|
||||
void _prevPage() {
|
||||
if (_currentPage > 0) {
|
||||
_pdfController.previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _pageCount - 1) {
|
||||
_pdfController.nextPage();
|
||||
}
|
||||
}
|
||||
|
||||
// -- Page link creation (long-press on left pane) --
|
||||
|
||||
void _onPdfLongPress(int pageNumber) {
|
||||
// Place a page link marker at the current scratchpad viewport center.
|
||||
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
||||
// manages its own transform — the user can reposition by panning.
|
||||
setState(() {
|
||||
_pageLinks.add(
|
||||
_PageLink(
|
||||
pageNumber: pageNumber,
|
||||
position: const Offset(100, 100), // default top-left area
|
||||
),
|
||||
);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Page link marker added for page $pageNumber')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPageLinkTap(_PageLink link) {
|
||||
_pdfController.jumpToPage(link.pageNumber);
|
||||
setState(() {
|
||||
_currentPage = link.pageNumber - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _deletePageLink(_PageLink link) {
|
||||
setState(() {
|
||||
_pageLinks.remove(link);
|
||||
});
|
||||
}
|
||||
|
||||
// -- Build --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_fileName.isEmpty ? 'Split View' : _fileName,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
_saveImmediate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
// Left pane page navigation
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_before),
|
||||
tooltip: 'Previous page (PDF)',
|
||||
onPressed: _currentPage > 0 ? _prevPage : null,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${_currentPage + 1} / $_pageCount',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_next),
|
||||
tooltip: 'Next page (PDF)',
|
||||
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Canvas info
|
||||
Tooltip(
|
||||
message:
|
||||
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${_canvasWidth.round()}x${_canvasHeight.round()}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Label clarifying that the toolbar controls the scratchpad pane.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Scratchpad tools',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Toolbar (applies to scratchpad only)
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _undoManager.canUndo,
|
||||
canRedo: _undoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) => setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
),
|
||||
// Split view body
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
final leftWidth = totalWidth * _leftPaneFraction;
|
||||
final rightWidth =
|
||||
totalWidth - leftWidth - 12; // 12px divider hit area
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Left pane: PDF reference (read-only)
|
||||
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
||||
// Draggable divider: 12px hit area, 4px visual strip.
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: _onDividerDragStart,
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
_onDividerDragUpdate(d, constraints),
|
||||
onHorizontalDragEnd: _onDividerDragEnd,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
child: SizedBox(
|
||||
width: 12,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 4,
|
||||
color: _isDraggingDivider
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right pane: Infinite scratchpad
|
||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPdfPane() {
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
// Long-press on PDF to create page link marker
|
||||
_onPdfLongPress(_currentPage + 1);
|
||||
},
|
||||
child: SfPdfViewer.file(
|
||||
File(widget.filePath),
|
||||
controller: _pdfController,
|
||||
canShowScrollHead: true,
|
||||
canShowScrollStatus: true,
|
||||
onPageChanged: (PdfPageChangedDetails details) {
|
||||
setState(() {
|
||||
_currentPage = details.newPageNumber - 1;
|
||||
});
|
||||
},
|
||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
||||
setState(() {
|
||||
_pageCount = details.document.pages.count;
|
||||
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
||||
if (_pageLinks.isNotEmpty)
|
||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageLinkChips() {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: _pageLinks.map((link) {
|
||||
return GestureDetector(
|
||||
onTap: () => _onPageLinkTap(link),
|
||||
onLongPress: () => _deletePageLink(link),
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
||||
label: Text(
|
||||
'p${link.pageNumber}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.blue.shade600,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScratchpadPane() {
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: InteractiveViewer(
|
||||
constrained: false,
|
||||
minScale: 0.25,
|
||||
maxScale: 8.0,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
child: SizedBox(
|
||||
width: _canvasWidth,
|
||||
height: _canvasHeight,
|
||||
child: InkCanvas(
|
||||
strokes: _strokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: PressureCurve(type: _pressureCurveType),
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
interactionMode: InteractionMode.draw,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker linking a scratchpad position to a specific PDF page.
|
||||
class _PageLink {
|
||||
final int pageNumber;
|
||||
final Offset position;
|
||||
|
||||
const _PageLink({required this.pageNumber, required this.position});
|
||||
}
|
||||
20
lib/services/camera_service.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
/// Thin wrapper around image_picker for camera capture and gallery selection.
|
||||
class CameraService {
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
/// Capture a photo using the device camera.
|
||||
/// Returns the file path, or null if the user cancelled.
|
||||
Future<String?> capturePhoto() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
|
||||
return image?.path;
|
||||
}
|
||||
|
||||
/// Pick an image from the device gallery.
|
||||
/// Returns the file path, or null if the user cancelled.
|
||||
Future<String?> pickFromGallery() async {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
|
||||
return image?.path;
|
||||
}
|
||||
}
|
||||
841
lib/services/database_service.dart
Normal file
@@ -0,0 +1,841 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/document.dart' as doc;
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
|
||||
class DatabaseService {
|
||||
static DatabaseService? _instance;
|
||||
late Database _database;
|
||||
|
||||
DatabaseService._();
|
||||
|
||||
static Future<DatabaseService> getInstance() async {
|
||||
if (_instance != null) return _instance!;
|
||||
final service = DatabaseService._();
|
||||
await service._initialize();
|
||||
_instance = service;
|
||||
return service;
|
||||
}
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = p.join(dir.path, 'badnote.db');
|
||||
|
||||
_database = await openDatabase(
|
||||
dbPath,
|
||||
version: 5,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
// Core tables (original v1)
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE strokes (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
stroke_width REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
points TEXT NOT NULL,
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('CREATE INDEX idx_strokes_note_id ON strokes(note_id)');
|
||||
|
||||
await _createFtsTable(db);
|
||||
|
||||
// Documents & annotations (originally v2, now part of fresh install)
|
||||
await db.execute('''
|
||||
CREATE TABLE documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
doc_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
page_count INTEGER NOT NULL DEFAULT 0,
|
||||
rotation INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
uuid TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
annotation_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
color INTEGER NOT NULL DEFAULT 4283215696,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_bookmarks_doc ON bookmarks(document_id)',
|
||||
);
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE ocr_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
ocr_text TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
// Document FTS (v3)
|
||||
await db.execute('''
|
||||
CREATE VIRTUAL TABLE document_fts USING fts5(
|
||||
document_id, page_number, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
|
||||
// Scratchpads (v5)
|
||||
await db.execute('''
|
||||
CREATE TABLE scratchpads (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT UNIQUE NOT NULL,
|
||||
strokes_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
if (oldVersion < 3) await _migrateV2toV3(db);
|
||||
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
||||
if (oldVersion < 5) await _migrateV4toV5(db);
|
||||
}
|
||||
|
||||
Future<void> _migrateV2toV3(Database db) async {
|
||||
// Wrap the whole migration in a transaction: a failure mid-migration
|
||||
// (after DROP TABLE annotations) would otherwise destroy data.
|
||||
await db.transaction((txn) async {
|
||||
// Add uuid column to annotations
|
||||
await txn.execute('ALTER TABLE annotations ADD COLUMN uuid TEXT');
|
||||
|
||||
// Generate UUIDs for existing rows
|
||||
await txn.rawUpdate(
|
||||
"UPDATE annotations SET uuid = hex(randomblob(16)) WHERE uuid IS NULL",
|
||||
);
|
||||
|
||||
// Recreate annotations table with UUID primary key
|
||||
await txn.execute('''
|
||||
CREATE TABLE annotations_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
uuid TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
annotation_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await txn.rawInsert('''
|
||||
INSERT INTO annotations_new (id, uuid, document_id, page_number, annotation_json, created_at, updated_at)
|
||||
SELECT id, uuid, document_id, page_number, annotation_json, created_at, updated_at FROM annotations
|
||||
''');
|
||||
|
||||
await txn.execute('DROP TABLE annotations');
|
||||
await txn.execute('ALTER TABLE annotations_new RENAME TO annotations');
|
||||
await txn.execute(
|
||||
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
|
||||
);
|
||||
|
||||
// Create document FTS table
|
||||
await txn.execute('''
|
||||
CREATE VIRTUAL TABLE document_fts USING fts5(
|
||||
document_id, page_number, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
|
||||
// Add rotation column to documents
|
||||
await txn.execute(
|
||||
'ALTER TABLE documents ADD COLUMN rotation INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV4toV5(Database db) async {
|
||||
// Wrap in a transaction so a partial failure does not leave the schema
|
||||
// in an inconsistent state.
|
||||
await db.transaction((txn) async {
|
||||
// Create scratchpads table
|
||||
await txn.execute('''
|
||||
CREATE TABLE scratchpads (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT UNIQUE NOT NULL,
|
||||
strokes_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
|
||||
await txn.execute(
|
||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Notes CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
Future<List<Note>> getAllNotes() async {
|
||||
final noteRows = await _database.query('notes', orderBy: 'updated_at DESC');
|
||||
final notes = <Note>[];
|
||||
for (final row in noteRows) {
|
||||
notes.add(await _noteFromRow(row));
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
Future<Note?> getNoteById(String id) async {
|
||||
final rows = await _database.query(
|
||||
'notes',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _noteFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<void> insertNote(Note note) async {
|
||||
// Atomic: the note row, its strokes, and the FTS index must all commit
|
||||
// together or not at all.
|
||||
await _database.transaction((txn) async {
|
||||
await txn.insert('notes', {
|
||||
'id': note.id,
|
||||
'title': note.title,
|
||||
'created_at': note.createdAt.toIso8601String(),
|
||||
'updated_at': note.updatedAt.toIso8601String(),
|
||||
'tags': jsonEncode(note.tags),
|
||||
});
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
await _insertStroke(txn, note.id, stroke);
|
||||
}
|
||||
|
||||
await _extractAndIndexNoteContent(txn, note);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateNote(Note note) async {
|
||||
// Atomic: this deletes all strokes then re-inserts them and rebuilds the
|
||||
// FTS entry. An interruption mid-way would permanently lose strokes, so
|
||||
// the whole sequence must run inside one transaction.
|
||||
await _database.transaction((txn) async {
|
||||
await txn.update(
|
||||
'notes',
|
||||
{
|
||||
'title': note.title,
|
||||
'updated_at': note.updatedAt.toIso8601String(),
|
||||
'tags': jsonEncode(note.tags),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [note.id],
|
||||
);
|
||||
|
||||
// Replace all strokes for this note
|
||||
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [note.id]);
|
||||
for (final stroke in note.strokes) {
|
||||
await _insertStroke(txn, note.id, stroke);
|
||||
}
|
||||
|
||||
await removeFromFts(txn, note.id);
|
||||
await _extractAndIndexNoteContent(txn, note);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteNote(String id) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [id]);
|
||||
await txn.delete('notes', where: 'id = ?', whereArgs: [id]);
|
||||
await removeFromFts(txn, id);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Strokes ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _insertStroke(
|
||||
DatabaseExecutor db,
|
||||
String noteId,
|
||||
InkStroke stroke,
|
||||
) async {
|
||||
await db.insert('strokes', {
|
||||
'id': stroke.id,
|
||||
'note_id': noteId,
|
||||
'tool': stroke.tool.name,
|
||||
'color': stroke.color,
|
||||
'stroke_width': stroke.strokeWidth,
|
||||
'created_at': stroke.createdAt.toIso8601String(),
|
||||
'points': jsonEncode(stroke.points.map(_pointToJson).toList()),
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<InkStroke>> _getStrokesForNote(String noteId) async {
|
||||
final rows = await _database.query(
|
||||
'strokes',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [noteId],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows.map(_strokeFromRow).toList();
|
||||
}
|
||||
|
||||
// ── Serialization helpers ───────────────────────────────────────────
|
||||
|
||||
Map<String, dynamic> _pointToJson(InkPoint p) => {
|
||||
'x': p.x,
|
||||
'y': p.y,
|
||||
'pressure': p.pressure,
|
||||
'tilt': p.tilt,
|
||||
'timestamp': p.timestamp,
|
||||
'pointerDeviceKind': p.pointerDeviceKind.name,
|
||||
};
|
||||
|
||||
InkPoint _pointFromJson(Map<String, dynamic> json) => InkPoint(
|
||||
x: (json['x'] as num).toDouble(),
|
||||
y: (json['y'] as num).toDouble(),
|
||||
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
|
||||
tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0,
|
||||
timestamp: json['timestamp'] as int,
|
||||
pointerDeviceKind: _parseDeviceKind(json['pointerDeviceKind'] as String?),
|
||||
);
|
||||
|
||||
InputDeviceKind _parseDeviceKind(String? value) {
|
||||
if (value == null) return InputDeviceKind.unknown;
|
||||
return InputDeviceKind.values.asNameMap()[value] ?? InputDeviceKind.unknown;
|
||||
}
|
||||
|
||||
InkStroke _strokeFromRow(Map<String, dynamic> row) {
|
||||
final pointsJson = jsonDecode(row['points'] as String) as List;
|
||||
return InkStroke(
|
||||
id: row['id'] as String,
|
||||
points: pointsJson
|
||||
.map((p) => _pointFromJson(p as Map<String, dynamic>))
|
||||
.toList(),
|
||||
tool: _parsePenTool(row['tool'] as String),
|
||||
color: row['color'] as int,
|
||||
strokeWidth: (row['stroke_width'] as num).toDouble(),
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
PenTool _parsePenTool(String value) {
|
||||
return PenTool.values.asNameMap()[value] ?? PenTool.pen;
|
||||
}
|
||||
|
||||
Future<Note> _noteFromRow(Map<String, dynamic> row) async {
|
||||
final tagsJson = jsonDecode(row['tags'] as String) as List;
|
||||
final strokes = await _getStrokesForNote(row['id'] as String);
|
||||
return Note(
|
||||
id: row['id'] as String,
|
||||
title: row['title'] as String,
|
||||
strokes: strokes,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
tags: tagsJson.cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Full-Text Search (FTS5) ────────────────────────────────────────
|
||||
|
||||
Future<void> _createFtsTable(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
||||
note_id, title, content, tokenize='porter unicode61'
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
/// Index a note's text content for full-text search.
|
||||
/// [content] should include any typed text, OCR text, etc.
|
||||
Future<void> indexNoteContent(
|
||||
DatabaseExecutor db,
|
||||
String noteId,
|
||||
String title,
|
||||
String content,
|
||||
) async {
|
||||
await db.insert('notes_fts', {
|
||||
'note_id': noteId,
|
||||
'title': title,
|
||||
'content': content,
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract text content from a note's strokes and index it for FTS.
|
||||
/// Concatenates the title with any textContent from strokes.
|
||||
Future<void> _extractAndIndexNoteContent(
|
||||
DatabaseExecutor db,
|
||||
Note note,
|
||||
) async {
|
||||
final textParts = <String>[note.title];
|
||||
for (final stroke in note.strokes) {
|
||||
if (stroke.textContent != null && stroke.textContent!.isNotEmpty) {
|
||||
textParts.add(stroke.textContent!);
|
||||
}
|
||||
}
|
||||
final content = textParts.join(' ');
|
||||
await indexNoteContent(db, note.id, note.title, content);
|
||||
}
|
||||
|
||||
/// Append OCR text to an existing note's FTS entry.
|
||||
/// Reads current content, merges with new OCR text, and re-indexes.
|
||||
Future<void> appendOcrToFts(String noteId, String ocrText) async {
|
||||
if (ocrText.trim().isEmpty) return;
|
||||
|
||||
// Read-modify-write must be atomic: querying the current content, removing
|
||||
// the old entry, and re-inserting the merged content all run inside one
|
||||
// transaction so a concurrent writer cannot cause a lost update.
|
||||
await _database.transaction((txn) async {
|
||||
// Read current FTS content
|
||||
final rows = await txn.query(
|
||||
'notes_fts',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [noteId],
|
||||
);
|
||||
|
||||
String existingContent = '';
|
||||
String existingTitle = '';
|
||||
if (rows.isNotEmpty) {
|
||||
existingTitle = rows.first['title'] as String? ?? '';
|
||||
existingContent = rows.first['content'] as String? ?? '';
|
||||
}
|
||||
|
||||
// Merge: append OCR text to existing content
|
||||
final mergedContent = existingContent.isEmpty
|
||||
? ocrText
|
||||
: '$existingContent $ocrText';
|
||||
|
||||
// Remove old entry and re-insert with merged content
|
||||
await removeFromFts(txn, noteId);
|
||||
await indexNoteContent(txn, noteId, existingTitle, mergedContent);
|
||||
});
|
||||
}
|
||||
|
||||
/// Full-text search across indexed notes.
|
||||
Future<List<Note>> searchNotes(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
// Sanitize query for FTS5: escape special chars and add prefix matching
|
||||
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
|
||||
if (sanitized.isEmpty) return [];
|
||||
|
||||
final ftsQuery = sanitized
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((w) => '"$w"*')
|
||||
.join(' ');
|
||||
|
||||
final rows = await _database.rawQuery(
|
||||
'SELECT note_id FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank',
|
||||
[ftsQuery],
|
||||
);
|
||||
|
||||
final notes = <Note>[];
|
||||
for (final row in rows) {
|
||||
final noteId = row['note_id'] as String;
|
||||
final note = await getNoteById(noteId);
|
||||
if (note != null) {
|
||||
notes.add(note);
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Remove a note from the FTS index.
|
||||
Future<void> removeFromFts(DatabaseExecutor db, String noteId) async {
|
||||
await db.delete('notes_fts', where: 'note_id = ?', whereArgs: [noteId]);
|
||||
}
|
||||
|
||||
// ── Document FTS ────────────────────────────────────────────────────
|
||||
|
||||
/// Index a page's text content for document full-text search.
|
||||
Future<void> indexDocumentContent(
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String content,
|
||||
) async {
|
||||
// Remove existing entry for this page first
|
||||
await _database.delete(
|
||||
'document_fts',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await _database.insert('document_fts', {
|
||||
'document_id': documentId,
|
||||
'page_number': pageNumber.toString(),
|
||||
'content': content,
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a page from the document FTS index.
|
||||
Future<void> removeDocumentFromFts(
|
||||
DatabaseExecutor db,
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
) async {
|
||||
await db.delete(
|
||||
'document_fts',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-text search across indexed document pages.
|
||||
Future<List<Map<String, dynamic>>> searchDocuments(String query) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
|
||||
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
|
||||
if (sanitized.isEmpty) return [];
|
||||
|
||||
final ftsQuery = sanitized
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((w) => '"$w"*')
|
||||
.join(' ');
|
||||
|
||||
final rows = await _database.rawQuery(
|
||||
'SELECT document_id, page_number, content FROM document_fts WHERE document_fts MATCH ? ORDER BY rank',
|
||||
[ftsQuery],
|
||||
);
|
||||
|
||||
return rows
|
||||
.map(
|
||||
(row) => {
|
||||
'document_id': row['document_id'] as String,
|
||||
'page_number': int.parse(row['page_number'] as String),
|
||||
'content': row['content'] as String,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Documents CRUD ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> insertDocument(doc.Document document) async {
|
||||
await _database.insert('documents', {
|
||||
'id': document.id,
|
||||
'filename': document.filename,
|
||||
'doc_type': document.docType,
|
||||
'file_path': document.filePath,
|
||||
'page_count': document.pageCount,
|
||||
'rotation': document.rotation,
|
||||
'created_at': document.createdAt.toIso8601String(),
|
||||
'updated_at': document.updatedAt.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<doc.Document?> getDocument(String id) async {
|
||||
final rows = await _database.query(
|
||||
'documents',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _documentFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<doc.Document?> getDocumentByPath(String filePath) async {
|
||||
final rows = await _database.query(
|
||||
'documents',
|
||||
where: 'file_path = ?',
|
||||
whereArgs: [filePath],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _documentFromRow(rows.first);
|
||||
}
|
||||
|
||||
Future<List<doc.Document>> getAllDocuments() async {
|
||||
final rows = await _database.query('documents', orderBy: 'updated_at DESC');
|
||||
return rows.map(_documentFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteDocument(String id) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete('bookmarks', where: 'document_id = ?', whereArgs: [id]);
|
||||
await txn.delete(
|
||||
'ocr_results',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await txn.delete('documents', where: 'id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
doc.Document _documentFromRow(Map<String, dynamic> row) {
|
||||
return doc.Document(
|
||||
id: row['id'] as String,
|
||||
filename: row['filename'] as String,
|
||||
docType: row['doc_type'] as String,
|
||||
filePath: row['file_path'] as String,
|
||||
pageCount: row['page_count'] as int,
|
||||
rotation: (row['rotation'] as int?) ?? 0,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Annotations CRUD ───────────────────────────────────────────────
|
||||
|
||||
Future<void> saveAnnotations(
|
||||
String documentId,
|
||||
int pageNumber,
|
||||
String annotationJson,
|
||||
) async {
|
||||
await _database.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await _database.insert('annotations', {
|
||||
'id': const Uuid().v4(),
|
||||
'uuid': const Uuid().v4(),
|
||||
'document_id': documentId,
|
||||
'page_number': pageNumber,
|
||||
'annotation_json': annotationJson,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> getAnnotations(String documentId, int pageNumber) async {
|
||||
final rows = await _database.query(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['annotation_json'] as String;
|
||||
}
|
||||
|
||||
Future<void> deleteDocumentAnnotations(String documentId) async {
|
||||
await _database.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Annotation/Bookmark Remapping ──────────────────────────────────
|
||||
|
||||
/// After deleting a page at [deletedIndex], shift all annotations
|
||||
/// with page_number > deletedIndex down by 1.
|
||||
Future<void> remapAnnotationsAfterDelete(
|
||||
String documentId,
|
||||
int deletedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE annotations SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
|
||||
[documentId, deletedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After inserting a page at [insertedIndex], shift all annotations
|
||||
/// with page_number >= insertedIndex up by 1.
|
||||
Future<void> remapAnnotationsAfterInsert(
|
||||
String documentId,
|
||||
int insertedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE annotations SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
|
||||
[documentId, insertedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After deleting a page at [deletedIndex], shift all bookmarks
|
||||
/// with page_number > deletedIndex down by 1.
|
||||
Future<void> remapBookmarksAfterDelete(
|
||||
String documentId,
|
||||
int deletedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE bookmarks SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
|
||||
[documentId, deletedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// After inserting a page at [insertedIndex], shift all bookmarks
|
||||
/// with page_number >= insertedIndex up by 1.
|
||||
Future<void> remapBookmarksAfterInsert(
|
||||
String documentId,
|
||||
int insertedIndex,
|
||||
) async {
|
||||
await _database.rawUpdate(
|
||||
'UPDATE bookmarks SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
|
||||
[documentId, insertedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete all annotations, bookmarks, and OCR data for a specific page.
|
||||
Future<void> deletePageData(String documentId, int pageNumber) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'annotations',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await txn.delete(
|
||||
'bookmarks',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await txn.delete(
|
||||
'ocr_results',
|
||||
where: 'document_id = ? AND page_number = ?',
|
||||
whereArgs: [documentId, pageNumber],
|
||||
);
|
||||
await removeDocumentFromFts(txn, documentId, pageNumber);
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the stored page count for a document.
|
||||
Future<void> updateDocumentPageCount(
|
||||
String documentId,
|
||||
int newPageCount,
|
||||
) async {
|
||||
await _database.update(
|
||||
'documents',
|
||||
{
|
||||
'page_count': newPageCount,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bookmarks CRUD ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> insertBookmark(Bookmark bookmark) async {
|
||||
await _database.insert('bookmarks', {
|
||||
'id': bookmark.id,
|
||||
'document_id': bookmark.documentId,
|
||||
'page_number': bookmark.pageNumber,
|
||||
'label': bookmark.label,
|
||||
'color': bookmark.color,
|
||||
'created_at': bookmark.createdAt.toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Bookmark>> getBookmarks(String documentId) async {
|
||||
final rows = await _database.query(
|
||||
'bookmarks',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
return rows.map(_bookmarkFromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteBookmark(String id) async {
|
||||
await _database.delete('bookmarks', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Bookmark _bookmarkFromRow(Map<String, dynamic> row) {
|
||||
return Bookmark(
|
||||
id: row['id'] as String,
|
||||
documentId: row['document_id'] as String,
|
||||
pageNumber: row['page_number'] as int,
|
||||
label: row['label'] as String,
|
||||
color: row['color'] as int,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scratchpad CRUD ────────────────────────────────────────────────
|
||||
|
||||
/// Save scratchpad strokes for a document (upsert).
|
||||
Future<void> saveScratchpad(String documentId, String strokesJson) async {
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await _database.rawInsert(
|
||||
'''INSERT INTO scratchpads (id, document_id, strokes_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(document_id) DO UPDATE SET strokes_json = excluded.strokes_json, updated_at = excluded.updated_at''',
|
||||
[const Uuid().v4(), documentId, strokesJson, now, now],
|
||||
);
|
||||
}
|
||||
|
||||
/// Load scratchpad strokes for a document.
|
||||
Future<List<InkStroke>> loadScratchpad(String documentId) async {
|
||||
final rows = await _database.query(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
);
|
||||
if (rows.isEmpty) return [];
|
||||
final json = rows.first['strokes_json'] as String;
|
||||
if (json.isEmpty || json == '[]') return [];
|
||||
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
|
||||
return list
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
21
lib/services/ocr_engine.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Platform OCR backend. Uses Windows built-in OCR on desktop Windows.
|
||||
class OcrEngine {
|
||||
static const _channel = MethodChannel('badnote/ocr');
|
||||
|
||||
/// Recognize text from a PNG image. Returns null when unavailable or empty.
|
||||
static Future<String?> recognizeImage(Uint8List pngBytes) async {
|
||||
if (!Platform.isWindows) return null;
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('recognize', pngBytes);
|
||||
final text = result?.trim();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/services/ocr_service.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import 'database_service.dart';
|
||||
import 'ocr_engine.dart';
|
||||
import 'stroke_rasterizer.dart';
|
||||
|
||||
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
||||
class OcrService {
|
||||
/// Extract searchable text from [note] and merge into the local FTS index.
|
||||
Future<void> processNote(Note note) async {
|
||||
final parts = <String>[];
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
if (stroke.tool == PenTool.text &&
|
||||
stroke.textContent != null &&
|
||||
stroke.textContent!.trim().isNotEmpty) {
|
||||
parts.add(stroke.textContent!.trim());
|
||||
}
|
||||
}
|
||||
|
||||
final handwritingStrokes = note.strokes
|
||||
.where(
|
||||
(s) =>
|
||||
s.tool != PenTool.eraser &&
|
||||
s.tool != PenTool.text &&
|
||||
s.points.isNotEmpty,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (handwritingStrokes.isNotEmpty) {
|
||||
final png = await StrokeRasterizer.render(handwritingStrokes);
|
||||
if (png != null) {
|
||||
final recognized = await OcrEngine.recognizeImage(png);
|
||||
if (recognized != null && recognized.isNotEmpty) {
|
||||
parts.add(recognized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final combined = parts.join(' ').trim();
|
||||
if (combined.isEmpty) return;
|
||||
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.appendOcrToFts(note.id, combined);
|
||||
}
|
||||
}
|
||||