Fix bugs across app + server, optimize UI/UX, add Gitea CI
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled

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>
This commit is contained in:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

101
.gitea/workflows/ci.yml Normal file
View 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

View 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
View 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
View 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
View 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
View 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
View 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

View 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 = "../.."
}

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
package com.badnote.badnote
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

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

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View 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

View 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
View 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

View 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>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

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

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View 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.

View 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>

View 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
View 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>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

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

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

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

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

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

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

View 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',
};

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

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

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

View 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
View 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
View File

@@ -0,0 +1,11 @@
enum PenTool {
pen,
marker,
eraser,
highlighter,
rectangle,
ellipse,
line,
arrow,
text,
}

View 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,
}

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

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

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

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

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

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

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

View 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,
),
),
),
],
),
),
),
),
);
}
}

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

View 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,
),
],
),
],
),
),
),
);
}
}

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

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

View 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),
),
],
),
);
}
}

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show More