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);
|
||||||
|
}
|
||||||
|
}
|
||||||