Compare commits
44 Commits
1d70c029b3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 307161f465 | |||
| ad9b1b46db | |||
| f31dd0fb52 | |||
| 85af037b7d | |||
| 4f6fb69dee | |||
| 2b1c6ba7e0 | |||
| 4a6fe7d05e | |||
| 198da00ecd | |||
| d346cc2670 | |||
| 3cabc7e074 | |||
| e939759458 | |||
| 20add27a30 | |||
| 1d5ba05bb8 | |||
| c800295c12 | |||
| 46589a4c87 | |||
| 6c2dd71b82 | |||
| 24d13642fd | |||
| 4886f1b2df | |||
| f4f0853eae | |||
| 2f0fda5f95 | |||
| 978111eeff | |||
| 953c7b700f | |||
| 9fcac47ef2 | |||
| 875dabcd89 | |||
| fd102b5703 | |||
| 9bb5c483d6 | |||
| f757701391 | |||
| 0feca74278 | |||
| 45a8931b64 | |||
| db6e3842c7 | |||
| f41df2033f | |||
| df389177e6 | |||
| 8908f42f76 | |||
| eae4493954 | |||
| 0ae2671f9b | |||
| ffb9e35755 | |||
| dfe5f2a477 | |||
| 3507e929b1 | |||
| 96594fbe1b | |||
| a7d71e7cfd | |||
| d1b265dc70 | |||
| 2c9f6037f7 | |||
| 4fb431727e | |||
| 299b9546a8 |
33
.claude/pua-loop-context.md
Normal file
33
.claude/pua-loop-context.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# PUA Loop — status (BadNote 整体重构)
|
||||||
|
|
||||||
|
## Oracle: `flutter analyze lib/editor && flutter test` → GREEN, exit 0, 145/145.
|
||||||
|
|
||||||
|
## Delivered this loop (all committed + pushed, 6829076..eedb52d)
|
||||||
|
P0 completion + P0.5 automatable layer + bonus pure cores:
|
||||||
|
- 6829076 zoom absolute-snapshot fix + pen streamline (P0/live)
|
||||||
|
- a48c0e7 input_arbiter (P0 step4)
|
||||||
|
- d500872 export single-source recipe / R7 (P0 step7)
|
||||||
|
- f64e656 SaveScheduler tests (P0 step8)
|
||||||
|
- eca5141 live canvas → revision-gated ui.Picture cache (P0 step3)
|
||||||
|
- 07b543f PageTileCache DPI-bucketed (P0.5)
|
||||||
|
- 1a3d106 PageStackMetrics windowing (P0.5)
|
||||||
|
- 6b7cc14 PageDocumentSource + fit-to-width glue (P0.5)
|
||||||
|
- c03513d PdfrxPageDocumentSource + API source-pin (P0.5 step12/SF4)
|
||||||
|
- c31bfd3 navigation math current-page/scroll-clamp (P0.5)
|
||||||
|
- 852eb38 双链 link_graph pure core (F7)
|
||||||
|
- eedb52d pressure curve floor+gamma (F5)
|
||||||
|
|
||||||
|
## THE BLOCKER (honest)
|
||||||
|
The refactor's CRITICAL PATH is device validation, which only the user's Surface
|
||||||
|
can provide and which the plan ITSELF gates on:
|
||||||
|
- P0 step9: pen/palm/pinch on Surface (build eedb52d).
|
||||||
|
- P0.5 exit: crisp-at-4× + 60fps profile — no automatable acceptance test exists.
|
||||||
|
The remaining work (page_tile renderer, page_viewport WIDGET, perf bench) is
|
||||||
|
device/GPU-gated; writing it blind = a claim with no acceptance evidence.
|
||||||
|
|
||||||
|
## Options for the user
|
||||||
|
1. Device-test eedb52d (zoom/pen/render/export) → I wire the P0.5 pure pieces
|
||||||
|
into the viewport widget and push the P0.5 device gate.
|
||||||
|
2. Tell me to keep PRE-BUILDING unwired pure cores (F6 page-map, F8 snippet
|
||||||
|
extraction, more F5/F7) — real + tested, but NOT on the blocked critical path.
|
||||||
|
3. /pua:cancel-pua-loop to end the loop.
|
||||||
3
.claude/pua-loop-history.jsonl
Normal file
3
.claude/pua-loop-history.jsonl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{"iteration":0,"status":"init","verify_command":"flutter analyze","timestamp":"2026-06-23T01:25:48Z"}
|
||||||
|
{"iteration":1,"status":"continue","timestamp":"2026-06-23T01:57:58Z"}
|
||||||
|
{"iteration":2,"status":"continue","timestamp":"2026-06-23T02:07:19Z"}
|
||||||
@@ -220,7 +220,13 @@ jobs:
|
|||||||
HTTPS_PROXY: http://192.168.31.189:7890
|
HTTPS_PROXY: http://192.168.31.189:7890
|
||||||
http_proxy: http://192.168.31.189:7890
|
http_proxy: http://192.168.31.189:7890
|
||||||
https_proxy: http://192.168.31.189:7890
|
https_proxy: http://192.168.31.189:7890
|
||||||
run: flutter build windows --release
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$sha = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.Substring(0, [Math]::Min(12, $env:GITHUB_SHA.Length)) } else { "unknown" }
|
||||||
|
$built = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
|
||||||
|
flutter build windows --release `
|
||||||
|
--dart-define="BADNOTE_GIT_SHA=$sha" `
|
||||||
|
--dart-define="BADNOTE_BUILD_TIME=$built"
|
||||||
|
|
||||||
- name: Show build output
|
- name: Show build output
|
||||||
shell: powershell
|
shell: powershell
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,17 +1,20 @@
|
|||||||
# BadNote
|
# BadNote
|
||||||
|
|
||||||
Local-first Surface Pen note-taking app with PDF/PPT annotation.
|
Local-first Surface Pen note-taking app with PDF / PPTX / DOCX annotation.
|
||||||
|
|
||||||
All notes, documents, search, and OCR run on your device. No server is required to use the app.
|
All notes, documents, search, and OCR run on your device. No server is required to use the app.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo)
|
- Unified shell: Library · Sticky board · Search · Settings
|
||||||
- PDF and PPT import with page-level annotation
|
- Ink notes with Surface Pen (pressure, predictor, undo/redo)
|
||||||
|
- PDF annotation + native OOXML PPTX/DOCX viewers (no LibreOffice required)
|
||||||
|
- Infinite sticky board with `[[wikilinks]]` / backlinks
|
||||||
- Full-text search over note titles, typed text, and OCR results
|
- Full-text search over note titles, typed text, and OCR results
|
||||||
- **Local OCR** — pluggable, fully on-device. An embedded ONNX recognition
|
- Always-on diagnostics + one-click diagnostic pack export (Settings)
|
||||||
backend (cross-platform, CPU/iGPU) with a graceful fallback to the platform's
|
- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
|
||||||
built-in OCR (Windows). See [Local OCR](#local-ocr).
|
- **Local OCR** — ONNX when bundled, else Windows WinRT
|
||||||
|
- WebDAV vault sync (NAS)
|
||||||
|
|
||||||
## Build (Windows)
|
## Build (Windows)
|
||||||
|
|
||||||
|
|||||||
7888
badnote_input_log-2.txt
Normal file
7888
badnote_input_log-2.txt
Normal file
File diff suppressed because it is too large
Load Diff
602
docs/plans/2026-06-24-file-based-storage.md
Normal file
602
docs/plans/2026-06-24-file-based-storage.md
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
# BadNote — File-Based Storage Re-Architecture (Obsidian-style vault)
|
||||||
|
|
||||||
|
**Status:** DESIGN (not yet implemented)
|
||||||
|
**Date:** 2026-06-24
|
||||||
|
**Owner plan file:** `docs/plans/2026-06-24-file-based-storage.md`
|
||||||
|
**Mode:** DELIBERATE (touches persistence of a working pen editor — must not lose ink)
|
||||||
|
|
||||||
|
> Grounding note: written after reading the actual current sources —
|
||||||
|
> `lib/services/database_service.dart`, `lib/editor/persistence/editor_repository.dart`,
|
||||||
|
> `lib/editor/persistence/save_scheduler.dart`, `lib/editor/canvas/pen_editor_screen.dart`,
|
||||||
|
> `lib/screens/split_view_screen.dart`, `lib/screens/home_screen.dart`,
|
||||||
|
> `lib/services/pdf_service.dart`, `lib/services/pptx_service.dart`,
|
||||||
|
> `lib/providers/document_provider.dart`, `lib/providers/settings_provider.dart`,
|
||||||
|
> `lib/main.dart`, and the stroke models
|
||||||
|
> (`lib/editor/engine/stroke_model.dart`, `lib/models/ink_stroke.dart`,
|
||||||
|
> `lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
|
||||||
|
> `lib/models/scratch_link.dart`, `lib/models/bookmark.dart`,
|
||||||
|
> `lib/models/document.dart`). `file_picker` `getDirectoryPath` (Windows desktop
|
||||||
|
> support) confirmed via Context7 against the installed `file_picker: ^8.0.0`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The user's intent (verbatim)
|
||||||
|
|
||||||
|
1. **"Import file" is a TOP-LEVEL home-screen action**, sibling of "Create
|
||||||
|
notebook", accepting **multiple file types** (docx, pptx, pdf, …).
|
||||||
|
2. **Annotations travel WITH the file** ("跟着文件走") — stored as JSON
|
||||||
|
**sidecar files** next to the source file, **NOT in SQLite**. Sync follows the
|
||||||
|
file.
|
||||||
|
3. After importing a PDF, the file is placed inside a **notebook folder**, then
|
||||||
|
synced to a **server**. User believes this is **more robust than SQLite**.
|
||||||
|
4. On first launch, the user **picks a notebook ROOT folder** (an Obsidian-style
|
||||||
|
vault).
|
||||||
|
5. **Robust auto-save.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What is stored in SQLite TODAY (inventory)
|
||||||
|
|
||||||
|
Read from `lib/services/database_service.dart` (schema version **8**). This is the
|
||||||
|
data that must move to files or be re-homed.
|
||||||
|
|
||||||
|
| Table | Written/read by | Holds | Coordinate format | Disposition |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `documents` | `DocumentListNotifier` (`document_provider.dart`), `DatabaseService.insertDocument/getAllDocuments/deleteDocument` | id (uuid), filename, doc_type (`pdf`/`pptx`), **absolute** file_path, page_count, rotation, timestamps | — | → derived from vault scan; becomes a cache/index only |
|
||||||
|
| `ink` | `EditorRepository` (`editor_repository.dart`), `SaveScheduler` | committed PDF-editor strokes; one row/stroke; `host_id = "doc:<documentId>:page:<pageIndex>"`, `stroke_json` = `EditorStroke.toJson()` | normalized [0,1], width = fraction of page width | → **sidecar `strokes[pageIndex]`** |
|
||||||
|
| `scratch_links` | `PenEditorScreen._placeScratchLink`, `DatabaseService.saveScratchLink/loadScratchLinks` | anchor id, document_id, page_index, nx, ny | normalized [0,1] | → **sidecar `scratchLinks[]`** |
|
||||||
|
| `scratchpads` | `SplitViewScreen` (keyed by **anchor id**), `DatabaseService.saveScratchpad/loadScratchpad` | one row per anchor id; `strokes_json` = list of `InkStroke.toJson()` | **absolute world pixels** (infinite canvas) | → **sidecar `scratchLinks[].scratchpad.strokes[]`** |
|
||||||
|
| `bookmarks` | `DatabaseService.insertBookmark/getBookmarks` | id, document_id, page_number, label, color | page index | → **sidecar `bookmarks[]`** |
|
||||||
|
| `annotations` | `DatabaseService.saveAnnotations/getAnnotations` | legacy per-page `annotation_json` blob | per-page | **DEAD in new editor** — migrate if present, else ignore |
|
||||||
|
| `notes` + `strokes` | `note_provider.dart` via `insertNote/updateNote`; `Note` model | free ink notebooks (not file-backed); strokes are `InkStroke` rows | normalized | → **standalone notebook sidecars** (see §A.4) |
|
||||||
|
| `board_cards` | `DatabaseService.saveBoardCards/loadBoard` (F7 sticky board) | board cards geometry+text | absolute | → **per-notebook `board.json`** (or keep in SQLite short-term, §B) |
|
||||||
|
| `document_fts`, `notes_fts`, `ocr_results`, `notebook_pages` | FTS + OCR | search index, OCR text | — | **stays in SQLite as a rebuildable cache** (never the source of truth) |
|
||||||
|
|
||||||
|
**Key existing facts the new format must preserve (cite):**
|
||||||
|
|
||||||
|
- `EditorStroke.toJson()` (freezed/json_serializable, `stroke_model.dart`) emits:
|
||||||
|
`{ "id", "points":[{ "x","y","pressure","tilt","timestamp","pointerDeviceKind" }], "tool":"pen"|"highlighter"|"eraser", "color":<int ARGB>, "width":<double>, "filled":<bool>, "textContent":<String?>, "fontSize":<double> }`.
|
||||||
|
**`brush` is deliberately NOT serialized** (`@JsonKey(includeFromJson:false,includeToJson:false)` in `stroke_model.dart`), so loaded strokes default to `BrushKind.fountainPen`. The sidecar format inherits this limitation (see §A.5 "brush TODO").
|
||||||
|
- `InkStroke.toJson()` (`ink_stroke.dart`) emits the same point shape plus
|
||||||
|
`createdAt` and `strokeWidth` (note: `strokeWidth`, not `width`). This is the
|
||||||
|
format already persisted for scratchpads and free notes.
|
||||||
|
- `ScratchLink.toJson()` (`scratch_link.dart`, hand-written): `{id, documentId, pageIndex, nx, ny}`.
|
||||||
|
- The editor derives its **documentId from the file path** via a djb2 hash
|
||||||
|
(`_documentIdFromPath` in `pen_editor_screen.dart`). Once strokes live in a
|
||||||
|
sidecar next to the file, **the path-hash document id becomes irrelevant** —
|
||||||
|
the sidecar IS the identity. This removes a class of bugs (moving a file
|
||||||
|
orphaned its SQLite rows).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decision summary (what we are building)
|
||||||
|
|
||||||
|
- A **vault** = a user-picked root folder, path stored in `SharedPreferences`.
|
||||||
|
- Each imported document becomes a **notebook folder** inside the vault:
|
||||||
|
`<vault>/<notebook-name>/` containing the **source file** + one **sidecar**
|
||||||
|
`<file>.badnote.json` holding all annotations for that file.
|
||||||
|
- **The sidecar is the source of truth.** SQLite is demoted to a
|
||||||
|
**rebuildable index/cache** (FTS + thumbnails + recent list). Recommendation:
|
||||||
|
**keep SQLite, but only as cache** (§B explains why dropping it entirely is
|
||||||
|
more work than it's worth right now).
|
||||||
|
- **Auto-save** writes the sidecar atomically (temp + rename), debounced, with
|
||||||
|
flush on pause/close.
|
||||||
|
- **Sync** = file-level sync of the whole vault folder to a server; sync unit =
|
||||||
|
notebook folder; conflict policy = last-write-wins per file + `.conflict` copy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. On-disk layout + JSON schema
|
||||||
|
|
||||||
|
### A.1 Vault layout
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault root>/ ← user-picked, persisted in SharedPreferences
|
||||||
|
├─ .badnote/ ← vault-level app metadata (hidden)
|
||||||
|
│ ├─ vault.json ← { "schemaVersion": 1, "vaultId": "<uuid>", "createdAt": ... }
|
||||||
|
│ └─ index.sqlite ← OPTIONAL local cache/index (FTS, thumbnails). Rebuildable. NOT synced.
|
||||||
|
├─ Calculus Lecture 3/ ← a notebook folder (one per imported doc)
|
||||||
|
│ ├─ Calculus Lecture 3.pdf ← the source file (pdf/docx/pptx/…)
|
||||||
|
│ ├─ Calculus Lecture 3.pdf.badnote.json ← the sidecar (annotations travel with the file)
|
||||||
|
│ └─ .badnote-assets/ ← optional: rendered page PNGs for pptx/docx, thumbnails
|
||||||
|
│ ├─ slide_1.png …
|
||||||
|
│ └─ thumb_0.png …
|
||||||
|
├─ My freehand notes/ ← a NON-file-backed notebook (free ink, no source doc)
|
||||||
|
│ └─ notebook.badnote.json ← strokes-only notebook (replaces SQLite notes/strokes)
|
||||||
|
└─ …
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- **Notebook folder name** = sanitized source filename (basename without
|
||||||
|
extension), de-duplicated with a numeric suffix on collision.
|
||||||
|
- **Sidecar name** = `<source filename incl. ext>.badnote.json`. Keeping the full
|
||||||
|
source extension in the sidecar name means a folder with both `slides.pdf` and
|
||||||
|
`slides.pptx` never collides.
|
||||||
|
- **Sidecar lives in the same folder as its file** → moving/copying/syncing the
|
||||||
|
folder moves the annotations with it ("跟着文件走"). This is the whole point.
|
||||||
|
- `.badnote/index.sqlite` is **per-vault** and **excluded from sync** (it is a
|
||||||
|
cache; each device rebuilds its own). It replaces today's
|
||||||
|
`getApplicationDocumentsDirectory()/badnote.db`.
|
||||||
|
|
||||||
|
### A.2 Sidecar JSON schema (file-backed document)
|
||||||
|
|
||||||
|
`<file>.badnote.json` (UTF-8, pretty-printed for diff-friendliness / git sync):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"badnoteSidecarVersion": 1,
|
||||||
|
"sourceFile": "Calculus Lecture 3.pdf",
|
||||||
|
"docType": "pdf",
|
||||||
|
"pageCount": 42,
|
||||||
|
"rotation": 0,
|
||||||
|
"createdAt": "2026-06-24T10:00:00.000Z",
|
||||||
|
"updatedAt": "2026-06-24T10:32:11.500Z",
|
||||||
|
|
||||||
|
"strokes": {
|
||||||
|
"0": [ <EditorStroke.toJson()>, <EditorStroke.toJson()>, … ],
|
||||||
|
"3": [ <EditorStroke.toJson()>, … ]
|
||||||
|
},
|
||||||
|
|
||||||
|
"highlights": {
|
||||||
|
"0": [ { "l": 0.12, "t": 0.20, "r": 0.88, "b": 0.235, "color": 1714657595 } ]
|
||||||
|
},
|
||||||
|
|
||||||
|
"bookmarks": [
|
||||||
|
{ "id": "<uuid>", "pageNumber": 5, "label": "Proof", "color": 4283215696, "createdAt": "…" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"scratchLinks": [
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"pageIndex": 7,
|
||||||
|
"nx": 0.83, "ny": 0.41,
|
||||||
|
"createdAt": "…",
|
||||||
|
"scratchpad": {
|
||||||
|
"canvasWidth": 4000,
|
||||||
|
"canvasHeight": 4000,
|
||||||
|
"strokes": [ <InkStroke.toJson()>, … ] // absolute world pixels (unchanged format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema notes, tied to existing code:
|
||||||
|
|
||||||
|
- **`strokes`** is a map keyed by **string page index** → list of
|
||||||
|
`EditorStroke.toJson()` objects. This is byte-for-byte the JSON already written
|
||||||
|
to the `ink` table's `stroke_json` column by `EditorRepository.saveHost`
|
||||||
|
(`jsonEncode(stroke.toJson())`). Loading just calls `EditorStroke.fromJson`.
|
||||||
|
- Drop the `host_id = "doc:<id>:page:<i>"` convention entirely; the sidecar key
|
||||||
|
IS the page index. `EditorRepository.pageHostId` /
|
||||||
|
`_pageIndexFromHostId` (in `pen_editor_screen.dart`) become obsolete for the
|
||||||
|
file path.
|
||||||
|
- **`highlights`** persists what is TODAY in-memory only — see
|
||||||
|
`_highlightsByPage` and `TODO(persist-highlights)` in `pen_editor_screen.dart`.
|
||||||
|
Each rect stored normalized [0,1] (`l/t/r/b`) exactly as `_highlightSelection`
|
||||||
|
computes it. This closes that TODO as a side benefit.
|
||||||
|
- **`bookmarks`** mirrors `Bookmark.toJson()` (`bookmark.dart`); `documentId`
|
||||||
|
field is dropped (the sidecar already scopes it).
|
||||||
|
- **`scratchLinks[]`** merges today's TWO tables: `scratch_links` (anchor
|
||||||
|
geometry) + `scratchpads` (the anchor's private ink, currently keyed by anchor
|
||||||
|
id). Each anchor now **embeds** its scratchpad. `scratchpad.strokes` keep the
|
||||||
|
`InkStroke.toJson()` format in **absolute world pixels** — unchanged from
|
||||||
|
`SplitViewScreen._saveImmediate`, so the infinite-canvas logic
|
||||||
|
(`_checkCanvasExpansion`, `penStrokesFromInk`) needs no change. We persist
|
||||||
|
`canvasWidth/Height` so the world size restores (today it always resets to
|
||||||
|
4000×4000).
|
||||||
|
|
||||||
|
### A.3 Why normalized vs absolute coords are preserved verbatim
|
||||||
|
|
||||||
|
- PDF-editor strokes (`EditorStroke`) are normalized to the page rect; width is a
|
||||||
|
fraction of page width (`stroke_model.dart` header comment). Sidecar stores them
|
||||||
|
unchanged → no re-projection, no rounding drift, export
|
||||||
|
(`PdfService.exportAnnotatedPdf`) keeps working untouched.
|
||||||
|
- Scratchpad strokes (`InkStroke`) are absolute world pixels (infinite canvas).
|
||||||
|
Sidecar stores them unchanged.
|
||||||
|
- This is a **format-preserving** migration: same `toJson`/`fromJson`, different
|
||||||
|
container (file vs row). That is what makes it low-risk.
|
||||||
|
|
||||||
|
### A.4 Standalone (non-file) notebooks — the "Create notebook" path
|
||||||
|
|
||||||
|
Free-ink notes today live in `notes` + `strokes` (`Note` model, `note_provider.dart`).
|
||||||
|
In the vault they become a notebook folder with **no source file**, holding a
|
||||||
|
`notebook.badnote.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"badnoteSidecarVersion": 1,
|
||||||
|
"docType": "notebook",
|
||||||
|
"title": "My freehand notes",
|
||||||
|
"tags": ["math"],
|
||||||
|
"createdAt": "…", "updatedAt": "…",
|
||||||
|
"pages": [
|
||||||
|
{ "ordinal": 0, "strokes": [ <InkStroke.toJson()>, … ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`InkStroke.toJson()` is the exact format `Note.strokes` already serialize to.)
|
||||||
|
|
||||||
|
### A.5 Brush persistence caveat (honest limitation)
|
||||||
|
|
||||||
|
`EditorStroke.brush` and `PenStroke.brush` are **not serialized** today
|
||||||
|
(`@JsonKey(includeFromJson:false,includeToJson:false)`, see `stroke_model.dart`
|
||||||
|
and `TODO(brush-persist)` in `pen_editor_screen.dart`). The sidecar inherits this:
|
||||||
|
a reloaded pen stroke renders as `fountainPen`; a highlighter is recovered from
|
||||||
|
`tool == highlighter`. **Recommendation:** add an optional `"brush"` field to the
|
||||||
|
sidecar `EditorStroke` JSON in a later increment by flipping the `@JsonKey` — the
|
||||||
|
sidecar schema is forward-compatible (unknown fields ignored on read), so this is
|
||||||
|
non-breaking. Not required for this storage migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Migration: SQLite → sidecars (no data loss)
|
||||||
|
|
||||||
|
**Recommendation: KEEP SQLite, demote it to a rebuildable cache. Do NOT drop it.**
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
- FTS5 (`document_fts`, `notes_fts`) and OCR (`ocr_results`) are non-trivial and
|
||||||
|
query-shaped; re-implementing search over flat JSON files is a separate project.
|
||||||
|
Keep them in `.badnote/index.sqlite`, rebuilt by scanning sidecars.
|
||||||
|
- The home screen's "recent documents" list (`getAllDocuments`) wants fast sorted
|
||||||
|
access; a cache table is the pragmatic backing for it (the source of truth is
|
||||||
|
still the vault scan).
|
||||||
|
- Dropping SQLite forces rewriting `note_provider`, `document_provider`,
|
||||||
|
`search_provider`, OCR, and the board in one shot — high blast radius. Demoting
|
||||||
|
is incremental and reversible.
|
||||||
|
|
||||||
|
### B.1 One-time migration on first launch after upgrade
|
||||||
|
|
||||||
|
Guarded by a `SharedPreferences` flag `vaultMigrationDone` (and only runs once a
|
||||||
|
vault root exists — see §C). Algorithm:
|
||||||
|
|
||||||
|
1. Open the legacy DB at `getApplicationDocumentsDirectory()/badnote.db` (the path
|
||||||
|
`DatabaseService._initialize` uses today). If absent → nothing to migrate.
|
||||||
|
2. For each row in `documents`:
|
||||||
|
a. Resolve the legacy `file_path` (absolute). If the file still exists, **copy**
|
||||||
|
it into a new notebook folder `<vault>/<sanitized filename>/`.
|
||||||
|
b. Build the sidecar:
|
||||||
|
- `strokes`: query `ink WHERE host_id LIKE 'doc:<documentId>:page:%'`
|
||||||
|
(the `EditorRepository.loadDocument` query), group by page index parsed
|
||||||
|
from host_id, write each `stroke_json` straight through (it's already
|
||||||
|
`EditorStroke` JSON — no re-encode).
|
||||||
|
- `bookmarks`: `getBookmarks(documentId)`.
|
||||||
|
- `scratchLinks`: `loadScratchLinks(documentId)`; for each, `loadScratchpad(anchorId)`
|
||||||
|
→ embed as `scratchpad.strokes` (re-encode via `InkStroke.toJson`).
|
||||||
|
- `annotations` (legacy per-page blob): if any rows exist, attempt to decode
|
||||||
|
and fold into `strokes`; if format is unrecognized, copy the raw blob into
|
||||||
|
a `legacyAnnotations` field so nothing is silently dropped.
|
||||||
|
c. Write the sidecar **atomically** (§F).
|
||||||
|
3. For each `notes` row → write a standalone notebook sidecar (§A.4) under a
|
||||||
|
notebook folder.
|
||||||
|
4. For `board_cards`: SHORT TERM leave them in SQLite (board is self-contained and
|
||||||
|
not part of the "files" intent). LATER, write a `board.json` per notebook.
|
||||||
|
5. **Do not delete the legacy DB.** Rename it to `badnote.db.premigration` as a
|
||||||
|
safety net. Set `vaultMigrationDone = true`.
|
||||||
|
6. Rebuild `.badnote/index.sqlite` (FTS + recent list) by scanning the new vault.
|
||||||
|
|
||||||
|
### B.2 Crash safety of migration
|
||||||
|
|
||||||
|
- Process sidecars one notebook at a time; each sidecar write is atomic.
|
||||||
|
- The migration is **idempotent**: re-running skips notebook folders whose sidecar
|
||||||
|
already exists and validates. If it dies halfway, relaunch resumes.
|
||||||
|
- Because the legacy DB is preserved until the flag flips, a failed migration
|
||||||
|
loses nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Folder picker on init (the vault prompt)
|
||||||
|
|
||||||
|
Use **`file_picker`** — already a dependency (`file_picker: ^8.0.0` in
|
||||||
|
`pubspec.yaml`, already used by `PdfService.pickPdfFile` /
|
||||||
|
`PptxService.openPptxFile`). Its **`FilePicker.platform.getDirectoryPath()`** is
|
||||||
|
**Desktop/Windows supported** (confirmed via Context7). **No new package needed.**
|
||||||
|
Do NOT add `file_selector` — `file_picker` already covers both file and directory
|
||||||
|
picking and is wired in.
|
||||||
|
|
||||||
|
### C.1 Persistence + flow
|
||||||
|
|
||||||
|
- New `VaultService` (singleton, like `DatabaseService`):
|
||||||
|
- `Future<String?> getVaultRoot()` — reads `SharedPreferences` key `vaultRoot`.
|
||||||
|
- `Future<void> setVaultRoot(String path)` — writes it.
|
||||||
|
- `Future<bool> vaultRootValid()` — true iff the stored path exists and is a
|
||||||
|
writable directory.
|
||||||
|
- `main.dart` change: after `SharedPreferences.getInstance()`, check
|
||||||
|
`vaultRootValid()`.
|
||||||
|
- If **valid** → go to `HomeScreen` as today.
|
||||||
|
- If **missing/invalid** → show a `VaultSetupScreen` (a gate before
|
||||||
|
`HomeScreen`) that explains "Pick a folder to store your notebooks (like an
|
||||||
|
Obsidian vault)" and calls `getDirectoryPath(dialogTitle: 'Choose your BadNote vault', lockParentWindow: true)`.
|
||||||
|
On selection: create `<root>/.badnote/vault.json`, persist the path, then
|
||||||
|
enter `HomeScreen`.
|
||||||
|
- **Re-prompt if missing:** if the stored path later disappears (external drive
|
||||||
|
unplugged, folder deleted), `vaultRootValid()` returns false → the gate shows
|
||||||
|
again with a "your vault folder is missing — relocate or pick a new one"
|
||||||
|
message. Never silently fall back to app-documents (that would scatter data).
|
||||||
|
- A **"Change vault"** entry in `SettingsScreen` re-runs the picker.
|
||||||
|
|
||||||
|
### C.2 Windows specifics
|
||||||
|
|
||||||
|
- Pass `lockParentWindow: true` so the native dialog is modal (Context7 note).
|
||||||
|
- Wrap in try/catch (Context7 shows `getDirectoryPath` can throw on Windows for
|
||||||
|
permission/system issues) and surface a retry.
|
||||||
|
- Validate the chosen folder is writable by writing+deleting a probe file before
|
||||||
|
committing it as the vault.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Multi-format import (docx / pptx / pdf)
|
||||||
|
|
||||||
|
This is the genuinely hard part. Be honest about it.
|
||||||
|
|
||||||
|
### D.1 The home-screen entry (requirement #1)
|
||||||
|
|
||||||
|
Replace today's two separate `IconButton`s (`_importPdf`, `_importPptx` in
|
||||||
|
`home_screen.dart`) and the empty-state buttons with **one top-level "Import
|
||||||
|
file" action**, a sibling of "Create notebook" (the FAB / `_createAndOpenNote`).
|
||||||
|
A single `FilePicker.pickFiles(type: FileType.custom, allowedExtensions: ['pdf','docx','pptx','ppt'])`
|
||||||
|
call; route on extension. Both actions sit at the same visual level (e.g. two
|
||||||
|
primary buttons in the empty state, and two entries in the app bar / a small
|
||||||
|
"+ New" menu with "Create notebook" and "Import file").
|
||||||
|
|
||||||
|
### D.2 Per-format strategy
|
||||||
|
|
||||||
|
| Format | Annotate how | Mechanism | Windows-viable? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **PDF** | Directly, as today | `pdfrx` `PdfViewer` + normalized ink overlay (`PenEditorScreen`, unchanged) | YES — already shipping |
|
||||||
|
| **PPTX/PPT** | **Convert to images, annotate as slides** | `PptxService.convertToImages` (LibreOffice headless → PNG, fallback placeholders) + `PenSlideScreen` | PARTIAL — needs LibreOffice |
|
||||||
|
| **DOCX** | **Convert to PDF, then annotate as PDF** (recommended) | LibreOffice headless `--convert-to pdf`, then the PDF path flows into `PenEditorScreen` | PARTIAL — needs LibreOffice |
|
||||||
|
|
||||||
|
### D.3 The realistic recommendation
|
||||||
|
|
||||||
|
- **PDF:** unchanged. Place the picked file into a notebook folder, open
|
||||||
|
`PenEditorScreen` on the **vault copy** (not the original picked path).
|
||||||
|
- **DOCX → PDF (convert-on-import):** the cleanest path is to **convert DOCX to a
|
||||||
|
PDF once, at import time**, store the **PDF** as the notebook's annotatable
|
||||||
|
artifact (keep the original `.docx` alongside it for fidelity/round-trip). Then
|
||||||
|
everything downstream is the existing, working PDF pipeline. This is far simpler
|
||||||
|
than rendering Word layout natively in Flutter (there is no good pure-Dart DOCX
|
||||||
|
renderer).
|
||||||
|
- **PPTX:** keep the existing slide-image path (`PptxService` + `PenSlideScreen`),
|
||||||
|
but **cache the rendered PNGs into the notebook's `.badnote-assets/`** instead of
|
||||||
|
a temp dir (today `convertToImages` writes to `getTemporaryDirectory()`, so
|
||||||
|
slides re-render every open — see `home_screen.dart` `_openDocument`). Caching
|
||||||
|
also makes the notebook self-contained for sync.
|
||||||
|
|
||||||
|
### D.4 The hard truth about conversion (call it out)
|
||||||
|
|
||||||
|
- Both DOCX→PDF and PPTX→PNG currently depend on **LibreOffice headless** being on
|
||||||
|
`PATH` (`PptxService._convertViaLibreOffice` runs `which libreoffice` then
|
||||||
|
`libreoffice --headless --convert-to …`). On a **Windows tablet, LibreOffice is
|
||||||
|
usually NOT installed**, and `which` is a POSIX tool that won't resolve `soffice.exe`.
|
||||||
|
**This path will silently fail today on Windows** and fall back to placeholder
|
||||||
|
slides.
|
||||||
|
- **Proposed fallbacks, in order:**
|
||||||
|
1. **Detect LibreOffice/`soffice.exe`** at the standard Windows install paths
|
||||||
|
(`C:\Program Files\LibreOffice\program\soffice.exe`) in addition to `PATH`;
|
||||||
|
invoke `soffice` (not `libreoffice`) on Windows. Fix the `which` assumption.
|
||||||
|
2. If absent, **prompt the user** ("Install LibreOffice to import Word/PowerPoint,
|
||||||
|
or convert to PDF first"), and offer a **"locate soffice.exe" picker** that we
|
||||||
|
persist in SharedPreferences.
|
||||||
|
3. **Bundle/ship nothing heavy.** Do not attempt to embed a converter. For a
|
||||||
|
single-user tablet app, requiring LibreOffice (or pre-export to PDF) is an
|
||||||
|
acceptable, honest constraint.
|
||||||
|
- **Minimal first cut:** ship **PDF import end-to-end on the new vault**, plus the
|
||||||
|
one-file "Import file" entry that *accepts* docx/pptx, but for docx/pptx route
|
||||||
|
through the existing (LibreOffice-dependent) converters with the Windows
|
||||||
|
`soffice.exe` fix. Treat full docx/pptx fidelity as a known limitation, not a
|
||||||
|
blocker for the storage re-architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Server sync (file-level, single user)
|
||||||
|
|
||||||
|
Keep it pragmatic. The data is now plain files in one folder, which is exactly what
|
||||||
|
makes simple sync viable.
|
||||||
|
|
||||||
|
### E.1 Sync unit & approach
|
||||||
|
|
||||||
|
- **Sync unit = the notebook folder** (source file + sidecar + assets). A notebook
|
||||||
|
is self-contained, so syncing the folder syncs the annotations with it.
|
||||||
|
- **Exclude** `.badnote/index.sqlite` and `.badnote-assets/` from sync if desired
|
||||||
|
(assets are re-derivable; the index is per-device). Source file + sidecar are the
|
||||||
|
must-sync pair.
|
||||||
|
- **Recommended concrete approach (single-user): WebDAV** to a self-hosted/Nextcloud
|
||||||
|
endpoint, OR a **simple REST blob sync** if the user controls the server.
|
||||||
|
- WebDAV is the lowest-effort robust option (PUT/GET/PROPFIND, mtime-based),
|
||||||
|
works against Nextcloud/ownCloud/rclone-serve, and there are Dart HTTP clients.
|
||||||
|
- If the user already uses Nextcloud/Dropbox/OneDrive **and** the vault folder
|
||||||
|
lives inside that synced folder, BadNote needs **zero sync code** — the OS sync
|
||||||
|
client handles it. This is the cheapest path and worth recommending as option 0.
|
||||||
|
- **git** is possible (text JSON diffs nicely) but binary PDFs bloat history and
|
||||||
|
conflict UX is poor for a tablet — not recommended as the default.
|
||||||
|
|
||||||
|
### E.2 Conflict policy
|
||||||
|
|
||||||
|
- **Last-write-wins per file**, using a manifest of `{ relativePath, sha256, mtime }`
|
||||||
|
per notebook (store in the sidecar's `updatedAt` + a small per-vault
|
||||||
|
`.badnote/sync-manifest.json`, NOT synced).
|
||||||
|
- On pull, if remote and local both changed a file since last sync (both differ
|
||||||
|
from the last-synced hash): **keep local, write the remote copy as
|
||||||
|
`<file>.badnote.json.conflict-<timestamp>`** next to it, and surface a
|
||||||
|
non-blocking notice. No silent overwrite, no merge attempt. For a single user on
|
||||||
|
≤2 devices this is rare and acceptable.
|
||||||
|
- The **source PDF/docx is effectively immutable** after import (annotations live in
|
||||||
|
the sidecar), so the only file that realistically conflicts is the sidecar JSON —
|
||||||
|
which is small and human-readable, making `.conflict` copies easy to reconcile.
|
||||||
|
|
||||||
|
### E.3 "Annotations follow the file" with sidecars
|
||||||
|
|
||||||
|
Because the sidecar sits in the same folder as the source file and shares its
|
||||||
|
basename, any sync that moves the folder moves both together. There is **no
|
||||||
|
database to keep in lockstep** — that is the robustness the user asked for. The
|
||||||
|
SQLite index is rebuilt locally from the synced files, never synced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Robust auto-save
|
||||||
|
|
||||||
|
### F.1 Atomic sidecar write
|
||||||
|
|
||||||
|
Single helper, e.g. `SidecarStore.writeAtomic(File target, String json)`:
|
||||||
|
|
||||||
|
1. Write to `target.path + '.tmp'` with `flush: true`
|
||||||
|
(`File.writeAsString(..., flush: true)` — same pattern `PdfService` already uses
|
||||||
|
with `writeAsBytes(flush: true)`).
|
||||||
|
2. `await tmp.rename(target.path)` — rename is atomic on the same filesystem on
|
||||||
|
Windows/NTFS and POSIX, so a reader never sees a half-written sidecar.
|
||||||
|
3. Keep a one-deep backup: before rename, if `target` exists copy it to
|
||||||
|
`target.path + '.bak'` (cheap insurance against a corrupt write taking out the
|
||||||
|
last good copy). On load, if the main file fails to parse, fall back to `.bak`.
|
||||||
|
|
||||||
|
### F.2 Debounce + scheduler (reuse existing machinery)
|
||||||
|
|
||||||
|
- The editors already debounce: `SaveScheduler` (800 ms, `save_scheduler.dart`) for
|
||||||
|
the PDF editor, and `SplitViewScreen`'s own 3 s `Timer`. **Reuse this exact
|
||||||
|
shape**, but the scheduler's sink becomes the sidecar writer instead of
|
||||||
|
`EditorRepository.saveHost`.
|
||||||
|
- Concretely: introduce a `SidecarRepository` with the same method surface the
|
||||||
|
`SaveScheduler` expects, so `_schedulePageSave` /
|
||||||
|
`PenEditorScreen._initPersistence` change only their wiring, not their control
|
||||||
|
flow. The scheduler still captures a synchronous snapshot before the async gap
|
||||||
|
(it already does — `save_scheduler.dart` comment).
|
||||||
|
- The unit of debounce stays "the whole document sidecar" (write the full JSON;
|
||||||
|
sidecars are small — strokes are sparse normalized points). One write per
|
||||||
|
debounce window, atomic.
|
||||||
|
- **Snapshot discipline:** capture the in-memory `_strokesByPage` /
|
||||||
|
`_highlightsByPage` / `_scratchLinks` into a plain JSON map synchronously in
|
||||||
|
`schedule(...)`, exactly as `SaveScheduler.schedule` captures strokes today, so a
|
||||||
|
later edit can't corrupt an in-flight write.
|
||||||
|
|
||||||
|
### F.3 Flush on pause/close (robustness)
|
||||||
|
|
||||||
|
- `PenEditorScreen.dispose` already calls `scheduler.flush()` then `dispose()`
|
||||||
|
(`pen_editor_screen.dart`); `SplitViewScreen.dispose` already calls
|
||||||
|
`_saveImmediate()`. Keep both, pointing at the sidecar writer.
|
||||||
|
- **Add an app-lifecycle flush** (today missing): register a
|
||||||
|
`WidgetsBindingObserver` (in `BadNoteApp` or each editor) and on
|
||||||
|
`AppLifecycleState.inactive/paused/detached` call `flush()`. On a Windows tablet,
|
||||||
|
app suspend/close is the main data-loss window; this closes it.
|
||||||
|
- Optionally, also flush on a short idle timer so a hard power-off loses ≤1 debounce
|
||||||
|
window.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Phased migration plan
|
||||||
|
|
||||||
|
Each phase is independently shippable and testable, ordered to minimize risk to the
|
||||||
|
working editors. "Files changed" lists the primary touch points.
|
||||||
|
|
||||||
|
### Phase 0 — Vault root + setup gate (no data move yet)
|
||||||
|
- Add `VaultService` (SharedPreferences-backed) + `VaultSetupScreen`.
|
||||||
|
- `main.dart`: gate `HomeScreen` behind `vaultRootValid()`; `SettingsScreen`:
|
||||||
|
"Change vault".
|
||||||
|
- **No editor or DB change.** Editors still read/write SQLite. Vault path is merely
|
||||||
|
recorded.
|
||||||
|
- **Ship/test:** first-run prompt appears, path persists, re-prompts when folder
|
||||||
|
missing, Windows `getDirectoryPath` works (manual + a `VaultService` unit test).
|
||||||
|
- Files: `lib/services/vault_service.dart` (new), `lib/screens/vault_setup_screen.dart`
|
||||||
|
(new), `lib/main.dart`, `lib/screens/settings_screen.dart`.
|
||||||
|
|
||||||
|
### Phase 1 — Sidecar format + atomic store + read/write library (no UI swap)
|
||||||
|
- Define `BadnoteSidecar` model (toJson/fromJson) per §A, reusing
|
||||||
|
`EditorStroke`/`InkStroke`/`ScratchLink`/`Bookmark` JSON.
|
||||||
|
- `SidecarStore.writeAtomic` (§F.1) + `.bak` fallback loader.
|
||||||
|
- Pure unit tests: round-trip a sidecar with strokes/highlights/links/scratchpads;
|
||||||
|
atomic-write crash simulation; `.bak` recovery.
|
||||||
|
- **No runtime behavior change yet** (library only).
|
||||||
|
- Files: `lib/storage/badnote_sidecar.dart` (new), `lib/storage/sidecar_store.dart`
|
||||||
|
(new), tests.
|
||||||
|
|
||||||
|
### Phase 2 — PDF editor reads/writes sidecar (the core swap)
|
||||||
|
- Introduce `SidecarRepository` implementing the `SaveScheduler` sink; rewire
|
||||||
|
`PenEditorScreen._initPersistence`, `_loadPersistedStrokes`,
|
||||||
|
`_schedulePageSave`, `_loadScratchLinks`, `_placeScratchLink`,
|
||||||
|
`_confirmDeleteScratchLink` to the sidecar instead of `EditorRepository` /
|
||||||
|
`DatabaseService.*ScratchLink`. Persist highlights (closes `TODO(persist-highlights)`).
|
||||||
|
- `SplitViewScreen` reads/writes its scratchpad from the sidecar's
|
||||||
|
`scratchLinks[].scratchpad`.
|
||||||
|
- The document's identity becomes its **vault path**, not the djb2 path-hash;
|
||||||
|
`_documentIdFromPath` retired for this path.
|
||||||
|
- **Ship/test:** import a PDF (into vault), draw, place scratch links, reopen →
|
||||||
|
everything restored from sidecar; SQLite `ink`/`scratch_links`/`scratchpads` no
|
||||||
|
longer written for new docs. Add a widget/integration test.
|
||||||
|
- Files: `lib/editor/persistence/sidecar_repository.dart` (new),
|
||||||
|
`lib/editor/canvas/pen_editor_screen.dart`, `lib/screens/split_view_screen.dart`.
|
||||||
|
|
||||||
|
### Phase 3 — Vault-backed import + top-level "Import file"
|
||||||
|
- `VaultService.createNotebook(sourceFilePath)` → makes the folder, copies the file
|
||||||
|
in, returns the vault paths.
|
||||||
|
- Home screen: collapse `_importPdf`/`_importPptx` into one **"Import file"** action
|
||||||
|
(sibling of "Create notebook"); multi-extension picker; route by extension.
|
||||||
|
- Documents list now comes from a **vault scan** (folders with sidecars), not
|
||||||
|
`documents` table; `document_provider` reads the vault (cache table optional).
|
||||||
|
- Windows `soffice.exe` detection fix in `PptxService`; PPTX assets cached into
|
||||||
|
`.badnote-assets/`; DOCX→PDF convert-on-import (best-effort, with the LibreOffice
|
||||||
|
caveat surfaced to the user).
|
||||||
|
- **Ship/test:** one Import button accepts pdf/docx/pptx; imported files land in
|
||||||
|
vault folders with sidecars; reopening reads from the vault.
|
||||||
|
- Files: `lib/screens/home_screen.dart`, `lib/providers/document_provider.dart`,
|
||||||
|
`lib/services/vault_service.dart`, `lib/services/pptx_service.dart`,
|
||||||
|
`lib/services/pdf_service.dart` (open vault copy).
|
||||||
|
|
||||||
|
### Phase 4 — Standalone notebooks + free notes on sidecars
|
||||||
|
- "Create notebook" writes a `notebook.badnote.json` (§A.4) instead of `notes`/`strokes`.
|
||||||
|
- `note_provider` reads/writes the vault; `home_screen` note tiles come from the scan.
|
||||||
|
- Files: `lib/providers/note_provider.dart`, `lib/editor/canvas/pen_note_screen.dart`,
|
||||||
|
`lib/screens/home_screen.dart`.
|
||||||
|
|
||||||
|
### Phase 5 — One-time SQLite→sidecar migration (§B)
|
||||||
|
- Migrator runs on first launch with a valid vault and `vaultMigrationDone == false`.
|
||||||
|
- Demote SQLite to `.badnote/index.sqlite` cache; preserve legacy DB as
|
||||||
|
`.premigration`.
|
||||||
|
- **Ship/test:** install over an old DB → all docs/strokes/scratchpads/bookmarks/notes
|
||||||
|
appear in the vault; idempotent re-run; legacy DB preserved. Golden-file tests
|
||||||
|
with a seeded legacy DB.
|
||||||
|
- Files: `lib/storage/sqlite_to_sidecar_migrator.dart` (new),
|
||||||
|
`lib/services/database_service.dart` (relocate DB path, expose raw read helpers),
|
||||||
|
`lib/main.dart` (invoke migrator).
|
||||||
|
|
||||||
|
### Phase 6 — Lifecycle-flush hardening + index rebuild
|
||||||
|
- `WidgetsBindingObserver` app-pause flush (§F.3) across editors.
|
||||||
|
- `.badnote/index.sqlite` (FTS + recent list + OCR) rebuilt by scanning sidecars;
|
||||||
|
`search_provider` queries the cache.
|
||||||
|
- Files: `lib/main.dart` (or a shared observer), the editors, `lib/providers/search_provider.dart`.
|
||||||
|
|
||||||
|
### Phase 7 — Server sync (optional, last)
|
||||||
|
- `SyncService`: WebDAV (or "vault lives in an OS-synced folder → no-op") +
|
||||||
|
per-notebook manifest + last-write-wins `.conflict` policy (§E).
|
||||||
|
- Settings UI to configure endpoint/credentials; manual "Sync now" + periodic.
|
||||||
|
- Files: `lib/services/sync_service.dart` (new), `lib/screens/settings_screen.dart`.
|
||||||
|
|
||||||
|
### Risk-minimization rationale
|
||||||
|
- Phases 0–1 add code without changing runtime behavior (lowest risk first).
|
||||||
|
- Phase 2 is the one delicate swap; it is isolated to the persistence wiring of two
|
||||||
|
screens and is backed by Phase-1 round-trip tests — the on-screen stroke models
|
||||||
|
and painters are untouched.
|
||||||
|
- The destructive step (Phase 5 migration) ships **after** the new format is proven
|
||||||
|
by Phases 2–4, and never deletes the legacy DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: exact files inspected
|
||||||
|
|
||||||
|
`lib/services/database_service.dart` (schema v8; `documents`, `ink`, `scratch_links`,
|
||||||
|
`scratchpads`, `bookmarks`, `annotations`, `notes`, `strokes`, `board_cards`, FTS),
|
||||||
|
`lib/editor/persistence/editor_repository.dart` (`saveHost`, `loadDocument`,
|
||||||
|
`pageHostId`, host_id scheme), `lib/editor/persistence/save_scheduler.dart`
|
||||||
|
(800 ms debounce, synchronous snapshot, `flush`), `lib/editor/engine/stroke_model.dart`
|
||||||
|
(`EditorStroke.toJson`, brush not serialized), `lib/models/ink_stroke.dart`,
|
||||||
|
`lib/models/ink_point.dart`, `lib/editor/canvas/pen_stroke.dart`,
|
||||||
|
`lib/models/scratch_link.dart`, `lib/models/bookmark.dart`, `lib/models/document.dart`,
|
||||||
|
`lib/editor/canvas/pen_editor_screen.dart` (`_documentIdFromPath`,
|
||||||
|
`_initPersistence`, `_schedulePageSave`, `_highlightsByPage` +
|
||||||
|
`TODO(persist-highlights)`, scratch-link flow), `lib/screens/split_view_screen.dart`
|
||||||
|
(scratchpad keyed by anchor id, absolute world pixels, 3 s autosave),
|
||||||
|
`lib/screens/home_screen.dart` (`_importPdf`/`_importPptx`, empty-state buttons),
|
||||||
|
`lib/services/pdf_service.dart` (`pickPdfFile`, `exportAnnotatedPdf`, `writeAsBytes(flush:true)`),
|
||||||
|
`lib/services/pptx_service.dart` (LibreOffice headless, `which libreoffice`,
|
||||||
|
temp-dir output), `lib/providers/document_provider.dart`,
|
||||||
|
`lib/providers/settings_provider.dart` (SharedPreferences pattern), `lib/main.dart`
|
||||||
|
(`DatabaseService.getInstance`, `SharedPreferences.getInstance`, `HomeScreen` home).
|
||||||
45
docs/plans/surface-diagnostic-checklist.md
Normal file
45
docs/plans/surface-diagnostic-checklist.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Surface 验收清单(诊断包驱动)
|
||||||
|
|
||||||
|
AI 无法坐在 Surface 前时,用本清单 + **设置 → 诊断 → 导出诊断包** 闭环。
|
||||||
|
|
||||||
|
## 准备
|
||||||
|
|
||||||
|
1. `flutter build windows --release` 或 profile 安装包
|
||||||
|
2. 打开 BadNote → 设置 → 确认「导出诊断包」可用
|
||||||
|
3. 准备:Surface Pen、一份 20+ 页 PDF、空白笔记
|
||||||
|
|
||||||
|
## MUST #3 — 笔 / 触控仲裁(约 1 分钟)
|
||||||
|
|
||||||
|
| 步骤 | 期望 |
|
||||||
|
|------|------|
|
||||||
|
| Pen 在 PDF 上书写 | 出墨,压感可见 |
|
||||||
|
| 单指上下滚 | 滚动页面,不画线 |
|
||||||
|
| 双指捏缩放 | 缩放,不画线 |
|
||||||
|
| 手掌搁在屏幕上同时用笔写 | 掌不画线(palm) |
|
||||||
|
|
||||||
|
导出诊断包。包内 `pen_events.json` 应出现 `arbiter` 行:`decision=draw`(笔)与 `decision=pan`(指)。
|
||||||
|
|
||||||
|
## W3 — 硬件笔按钮
|
||||||
|
|
||||||
|
| 步骤 | 期望 |
|
||||||
|
|------|------|
|
||||||
|
| 无悬停,直接用笔尾点按 | 擦除而非画线 |
|
||||||
|
| 按侧键(按你的笔设置) | 触发橡皮擦/平移/撤销 |
|
||||||
|
| 倾斜笔身书写 | `pen_events` / native summary 中 tilt 非全 0 |
|
||||||
|
|
||||||
|
查看 `meta.json` → `penNative`:`orPenFlags` / `orPtrFlags` 在按键时应有非零位;`historyCount` 可 >1。
|
||||||
|
|
||||||
|
## MUST #4 / #5 — 流畅(profile)
|
||||||
|
|
||||||
|
1. `flutter run --profile`
|
||||||
|
2. 打开大 PDF,快速 fling + 捏缩放 30 秒
|
||||||
|
3. 导出诊断包 → `frame_samples.json` 中 `overBudget` 占比主观可接受;体感不掉帧
|
||||||
|
|
||||||
|
## 手感主观
|
||||||
|
|
||||||
|
- 快速甩笔:笔尖无明显拖尾/点状塌缩
|
||||||
|
- 缩放后页面不长时间白闪(若仍闪,在包内搜 `zoom` / `rebaseline`)
|
||||||
|
|
||||||
|
## 回传
|
||||||
|
|
||||||
|
把 `badnote_diag_*.zip` 发回即可;无需录屏(可选)。
|
||||||
323
docs/research/pen-brush-spec.md
Normal file
323
docs/research/pen-brush-spec.md
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
# Pen/Ink Engine Spec — rnote + Krita algorithms, ported to Flutter/Dart
|
||||||
|
|
||||||
|
Concrete, implementable spec for a drawing-grade, Krita-compatible, extensible brush model
|
||||||
|
using the `perfect_freehand` Dart package plus a custom variable-width polygon path where
|
||||||
|
needed. All formulas are verbatim from primary source. Sources cited at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. rnote pressure → width (QUADRATIC + all PressureCurve options)
|
||||||
|
|
||||||
|
**Source:** `crates/rnote-compose/src/style/mod.rs` — `PressureCurve` enum and its `apply()` method.
|
||||||
|
Repo: https://github.com/flxzt/rnote
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PressureCurve { Const = 0, Linear, Sqrt, Cbrt, Pow2, Pow3 } // default = Linear
|
||||||
|
|
||||||
|
pub fn apply(&self, width: f64, pressure: f64) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Const => width, // w
|
||||||
|
Self::Linear => width * pressure, // w·p
|
||||||
|
Self::Sqrt => width * pressure.sqrt(), // w·p^0.5
|
||||||
|
Self::Cbrt => width * pressure.cbrt(), // w·p^(1/3)
|
||||||
|
Self::Pow2 => width * pressure.powi(2), // w·p^2 <-- QUADRATIC
|
||||||
|
Self::Pow3 => width * pressure.powi(3), // w·p^3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pressure ∈ [0,1]`; `width` = configured max stroke **width** (full width, not radius).
|
||||||
|
- It is a **pure power law** `width = baseWidth · p^n`, with `n ∈ {0, 1, 0.5, 1/3, 2, 3}`.
|
||||||
|
There are no other coefficients.
|
||||||
|
|
||||||
|
### The QUADRATIC the user wants = `Pow2`
|
||||||
|
|
||||||
|
```
|
||||||
|
width(p) = baseWidth · p² (p ∈ [0,1])
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variant | Exponent | Formula | Feel |
|
||||||
|
|---------|----------|--------------------|----------------------------------------|
|
||||||
|
| Const | — | `w` | constant width (no pressure) |
|
||||||
|
| Linear | 1 | `w · p` | proportional (PF native model) |
|
||||||
|
| Sqrt | 0.5 | `w · √p` | thickens fast then plateaus (firm pen) |
|
||||||
|
| Cbrt | 1/3 | `w · p^(1/3)` | thickens very fast then plateaus |
|
||||||
|
| **Pow2**| **2** | **`w · p²`** | **thin at low p, ramps steeply (fountain/brush)** |
|
||||||
|
| Pow3 | 3 | `w · p³` | very thin until high p (expressive) |
|
||||||
|
|
||||||
|
Dart:
|
||||||
|
```dart
|
||||||
|
double rnoteWidth(double baseWidth, double p, PressureCurve c) => switch (c) {
|
||||||
|
PressureCurve.constc => baseWidth,
|
||||||
|
PressureCurve.linear => baseWidth * p,
|
||||||
|
PressureCurve.sqrt => baseWidth * math.sqrt(p),
|
||||||
|
PressureCurve.cbrt => baseWidth * math.pow(p, 1/3),
|
||||||
|
PressureCurve.pow2 => baseWidth * p * p, // QUADRATIC
|
||||||
|
PressureCurve.pow3 => baseWidth * p * p * p,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended floored variant** (real pens never reach zero width):
|
||||||
|
```
|
||||||
|
width(p) = baseWidth · (wMin + (1 - wMin) · p²), wMin ≈ 0.15 .. 0.35
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. rnote stroke building / smoothing (step by step)
|
||||||
|
|
||||||
|
Output unit — `Segment` (`crates/rnote-compose/src/penpath/segment.rs`):
|
||||||
|
```rust
|
||||||
|
enum Segment {
|
||||||
|
LineTo { end: Element }, // Element = { pos: Vec2, pressure: f64 }
|
||||||
|
QuadBezTo { cp: Vec2, end: Element },
|
||||||
|
CubBezTo { cp1: Vec2, cp2: Vec2, end: Element },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
rnote has two pen builders. **Pick one to port.**
|
||||||
|
|
||||||
|
### 2a. Curved builder — uniform Catmull-Rom → cubic Bézier (RECOMMENDED FIRST; simple, deterministic)
|
||||||
|
|
||||||
|
**Source:** `crates/rnote-compose/src/builders/penpathcurvedbuilder.rs`
|
||||||
|
and `crates/rnote-compose/src/shapes/cubbez.rs::new_w_catmull_rom`.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Buffer raw input `Element`s into a `Vec<Element>`.
|
||||||
|
2. **Start state:** emit plain `LineTo` segments until ≥ 4 points are buffered.
|
||||||
|
3. While ≥ 4 buffered points remain, take a **sliding window of 4 consecutive points**
|
||||||
|
`(p0, p1, p2, p3)` and emit ONE `CubBezTo` that draws the **middle span p1 → p2**.
|
||||||
|
Advance `i += 1` (windows overlap by 3 points → C1 continuity).
|
||||||
|
4. **Control points** (Catmull-Rom → cubic-Bézier conversion, **tension = 1.0 fixed, divisor = 6.0**):
|
||||||
|
```
|
||||||
|
cp1 = p1 + (p2 - p0) / (6.0 * tension)
|
||||||
|
cp2 = p2 - (p3 - p1) / (6.0 * tension)
|
||||||
|
// cubic Bézier: start = p1, cp1, cp2, end = p2
|
||||||
|
```
|
||||||
|
5. If the construction degenerates (coincident points), fall back to `LineTo`.
|
||||||
|
|
||||||
|
This is a **uniform (non-centripetal) Catmull-Rom spline expressed as a chain of cubic Béziers.**
|
||||||
|
The `1/6` factor is the standard Catmull-Rom→Bézier identity `cp = Pk ± (Pk+1 − Pk−1)/6`.
|
||||||
|
**There is NO separate streamline / position-averaging step in this builder** — all smoothing
|
||||||
|
comes from the spline. Per-point width still comes from each Element's pressure via §1.
|
||||||
|
|
||||||
|
Dart (per emitted cubic, tension = 1.0):
|
||||||
|
```dart
|
||||||
|
final cp1 = p1 + (p2 - p0) / 6.0;
|
||||||
|
final cp2 = p2 - (p3 - p1) / 6.0;
|
||||||
|
path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2b. Modeled builder — Google ink-stroke-modeler spring-mass-damper ("physics" path)
|
||||||
|
|
||||||
|
**Source:** `crates/rnote-compose/src/builders/penpathmodeledbuilder.rs`, which wraps the
|
||||||
|
`ink-stroke-modeler-rs` crate (Rust binding of Google C++ `ink-stroke-modeler`).
|
||||||
|
The rendered tip is a **mass on a spring** anchored to the raw input, with drag — giving
|
||||||
|
smoothing plus the slight realistic "catch-up" lag of good ink.
|
||||||
|
|
||||||
|
Pipeline per input event (`Down`/`Move`/`Up`, each with pos + pressure + time):
|
||||||
|
1. **Wobble smoothing** — speed-gated moving average that kills high-frequency jitter (only when slow).
|
||||||
|
2. **Resampling** — upsample to a fixed output rate so curvature is even regardless of input rate.
|
||||||
|
3. **Position modeling** — spring-mass-damper integrates the tip toward each resampled anchor.
|
||||||
|
4. **Stylus-state modeling** — interpolate pressure/tilt onto resampled points (last N input samples).
|
||||||
|
5. **Prediction** — `predict()` extends the tip ahead of the latest real input to hide latency; cleared on `Up`.
|
||||||
|
6. Emit dense `Segment::LineTo` points (and prediction points while drawing).
|
||||||
|
|
||||||
|
rnote's `MODELER_PARAMS` (overrides on `ModelerParams::suggested()`):
|
||||||
|
- `sampling_min_output_rate = 120.0` Hz
|
||||||
|
- `sampling_max_outputs_per_call = 200`
|
||||||
|
- `sampling_end_of_stroke_stopping_distance = 0.01`
|
||||||
|
- `stylus_state_modeler_max_input_samples = 20`
|
||||||
|
|
||||||
|
Google `suggested()` defaults (`ink_stroke_modeler/params.cc`) — the actual spring constants:
|
||||||
|
- wobble_smoother: `timeout = 0.04 s`, `speed_floor = 1.31`, `speed_ceiling = 1.44`
|
||||||
|
- position_modeler: **`spring_mass_constant = 11/32400 ≈ 0.00033951`**, **`drag_constant = 72.0`**
|
||||||
|
- sampling: `min_output_rate = 180`, `end_of_stroke_stopping_distance = 0.001`, `end_of_stroke_max_iterations = 20`
|
||||||
|
- stylus_state_modeler: `max_input_samples = 20`
|
||||||
|
|
||||||
|
Spring update (Euler, fixed dt = 1/output_rate):
|
||||||
|
```
|
||||||
|
F = (x_anchor - x_tip)/spring_mass_constant - drag_constant * v_tip
|
||||||
|
v_tip += F * dt
|
||||||
|
x_tip += v_tip * dt
|
||||||
|
```
|
||||||
|
Higher `drag_constant` = more damping/lag; smaller `spring_mass_constant` = stiffer/snappier.
|
||||||
|
|
||||||
|
**Porting call:** ship 2a now (trivial, looks great for notes). Add 2b later as a "smooth mode"
|
||||||
|
tip filter for premium feel + latency hiding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Krita: ballpoint vs fountain pen parameter sets
|
||||||
|
|
||||||
|
**Source:** Krita Manual 5.3 —
|
||||||
|
- Sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
|
||||||
|
- Opacity vs Flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
|
||||||
|
- Inking: https://docs.krita.org/en/tutorials/inking.html
|
||||||
|
|
||||||
|
**Model:** The Pixel brush stamps **dabs** along the stroke; each property (Size, Opacity, Flow,
|
||||||
|
Rotation, …) is driven by a **sensor** through an editable **response curve** (x = sensor 0..1 →
|
||||||
|
y = output multiplier 0..1).
|
||||||
|
|
||||||
|
**Sensors & ranges:** Pressure 0..1 (PressureIn = ratchet, ignores decreasing pressure);
|
||||||
|
Speed 0..1; Tilt-elevation 0°(flat)..90°(vertical); Tilt-direction −180°..+180° (azimuth);
|
||||||
|
Rotation; Fade (over brush-size lengths); Distance (px); Time (s).
|
||||||
|
|
||||||
|
**Opacity vs Flow** (multiply together since 4.2): Opacity = whole-stroke transparency
|
||||||
|
(clamped per stroke in *Wash* mode); Flow = per-dab transparency (in *Build-up* mode overlapping
|
||||||
|
dabs accumulate). Ink wants Flow=1 / Opacity=1 (solid); marker wants Flow≈0.5 build-up.
|
||||||
|
|
||||||
|
| Property | **Fountain pen** | **Ballpoint** |
|
||||||
|
|-----------------------|---------------------------------------------------|--------------------------------------------|
|
||||||
|
| Size sensor | Pressure (+ optional Tilt-elevation) | Pressure |
|
||||||
|
| Size curve | concave / ease-in, **γ ≈ 2 (≈ p²)** | nearly flat (constant) |
|
||||||
|
| Size output range | **0.15 → 1.0** of nominal | **0.90 → 1.0** (barely varies) |
|
||||||
|
| Opacity sensor | Pressure | Pressure |
|
||||||
|
| Opacity curve | slight concave γ ≈ 1.5 (or constant) | **linear γ ≈ 1**, range **0.6 → 1.0** |
|
||||||
|
| Flow | 1.0 | 1.0 |
|
||||||
|
| Tilt usage | Tilt-elevation → broaden Size; Tilt-direction → tip Rotation (calligraphic) | none |
|
||||||
|
| Net character | **strong pressure→width**, near-opaque, calligraphic edge | **near-constant width**, pressure→**opacity** (the ballpoint "tell") |
|
||||||
|
|
||||||
|
Optional ballpoint nicety: Speed→Opacity (faster = slightly lighter, mimics ink skipping).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. perfect_freehand option sets + where it is insufficient
|
||||||
|
|
||||||
|
### What perfect_freehand actually computes (so the knobs are unambiguous)
|
||||||
|
**Source:** `getStrokeRadius.ts` — https://github.com/steveruizok/perfect-freehand
|
||||||
|
|
||||||
|
Per-point radius:
|
||||||
|
```
|
||||||
|
radius = size * easing( 0.5 - thinning * (0.5 - pressure) )
|
||||||
|
```
|
||||||
|
Default easing = identity (linear). Therefore:
|
||||||
|
- `p = 0 → radius = size * (0.5 - 0.5·thinning)`
|
||||||
|
- `p = 1 → radius = size * (0.5 + 0.5·thinning)`
|
||||||
|
- `p = 0.5 → radius = size * 0.5` (always)
|
||||||
|
|
||||||
|
⇒ PF's pressure→width is **strictly LINEAR** (rnote `Linear`), symmetric about `0.5·size`,
|
||||||
|
slope set by `thinning ∈ [-1,1]`. `size` = **diameter**. `streamline ∈ [0,1]` = EMA low-pass on
|
||||||
|
input positions. `smoothing ∈ [0,1]` = corner-softening on the **outline** polygon (not the
|
||||||
|
centerline). `simulatePressure:true` fakes pressure from velocity (slower = thicker).
|
||||||
|
|
||||||
|
**perfect_freehand Dart defaults:** `size=16, thinning=0.5, smoothing=0.5, streamline=0.5,
|
||||||
|
simulatePressure=true, isComplete=true, start.cap=true, end.cap=true, taperEnabled=false`.
|
||||||
|
Source: https://pub.dev/packages/perfect_freehand
|
||||||
|
|
||||||
|
### Getting rnote's quadratic out of PF: pre-warp the per-point pressure
|
||||||
|
PF is linear internally, but feed it warped pressure and the *width* curve becomes whatever you
|
||||||
|
want — **no custom polygon needed** for width-only brushes:
|
||||||
|
```dart
|
||||||
|
double warpPressure(double p, PressureMode m) => switch (m) {
|
||||||
|
PressureMode.linear => p,
|
||||||
|
PressureMode.quadratic => p * p, // rnote Pow2 — fountain pen
|
||||||
|
PressureMode.cubic => p * p * p, // rnote Pow3
|
||||||
|
PressureMode.sqrt => math.sqrt(p), // firm pen / pencil
|
||||||
|
};
|
||||||
|
// points.add(PointVector(x, y, warpPressure(rawPressure, mode)));
|
||||||
|
// StrokeOptions(thinning: ~0.9, simulatePressure: false);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where perfect_freehand is INSUFFICIENT → custom variable-width polygon (rnote-style)
|
||||||
|
| Need | PF enough? |
|
||||||
|
|---------------------------------------------------|-----------------------------------------------------|
|
||||||
|
| Linear pressure → width | ✅ via `thinning` |
|
||||||
|
| Quadratic / Sqrt pressure → width | ⚠️ pressure pre-warp (above), `thinning≈0.9`, `simulatePressure:false` |
|
||||||
|
| **Tilt → width or tip rotation** (calligraphy) | ❌ **custom polygon**: per point `w=f(pressure,tilt)`, normal `n=perp(tangent)`, emit `P ± n·w/2`, triangulate (rnote-style left/right offsetting) |
|
||||||
|
| Pressure → **opacity** (ballpoint/pencil/marker) | ❌ PF is geometry-only — render with per-segment / per-stroke alpha yourself |
|
||||||
|
| True spring-mass smoothing + latency prediction | ❌ PF `streamline` is only an EMA — port ink-stroke-modeler (§2b) or use §2a first |
|
||||||
|
| Per-point opacity along one stroke | ❌ split into short sub-strokes by pressure band, paint each with its own alpha |
|
||||||
|
|
||||||
|
### Concrete per-brush option sets (logical-px diameters; scale by zoom)
|
||||||
|
|
||||||
|
**Fountain pen** — strong pressure→width, soft taper, solid ink:
|
||||||
|
```dart
|
||||||
|
// per-point pressure pre-warped to p² (quadratic)
|
||||||
|
StrokeOptions(
|
||||||
|
size: 6.0, // tune 4–8
|
||||||
|
thinning: 0.9, // wide dynamic range
|
||||||
|
smoothing: 0.55,
|
||||||
|
streamline: 0.45, // smooth but responsive
|
||||||
|
simulatePressure: false,
|
||||||
|
start: StrokeEndOptions.start(taperEnabled: true, cap: true),
|
||||||
|
end: StrokeEndOptions.end(taperEnabled: true, cap: true),
|
||||||
|
);
|
||||||
|
// opacity = 1.0 (solid). Add Tilt → custom polygon only if you want calligraphic edge.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ballpoint** — near-constant width, pressure → opacity:
|
||||||
|
```dart
|
||||||
|
// raw per-point pressure (NOT warped); used for OPACITY, not width
|
||||||
|
StrokeOptions(
|
||||||
|
size: 2.2, // thin, fixed
|
||||||
|
thinning: 0.15, // almost no width variation
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.55, // ballpoints glide
|
||||||
|
simulatePressure: false,
|
||||||
|
);
|
||||||
|
// opacity = 0.55 + 0.45 * pressureAvg (per-stroke; or per-segment sub-strokes by pressure band)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Highlighter** — flat width, translucent, build-up, blunt caps:
|
||||||
|
```dart
|
||||||
|
StrokeOptions(
|
||||||
|
size: 22.0, // broad
|
||||||
|
thinning: 0.0, // constant width
|
||||||
|
smoothing: 0.4,
|
||||||
|
streamline: 0.5,
|
||||||
|
simulatePressure: false,
|
||||||
|
start: StrokeEndOptions.start(cap: false), // square ends
|
||||||
|
end: StrokeEndOptions.end(cap: false),
|
||||||
|
);
|
||||||
|
// Paint: BlendMode.multiply (or .darken), color.withOpacity(0.35).
|
||||||
|
// Draw the WHOLE stroke once on pointer-up so self-overlap doesn't darken (Krita "Wash");
|
||||||
|
// cross-stroke overlap darkens via multiply = real marker.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pencil** — slight width + opacity from pressure, grainy:
|
||||||
|
```dart
|
||||||
|
// per-point pressure pre-warped to sqrt(p) (firm, quick-darkening)
|
||||||
|
StrokeOptions(
|
||||||
|
size: 3.0,
|
||||||
|
thinning: 0.5, // moderate width range
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.4, // scratchy -> less smoothing
|
||||||
|
simulatePressure: false, // if no real stylus pressure, set true for velocity-thinning
|
||||||
|
);
|
||||||
|
// opacity = 0.35 + 0.55 * pressure
|
||||||
|
// overlay a paper-noise texture via BlendMode.multiply for graphite grain (PF can't do texture)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Krita-compatible, extensible brush model (recommended struct)
|
||||||
|
Mirror Krita's sensor→curve design, then translate to PF + your own opacity/compositing layer:
|
||||||
|
```
|
||||||
|
BrushProfile {
|
||||||
|
sizeBase, sizeSensor (pressure/tilt/speed), sizeCurve (power-law exponent or LUT), sizeRange (min,max),
|
||||||
|
opacitySensor, opacityCurve, opacityRange, flow,
|
||||||
|
tiltToWidth, tiltToRotation, // any of these -> custom variable-width polygon path
|
||||||
|
smoothingMode (catmullRom §2a | spring §2b),
|
||||||
|
pfThinning, pfStreamline, pfSmoothing, cap, taper, pressureWarp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Width = `sizeBase · curve(sizeSensor)`; `curve` = power law for exact Krita/rnote parity (`p^n`).
|
||||||
|
- Width-only brushes (fountain/ballpoint/highlighter/pencil) go through PF via pressure pre-warp.
|
||||||
|
- Any brush with `tiltToWidth`/`tiltToRotation` switches to the custom variable-width polygon renderer.
|
||||||
|
- Opacity/flow are ALWAYS handled by your compositing layer, never by PF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
- rnote `PressureCurve` + `apply`: `crates/rnote-compose/src/style/mod.rs` — https://github.com/flxzt/rnote
|
||||||
|
- rnote `Segment`: `crates/rnote-compose/src/penpath/segment.rs`
|
||||||
|
- rnote Catmull-Rom curved builder: `builders/penpathcurvedbuilder.rs`,
|
||||||
|
`shapes/cubbez.rs::new_w_catmull_rom` (`cp = P ± Δ/(6·tension)`, tension = 1.0)
|
||||||
|
- rnote modeled builder: `builders/penpathmodeledbuilder.rs` (wraps ink-stroke-modeler-rs)
|
||||||
|
- Google ink-stroke-modeler params (spring_mass = 11/32400, drag = 72.0, …):
|
||||||
|
https://github.com/google/ink-stroke-modeler/blob/main/ink_stroke_modeler/params.h and `params.cc`
|
||||||
|
- perfect-freehand radius `size·easing(0.5 − thinning·(0.5 − pressure))`: `getStrokeRadius.ts` —
|
||||||
|
https://github.com/steveruizok/perfect-freehand
|
||||||
|
- perfect_freehand Dart defaults: https://pub.dev/packages/perfect_freehand
|
||||||
|
- Krita sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
|
||||||
|
- Krita opacity vs flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
|
||||||
|
- Krita inking: https://docs.krita.org/en/tutorials/inking.html
|
||||||
@@ -1,166 +1,13 @@
|
|||||||
// integration_test/coordinate_assertion_test.dart
|
// Coordinate gate previously used spike_editor_pane (retired).
|
||||||
//
|
// Re-run on Surface via docs/plans/surface-diagnostic-checklist.md
|
||||||
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
|
// once a PenEditorScreen-based harness is restored.
|
||||||
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
|
|
||||||
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
|
|
||||||
// this is a blocking gate.
|
|
||||||
//
|
|
||||||
// RUN (on a device/desktop with a display + working pdfium):
|
|
||||||
// flutter test integration_test/coordinate_assertion_test.dart
|
|
||||||
// or, on the Windows tablet via a driver:
|
|
||||||
// flutter drive --driver=test_driver/integration_test.dart \
|
|
||||||
// --target=integration_test/coordinate_assertion_test.dart
|
|
||||||
//
|
|
||||||
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
|
|
||||||
// resolve. If pdfium cannot render under the harness on a headless Linux box
|
|
||||||
// (no GL/surface), this test will time out at `_waitForReady`; that is an
|
|
||||||
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
|
|
||||||
// assertion logic below is correct and must not be weakened to force a pass.
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
|
|
||||||
|
|
||||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Standard integration binding. This test drives zoom + reads geometry only;
|
|
||||||
// it does not inject pen events, so PenCaptureRegion stays transparent
|
|
||||||
// (currentPointerKind == null → never captures), which is exactly correct
|
|
||||||
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
|
|
||||||
// which the runner initializes first.)
|
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
pdfrxFlutterInitialize();
|
|
||||||
|
|
||||||
late File pdfFile;
|
testWidgets('coordinate_assertion retired with spike pane', (tester) async {
|
||||||
|
}, skip: true);
|
||||||
setUpAll(() async {
|
|
||||||
pdfFile = await _writeTinyPdf();
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDownAll(() async {
|
|
||||||
if (await pdfFile.exists()) await pdfFile.delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
|
|
||||||
(tester) async {
|
|
||||||
final controller = PdfViewerController();
|
|
||||||
PdfDocument? readyDoc;
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Scaffold(
|
|
||||||
body: SpikeEditorPane(
|
|
||||||
pdfPath: pdfFile.path,
|
|
||||||
controller: controller,
|
|
||||||
onViewerReady: (doc, _) => readyDoc = doc,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for pdfrx to load + lay out the page.
|
|
||||||
final ready = await _waitForReady(tester, controller);
|
|
||||||
if (!ready) {
|
|
||||||
fail(
|
|
||||||
'pdfrx did not become ready (page layout unavailable). This is almost '
|
|
||||||
'certainly the headless-Linux pdfium limitation described in the file '
|
|
||||||
'header — run on the Windows tablet:\n'
|
|
||||||
' flutter drive --driver=test_driver/integration_test.dart '
|
|
||||||
'--target=integration_test/coordinate_assertion_test.dart',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
expect(readyDoc, isNotNull);
|
|
||||||
|
|
||||||
// The page-center in DOCUMENT space is the layout rect center of page 1.
|
|
||||||
final pageRect = controller.layout.pageLayouts.first;
|
|
||||||
final pageCenterDoc = pageRect.center;
|
|
||||||
|
|
||||||
Future<void> assertCenterAtCurrentZoom(String label) async {
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
// Project the page-center document point to viewer-local (== screen,
|
|
||||||
// since the viewer fills the Scaffold body) coordinates.
|
|
||||||
final localCenter = controller.documentToLocal(pageCenterDoc);
|
|
||||||
|
|
||||||
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
|
|
||||||
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
|
|
||||||
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
|
|
||||||
// the page center within tolerance, proving the coordinate model maps
|
|
||||||
// normalized→document→screen consistently at this zoom.
|
|
||||||
final box = tester.renderObject<RenderBox>(
|
|
||||||
find.byType(SpikeEditorPane),
|
|
||||||
);
|
|
||||||
final globalCenter = box.localToGlobal(localCenter);
|
|
||||||
final roundTripDoc = controller.globalToDocument(globalCenter);
|
|
||||||
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
|
|
||||||
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
|
|
||||||
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
|
|
||||||
// Tolerance: 1 document unit (sub-pixel at these zooms).
|
|
||||||
expect(dx, lessThan(1.0),
|
|
||||||
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
|
|
||||||
expect(dy, lessThan(1.0),
|
|
||||||
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- fit ---
|
|
||||||
await controller.goTo(
|
|
||||||
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
|
|
||||||
duration: Duration.zero,
|
|
||||||
);
|
|
||||||
await assertCenterAtCurrentZoom('fit');
|
|
||||||
final fitZoom = controller.currentZoom;
|
|
||||||
|
|
||||||
// --- 2x (relative to fit) ---
|
|
||||||
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
|
|
||||||
await assertCenterAtCurrentZoom('2x');
|
|
||||||
|
|
||||||
// --- 4x (relative to fit) ---
|
|
||||||
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
|
|
||||||
await assertCenterAtCurrentZoom('4x');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
|
|
||||||
/// or the timeout elapses. Returns whether it became ready.
|
|
||||||
Future<bool> _waitForReady(
|
|
||||||
WidgetTester tester,
|
|
||||||
PdfViewerController controller, {
|
|
||||||
Duration timeout = const Duration(seconds: 20),
|
|
||||||
}) async {
|
|
||||||
final deadline = DateTime.now().add(timeout);
|
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
|
|
||||||
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
|
|
||||||
Future<File> _writeTinyPdf() async {
|
|
||||||
final doc = sf.PdfDocument();
|
|
||||||
final page = doc.pages.add();
|
|
||||||
final size = page.getClientSize();
|
|
||||||
page.graphics.drawRectangle(
|
|
||||||
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
|
|
||||||
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
|
|
||||||
);
|
|
||||||
page.graphics.drawString(
|
|
||||||
'M1 coord test',
|
|
||||||
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
|
|
||||||
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
|
|
||||||
);
|
|
||||||
final bytes = await doc.save();
|
|
||||||
doc.dispose();
|
|
||||||
final file = File(
|
|
||||||
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
|
|
||||||
);
|
|
||||||
await file.writeAsBytes(bytes, flush: true);
|
|
||||||
return file;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,245 +1,16 @@
|
|||||||
// integration_test/perf_scroll_bench.dart
|
// Spike-based bench retired with own-canvas architecture.
|
||||||
//
|
// See docs/plans/surface-diagnostic-checklist.md for device gates.
|
||||||
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
|
// Replacement bench will target PenEditorScreen + PageTileCache.
|
||||||
//
|
|
||||||
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
|
|
||||||
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
|
|
||||||
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
|
|
||||||
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
|
|
||||||
// time ≤ 16.6ms.
|
|
||||||
//
|
|
||||||
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
|
|
||||||
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
|
|
||||||
// are populated before sampling.
|
|
||||||
//
|
|
||||||
// RUN (profile mode, on the Windows tablet or a desktop with a display):
|
|
||||||
// flutter test --profile integration_test/perf_scroll_bench.dart
|
|
||||||
// or via the driver for on-device profiling:
|
|
||||||
// flutter drive --profile \
|
|
||||||
// --driver=test_driver/integration_test.dart \
|
|
||||||
// --target=integration_test/perf_scroll_bench.dart
|
|
||||||
//
|
|
||||||
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
|
|
||||||
// always read the numbers from a PROFILE run on the target device. On a
|
|
||||||
// headless Linux box pdfium may fail to render; if so the bench prints a clear
|
|
||||||
// skip and must be run on the tablet (see coordinate_assertion_test header).
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/scheduler.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
|
|
||||||
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
|
|
||||||
|
|
||||||
const String _kPdfPath = 'test/assets/large_300p.pdf';
|
|
||||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
|
||||||
|
|
||||||
/// Frames to sample after warm-up.
|
|
||||||
const int _kSampleFrames = 120;
|
|
||||||
|
|
||||||
/// Frames to discard before sampling (cache warm-up, §7.1).
|
|
||||||
const int _kWarmupFrames = 30;
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
// Report raw frame timings to the device lab / driver too.
|
|
||||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
|
||||||
pdfrxFlutterInitialize();
|
|
||||||
|
|
||||||
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
|
testWidgets('perf_scroll_bench retired — use Surface diagnostic checklist',
|
||||||
final pdf = File(_kPdfPath);
|
(tester) async {
|
||||||
if (!pdf.existsSync()) {
|
// ignore: avoid_print
|
||||||
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
|
print('SKIP: spike_editor_pane deleted; run Surface checklist instead.');
|
||||||
return;
|
}, skip: true);
|
||||||
}
|
|
||||||
|
|
||||||
// ---- MUST #4: pdfrx alone ----
|
|
||||||
final r4 = await _runScrollPass(
|
|
||||||
tester,
|
|
||||||
label: 'MUST #4 — pdfrx alone (no ink overlay)',
|
|
||||||
inkLoad: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- MUST #5: WITH dense ink overlay ----
|
|
||||||
final r5 = await _runScrollPass(
|
|
||||||
tester,
|
|
||||||
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
|
|
||||||
inkLoad: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (r4 == null || r5 == null) {
|
|
||||||
stdout.writeln(
|
|
||||||
'\n=== PERF BENCH SKIPPED ===\n'
|
|
||||||
'pdfrx did not become ready (headless pdfium limitation). Run on the '
|
|
||||||
'Windows tablet in profile mode:\n'
|
|
||||||
' flutter drive --profile '
|
|
||||||
'--driver=test_driver/integration_test.dart '
|
|
||||||
'--target=integration_test/perf_scroll_bench.dart\n',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_printReport('MUST #4', r4, buildOnlyGate: false);
|
|
||||||
_printReport('MUST #5', r5, buildOnlyGate: true);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Stats {
|
|
||||||
_Stats(this.label, this.build, this.raster, this.total);
|
|
||||||
final String label;
|
|
||||||
final _Series build;
|
|
||||||
final _Series raster;
|
|
||||||
final _Series total;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Series {
|
|
||||||
_Series(List<double> values)
|
|
||||||
: median = _pct(values, 50),
|
|
||||||
p95 = _pct(values, 95),
|
|
||||||
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
|
|
||||||
jankFrames = values.where((v) => v > 32.0).length,
|
|
||||||
n = values.length;
|
|
||||||
final double median;
|
|
||||||
final double p95;
|
|
||||||
final double worst;
|
|
||||||
final int jankFrames;
|
|
||||||
final int n;
|
|
||||||
|
|
||||||
static double _pct(List<double> v, int p) {
|
|
||||||
if (v.isEmpty) return 0;
|
|
||||||
final s = List<double>.from(v)..sort();
|
|
||||||
final i = ((p / 100.0) * (s.length - 1)).round();
|
|
||||||
return s[i.clamp(0, s.length - 1)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pumps the spike pane, warms up, then drives a sustained fling while
|
|
||||||
/// collecting FrameTiming. Returns null if pdfrx never became ready.
|
|
||||||
Future<_Stats?> _runScrollPass(
|
|
||||||
WidgetTester tester, {
|
|
||||||
required String label,
|
|
||||||
required bool inkLoad,
|
|
||||||
}) async {
|
|
||||||
final controller = PdfViewerController();
|
|
||||||
final paneKey = GlobalKey<SpikeEditorPaneState>();
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
home: Scaffold(
|
|
||||||
body: SpikeEditorPane(
|
|
||||||
key: paneKey,
|
|
||||||
pdfPath: _kPdfPath,
|
|
||||||
controller: controller,
|
|
||||||
denseStrokesAsset: _kDenseStrokesAsset,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for the document to lay out.
|
|
||||||
final deadline = DateTime.now().add(const Duration(seconds: 20));
|
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
|
||||||
await tester.pump(const Duration(milliseconds: 100));
|
|
||||||
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
|
|
||||||
}
|
|
||||||
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inkLoad) {
|
|
||||||
await paneKey.currentState!.setInkLoad(true);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect frame timings.
|
|
||||||
final build = <double>[];
|
|
||||||
final raster = <double>[];
|
|
||||||
final total = <double>[];
|
|
||||||
var seen = 0;
|
|
||||||
void onTimings(List<FrameTiming> timings) {
|
|
||||||
for (final t in timings) {
|
|
||||||
seen++;
|
|
||||||
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
|
|
||||||
if (build.length >= _kSampleFrames) continue;
|
|
||||||
build.add(t.buildDuration.inMicroseconds / 1000.0);
|
|
||||||
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
|
||||||
total.add(t.totalSpan.inMicroseconds / 1000.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SchedulerBinding.instance.addTimingsCallback(onTimings);
|
|
||||||
try {
|
|
||||||
// Sustained fling: repeated downward flings across the viewport center to
|
|
||||||
// keep the document scrolling continuously while we gather ≥150 frames.
|
|
||||||
final center = tester.getCenter(find.byType(SpikeEditorPane));
|
|
||||||
var safety = 0;
|
|
||||||
while (build.length < _kSampleFrames && safety < 400) {
|
|
||||||
await tester.fling(
|
|
||||||
find.byType(SpikeEditorPane),
|
|
||||||
const Offset(0, -600),
|
|
||||||
2000,
|
|
||||||
warnIfMissed: false,
|
|
||||||
);
|
|
||||||
// Pump several frames to let the fling settle and emit timings.
|
|
||||||
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
}
|
|
||||||
// Nudge back up occasionally so we don't run off the end of 300 pages.
|
|
||||||
if (safety % 8 == 7) {
|
|
||||||
await tester.fling(find.byType(SpikeEditorPane),
|
|
||||||
const Offset(0, 1200), 2000, warnIfMissed: false);
|
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
|
||||||
}
|
|
||||||
safety++;
|
|
||||||
// Keep `center` referenced (avoids unused warning) and re-target if needed.
|
|
||||||
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
SchedulerBinding.instance.removeTimingsCallback(onTimings);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _Stats(
|
|
||||||
label,
|
|
||||||
_Series(build),
|
|
||||||
_Series(raster),
|
|
||||||
_Series(total),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
|
|
||||||
final buf = StringBuffer();
|
|
||||||
buf.writeln('\n========================================================');
|
|
||||||
buf.writeln('$tag — ${s.label}');
|
|
||||||
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
|
|
||||||
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
|
|
||||||
buf.writeln('--------------------------------------------------------');
|
|
||||||
buf.writeln('phase median p95 worst jank(>32ms)');
|
|
||||||
buf.writeln('build ${_row(s.build)}');
|
|
||||||
buf.writeln('raster ${_row(s.raster)}');
|
|
||||||
buf.writeln('total ${_row(s.total)}');
|
|
||||||
buf.writeln('--------------------------------------------------------');
|
|
||||||
if (buildOnlyGate) {
|
|
||||||
final pass = s.build.median <= 16.6;
|
|
||||||
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
|
|
||||||
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
|
|
||||||
} else {
|
|
||||||
final passMed = s.total.median <= 16.6;
|
|
||||||
final passP95 = s.total.p95 <= 22.0;
|
|
||||||
buf.writeln('GATE (MUST #4): build+raster median '
|
|
||||||
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
|
|
||||||
'${passMed ? "PASS" : "FAIL"}; '
|
|
||||||
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
|
|
||||||
'${passP95 ? "PASS" : "FAIL"}');
|
|
||||||
}
|
|
||||||
buf.writeln('========================================================\n');
|
|
||||||
stdout.write(buf.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
String _row(_Series s) =>
|
|
||||||
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
|
|
||||||
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
|
|
||||||
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
|
|
||||||
'${s.jankFrames.toString().padLeft(6)}';
|
|
||||||
|
|||||||
164
lib/diagnostics/badnote_log.dart
Normal file
164
lib/diagnostics/badnote_log.dart
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Global structured logging bus for BadNote.
|
||||||
|
//
|
||||||
|
// Always-on (unlike the old PDF-only DiagnosticLogger opt-in). Writes NDJSON
|
||||||
|
// lines to a rotating session file under the app documents directory so a
|
||||||
|
// Surface user can export a diagnostic pack without attaching a debugger.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
enum LogLevel { trace, debug, info, warn, error }
|
||||||
|
|
||||||
|
/// Known subsystems — keep the set small so filters stay useful.
|
||||||
|
abstract final class LogSubsystem {
|
||||||
|
static const shell = 'shell';
|
||||||
|
static const ink = 'ink';
|
||||||
|
static const arbiter = 'arbiter';
|
||||||
|
static const penNative = 'pen_native';
|
||||||
|
static const pdf = 'pdf';
|
||||||
|
static const office = 'office';
|
||||||
|
static const board = 'board';
|
||||||
|
static const sync = 'sync';
|
||||||
|
static const diag = 'diag';
|
||||||
|
static const frame = 'frame';
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadNoteLog {
|
||||||
|
BadNoteLog._();
|
||||||
|
static final BadNoteLog instance = BadNoteLog._();
|
||||||
|
|
||||||
|
final String sessionId = const Uuid().v4();
|
||||||
|
final List<Map<String, Object?>> _ring = <Map<String, Object?>>[];
|
||||||
|
static const int _ringCap = 4000;
|
||||||
|
|
||||||
|
File? _file;
|
||||||
|
Directory? _dir;
|
||||||
|
Timer? _flushTimer;
|
||||||
|
final List<String> _pending = <String>[];
|
||||||
|
bool _started = false;
|
||||||
|
LogLevel minLevel = LogLevel.debug;
|
||||||
|
|
||||||
|
/// Absolute path of the current session log, once [start] succeeds.
|
||||||
|
String? get path => _file?.path;
|
||||||
|
|
||||||
|
Directory? get directory => _dir;
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
try {
|
||||||
|
Directory base;
|
||||||
|
try {
|
||||||
|
base = await getApplicationDocumentsDirectory();
|
||||||
|
} catch (_) {
|
||||||
|
base = await getTemporaryDirectory();
|
||||||
|
}
|
||||||
|
_dir = Directory(
|
||||||
|
'${base.path}${Platform.pathSeparator}badnote_diagnostics',
|
||||||
|
);
|
||||||
|
if (!await _dir!.exists()) {
|
||||||
|
await _dir!.create(recursive: true);
|
||||||
|
}
|
||||||
|
final stamp = DateTime.now()
|
||||||
|
.toIso8601String()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replaceAll('.', '-');
|
||||||
|
_file = File(
|
||||||
|
'${_dir!.path}${Platform.pathSeparator}session_$stamp.ndjson',
|
||||||
|
);
|
||||||
|
await _file!.writeAsString(
|
||||||
|
'${jsonEncode({
|
||||||
|
'ts': DateTime.now().toIso8601String(),
|
||||||
|
'level': 'info',
|
||||||
|
'subsystem': LogSubsystem.diag,
|
||||||
|
'msg': 'session_start',
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'platform': Platform.operatingSystem,
|
||||||
|
'osVersion': Platform.operatingSystemVersion,
|
||||||
|
})}\n',
|
||||||
|
flush: true,
|
||||||
|
);
|
||||||
|
_flushTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
|
||||||
|
info(LogSubsystem.diag, 'log file ready', fields: {'path': _file!.path});
|
||||||
|
} catch (e) {
|
||||||
|
// Logging must never crash the app.
|
||||||
|
developer.log('BadNoteLog start failed: $e', name: 'badnote');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void trace(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||||
|
_emit(LogLevel.trace, subsystem, msg, fields);
|
||||||
|
|
||||||
|
void debug(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||||
|
_emit(LogLevel.debug, subsystem, msg, fields);
|
||||||
|
|
||||||
|
void info(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||||
|
_emit(LogLevel.info, subsystem, msg, fields);
|
||||||
|
|
||||||
|
void warn(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||||
|
_emit(LogLevel.warn, subsystem, msg, fields);
|
||||||
|
|
||||||
|
void error(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||||
|
_emit(LogLevel.error, subsystem, msg, fields);
|
||||||
|
|
||||||
|
void _emit(
|
||||||
|
LogLevel level,
|
||||||
|
String subsystem,
|
||||||
|
String msg,
|
||||||
|
Map<String, Object?>? fields,
|
||||||
|
) {
|
||||||
|
if (level.index < minLevel.index) return;
|
||||||
|
final entry = <String, Object?>{
|
||||||
|
'ts': DateTime.now().toIso8601String(),
|
||||||
|
'level': level.name,
|
||||||
|
'subsystem': subsystem,
|
||||||
|
'msg': msg,
|
||||||
|
'sessionId': sessionId,
|
||||||
|
if (fields != null) ...fields,
|
||||||
|
};
|
||||||
|
_ring.add(entry);
|
||||||
|
if (_ring.length > _ringCap) {
|
||||||
|
_ring.removeRange(0, _ring.length - _ringCap);
|
||||||
|
}
|
||||||
|
final line = jsonEncode(entry);
|
||||||
|
developer.log(line, name: 'badnote.$subsystem');
|
||||||
|
if (_file != null) {
|
||||||
|
_pending.add(line);
|
||||||
|
if (_pending.length >= 200) {
|
||||||
|
unawaited(_flush());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flush() async {
|
||||||
|
final file = _file;
|
||||||
|
if (file == null || _pending.isEmpty) return;
|
||||||
|
final chunk = '${_pending.join('\n')}\n';
|
||||||
|
_pending.clear();
|
||||||
|
try {
|
||||||
|
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the in-memory ring (newest last).
|
||||||
|
List<Map<String, Object?>> snapshotRing() =>
|
||||||
|
List<Map<String, Object?>>.unmodifiable(_ring);
|
||||||
|
|
||||||
|
Future<void> flush() => _flush();
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
_flushTimer?.cancel();
|
||||||
|
_flushTimer = null;
|
||||||
|
await _flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge for legacy call sites that still use plain strings.
|
||||||
|
void logLegacyInputLine(String line) {
|
||||||
|
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
|
||||||
|
}
|
||||||
177
lib/diagnostics/diagnostic_chrome.dart
Normal file
177
lib/diagnostics/diagnostic_chrome.dart
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'badnote_log.dart';
|
||||||
|
import 'diagnostic_export.dart';
|
||||||
|
import '../editor/canvas/input_diagnostics.dart';
|
||||||
|
import '../editor/input/diagnostic_logger.dart';
|
||||||
|
import '../editor/input/pen_input_service.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Shared diagnostics chrome: overlay readout + export action.
|
||||||
|
/// Mount on any document surface (note / PDF / PPT / board).
|
||||||
|
class DiagnosticChrome extends StatefulWidget {
|
||||||
|
const DiagnosticChrome({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.initiallyVisible = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final bool initiallyVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DiagnosticChrome> createState() => DiagnosticChromeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiagnosticChromeState extends State<DiagnosticChrome> {
|
||||||
|
late bool _visible = widget.initiallyVisible;
|
||||||
|
bool _exporting = false;
|
||||||
|
String? _lastExportPath;
|
||||||
|
|
||||||
|
bool get isVisible => _visible;
|
||||||
|
|
||||||
|
void toggle() {
|
||||||
|
setState(() {
|
||||||
|
_visible = !_visible;
|
||||||
|
if (_visible) {
|
||||||
|
DiagnosticLogger.instance.start();
|
||||||
|
InputDiagnostics.instance.reset();
|
||||||
|
} else {
|
||||||
|
DiagnosticLogger.instance.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> exportPack() async {
|
||||||
|
if (_exporting) return;
|
||||||
|
setState(() => _exporting = true);
|
||||||
|
try {
|
||||||
|
final result = await DiagnosticExport.instance.exportPack();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _lastExportPath = result.zipPath);
|
||||||
|
await Clipboard.setData(ClipboardData(text: result.zipPath));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
AppLocalizations.of(context).diagExported(result.bytes),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
BadNoteLog.instance.error(LogSubsystem.diag, 'export_failed', fields: {
|
||||||
|
'error': '$e',
|
||||||
|
});
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).diagExportFail('$e')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _exporting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
if (_visible)
|
||||||
|
Positioned(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: Material(
|
||||||
|
elevation: 6,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.black.withValues(alpha: 0.82),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: InputDiagnostics.instance,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Text(
|
||||||
|
'${InputDiagnostics.instance.summary()}\n'
|
||||||
|
'${PenInputService.instance.debugSummary}\n'
|
||||||
|
'log: ${BadNoteLog.instance.path ?? "(starting…)"}\n'
|
||||||
|
'session: ${BadNoteLog.instance.sessionId}'
|
||||||
|
'${_lastExportPath != null ? "\nlast zip: $_lastExportPath" : ""}',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => InputDiagnostics.instance.reset(),
|
||||||
|
child: const Text('Reset',
|
||||||
|
style: TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _exporting ? null : exportPack,
|
||||||
|
child: Text(
|
||||||
|
_exporting ? 'Exporting…' : 'Export pack',
|
||||||
|
style: const TextStyle(color: Colors.lightGreenAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: toggle,
|
||||||
|
child: const Text('Hide',
|
||||||
|
style: TextStyle(color: Colors.white54)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact icon button for app bars / toolbars.
|
||||||
|
class DiagnosticToggleButton extends StatelessWidget {
|
||||||
|
const DiagnosticToggleButton({
|
||||||
|
super.key,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.onExport,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final VoidCallback onExport;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
tooltip: 'Diagnostics',
|
||||||
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'toggle') onToggle();
|
||||||
|
if (v == 'export') onExport();
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem(value: 'toggle', child: Text('Toggle overlay')),
|
||||||
|
PopupMenuItem(value: 'export', child: Text('Export diagnostic pack')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
lib/diagnostics/diagnostic_export.dart
Normal file
154
lib/diagnostics/diagnostic_export.dart
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Build a zip diagnostic pack the user can hand back for remote debugging.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../editor/canvas/input_diagnostics.dart';
|
||||||
|
import '../editor/input/pen_input_service.dart';
|
||||||
|
import 'badnote_log.dart';
|
||||||
|
import 'frame_sampler.dart';
|
||||||
|
import 'pen_event_ring.dart';
|
||||||
|
|
||||||
|
/// Injected at build/export time so Surface packages can be matched to git.
|
||||||
|
/// Override via `--dart-define=BADNOTE_GIT_SHA=...` in CI.
|
||||||
|
const String kBadNoteGitSha = String.fromEnvironment(
|
||||||
|
'BADNOTE_GIT_SHA',
|
||||||
|
defaultValue: 'dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
const String kBadNoteBuildTime = String.fromEnvironment(
|
||||||
|
'BADNOTE_BUILD_TIME',
|
||||||
|
defaultValue: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
class DiagnosticExportResult {
|
||||||
|
DiagnosticExportResult({required this.zipPath, required this.bytes});
|
||||||
|
|
||||||
|
final String zipPath;
|
||||||
|
final int bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiagnosticExport {
|
||||||
|
DiagnosticExport._();
|
||||||
|
static final DiagnosticExport instance = DiagnosticExport._();
|
||||||
|
|
||||||
|
/// Flush logs and write a zip under Documents/badnote_diagnostics/.
|
||||||
|
Future<DiagnosticExportResult> exportPack({
|
||||||
|
Duration penWindow = const Duration(minutes: 5),
|
||||||
|
}) async {
|
||||||
|
final log = BadNoteLog.instance;
|
||||||
|
await log.flush();
|
||||||
|
|
||||||
|
final meta = <String, Object?>{
|
||||||
|
'exportedAt': DateTime.now().toIso8601String(),
|
||||||
|
'sessionId': log.sessionId,
|
||||||
|
'gitSha': kBadNoteGitSha,
|
||||||
|
'buildTime': kBadNoteBuildTime.isEmpty ? null : kBadNoteBuildTime,
|
||||||
|
'platform': Platform.operatingSystem,
|
||||||
|
'osVersion': Platform.operatingSystemVersion,
|
||||||
|
'localHostname': Platform.localHostname,
|
||||||
|
'numberOfProcessors': Platform.numberOfProcessors,
|
||||||
|
'flutter': {
|
||||||
|
'foundationDebug': kDebugMode,
|
||||||
|
'foundationProfile': kProfileMode,
|
||||||
|
'foundationRelease': kReleaseMode,
|
||||||
|
},
|
||||||
|
'penNative': PenInputService.instance.debugSummary,
|
||||||
|
'penActive': PenInputService.instance.isActive,
|
||||||
|
'zoom': InputDiagnostics.instance.summary(),
|
||||||
|
'frames': FrameSampler.instance.summary(),
|
||||||
|
'instructions':
|
||||||
|
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip. '
|
||||||
|
'Confirm meta.gitSha matches the CI commit you installed.',
|
||||||
|
};
|
||||||
|
|
||||||
|
final archive = Archive();
|
||||||
|
void addText(String name, String body) {
|
||||||
|
final bytes = utf8.encode(body);
|
||||||
|
archive.addFile(ArchiveFile(name, bytes.length, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
addText('meta.json', const JsonEncoder.withIndent(' ').convert(meta));
|
||||||
|
addText(
|
||||||
|
'pen_events.json',
|
||||||
|
const JsonEncoder.withIndent(' ').convert(
|
||||||
|
PenEventRing.instance.toJsonList(window: penWindow),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addText(
|
||||||
|
'frame_samples.json',
|
||||||
|
const JsonEncoder.withIndent(' ').convert(FrameSampler.instance.toJsonList()),
|
||||||
|
);
|
||||||
|
addText(
|
||||||
|
'log_ring.json',
|
||||||
|
const JsonEncoder.withIndent(' ').convert(log.snapshotRing()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Include on-disk session NDJSON if present.
|
||||||
|
final sessionPath = log.path;
|
||||||
|
if (sessionPath != null) {
|
||||||
|
try {
|
||||||
|
final f = File(sessionPath);
|
||||||
|
if (await f.exists()) {
|
||||||
|
final bytes = await f.readAsBytes();
|
||||||
|
archive.addFile(
|
||||||
|
ArchiveFile('session.ndjson', bytes.length, bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy input log if it exists alongside.
|
||||||
|
try {
|
||||||
|
Directory dir;
|
||||||
|
try {
|
||||||
|
dir = await getApplicationDocumentsDirectory();
|
||||||
|
} catch (_) {
|
||||||
|
dir = await getTemporaryDirectory();
|
||||||
|
}
|
||||||
|
final legacy = File(
|
||||||
|
'${dir.path}${Platform.pathSeparator}badnote_input_log.txt',
|
||||||
|
);
|
||||||
|
if (await legacy.exists()) {
|
||||||
|
final bytes = await legacy.readAsBytes();
|
||||||
|
archive.addFile(
|
||||||
|
ArchiveFile('legacy_input_log.txt', bytes.length, bytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final encoded = ZipEncoder().encode(archive);
|
||||||
|
if (encoded.isEmpty) {
|
||||||
|
throw StateError('Failed to encode diagnostic zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory outDir = log.directory ??
|
||||||
|
Directory(
|
||||||
|
'${(await getApplicationDocumentsDirectory()).path}'
|
||||||
|
'${Platform.pathSeparator}badnote_diagnostics',
|
||||||
|
);
|
||||||
|
if (!await outDir.exists()) {
|
||||||
|
await outDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
final stamp = DateTime.now()
|
||||||
|
.toIso8601String()
|
||||||
|
.replaceAll(':', '-')
|
||||||
|
.replaceAll('.', '-');
|
||||||
|
final zipPath =
|
||||||
|
'${outDir.path}${Platform.pathSeparator}badnote_diag_$stamp.zip';
|
||||||
|
await File(zipPath).writeAsBytes(encoded, flush: true);
|
||||||
|
|
||||||
|
BadNoteLog.instance.info(
|
||||||
|
LogSubsystem.diag,
|
||||||
|
'export_pack',
|
||||||
|
fields: {'path': zipPath, 'bytes': encoded.length},
|
||||||
|
);
|
||||||
|
|
||||||
|
return DiagnosticExportResult(zipPath: zipPath, bytes: encoded.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/diagnostics/frame_sampler.dart
Normal file
99
lib/diagnostics/frame_sampler.dart
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// Frame / hitch sampler for diagnostic packs.
|
||||||
|
|
||||||
|
import 'badnote_log.dart';
|
||||||
|
|
||||||
|
class FrameSample {
|
||||||
|
FrameSample({
|
||||||
|
required this.at,
|
||||||
|
required this.label,
|
||||||
|
required this.ms,
|
||||||
|
this.dropped = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime at;
|
||||||
|
final String label;
|
||||||
|
final double ms;
|
||||||
|
final bool dropped;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'at': at.toIso8601String(),
|
||||||
|
'label': label,
|
||||||
|
'ms': ms,
|
||||||
|
'dropped': dropped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrameSampler {
|
||||||
|
FrameSampler._();
|
||||||
|
static final FrameSampler instance = FrameSampler._();
|
||||||
|
|
||||||
|
static const int capacity = 500;
|
||||||
|
final List<FrameSample> _samples = <FrameSample>[];
|
||||||
|
int overBudget = 0;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
/// Budget for a single frame at 60fps.
|
||||||
|
static const double budgetMs = 16.6;
|
||||||
|
|
||||||
|
void record(String label, double ms, {bool dropped = false}) {
|
||||||
|
total++;
|
||||||
|
final over = ms > budgetMs;
|
||||||
|
if (over) overBudget++;
|
||||||
|
final sample = FrameSample(
|
||||||
|
at: DateTime.now(),
|
||||||
|
label: label,
|
||||||
|
ms: ms,
|
||||||
|
dropped: dropped || over,
|
||||||
|
);
|
||||||
|
_samples.add(sample);
|
||||||
|
if (_samples.length > capacity) {
|
||||||
|
_samples.removeRange(0, _samples.length - capacity);
|
||||||
|
}
|
||||||
|
if (over || dropped) {
|
||||||
|
BadNoteLog.instance.warn(
|
||||||
|
LogSubsystem.frame,
|
||||||
|
'slow_frame',
|
||||||
|
fields: {'label': label, 'ms': ms, 'dropped': dropped},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordZoom({
|
||||||
|
required double rawScale,
|
||||||
|
required bool scaleDrop,
|
||||||
|
required bool focalDrop,
|
||||||
|
required double focalJumpPx,
|
||||||
|
}) {
|
||||||
|
record(
|
||||||
|
'zoom',
|
||||||
|
scaleDrop || focalDrop ? budgetMs + 1 : 8,
|
||||||
|
dropped: scaleDrop || focalDrop,
|
||||||
|
);
|
||||||
|
BadNoteLog.instance.debug(
|
||||||
|
LogSubsystem.frame,
|
||||||
|
'zoom',
|
||||||
|
fields: {
|
||||||
|
'rawScale': rawScale,
|
||||||
|
'scaleDrop': scaleDrop,
|
||||||
|
'focalDrop': focalDrop,
|
||||||
|
'focalJumpPx': focalJumpPx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object?>> toJsonList() =>
|
||||||
|
_samples.map((s) => s.toJson()).toList(growable: false);
|
||||||
|
|
||||||
|
Map<String, Object?> summary() => {
|
||||||
|
'total': total,
|
||||||
|
'overBudget': overBudget,
|
||||||
|
'budgetMs': budgetMs,
|
||||||
|
'recent': toJsonList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_samples.clear();
|
||||||
|
overBudget = 0;
|
||||||
|
total = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
lib/diagnostics/pen_event_ring.dart
Normal file
121
lib/diagnostics/pen_event_ring.dart
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Rolling ring of recent pen / arbiter events for diagnostic export.
|
||||||
|
|
||||||
|
class PenEventRecord {
|
||||||
|
PenEventRecord({
|
||||||
|
required this.at,
|
||||||
|
required this.kind,
|
||||||
|
required this.pointerId,
|
||||||
|
this.pressure,
|
||||||
|
this.tiltX,
|
||||||
|
this.tiltY,
|
||||||
|
this.barrel = false,
|
||||||
|
this.eraser = false,
|
||||||
|
this.inverted = false,
|
||||||
|
this.decision,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime at;
|
||||||
|
final String kind; // down|move|up|hw|arbiter
|
||||||
|
final int pointerId;
|
||||||
|
final double? pressure;
|
||||||
|
final double? tiltX;
|
||||||
|
final double? tiltY;
|
||||||
|
final bool barrel;
|
||||||
|
final bool eraser;
|
||||||
|
final bool inverted;
|
||||||
|
final String? decision; // draw|pan|reject
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'at': at.toIso8601String(),
|
||||||
|
'kind': kind,
|
||||||
|
'pointerId': pointerId,
|
||||||
|
if (pressure != null) 'pressure': pressure,
|
||||||
|
if (tiltX != null) 'tiltX': tiltX,
|
||||||
|
if (tiltY != null) 'tiltY': tiltY,
|
||||||
|
'barrel': barrel,
|
||||||
|
'eraser': eraser,
|
||||||
|
'inverted': inverted,
|
||||||
|
if (decision != null) 'decision': decision,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PenEventRing {
|
||||||
|
PenEventRing._();
|
||||||
|
static final PenEventRing instance = PenEventRing._();
|
||||||
|
|
||||||
|
static const int capacity = 2000;
|
||||||
|
final List<PenEventRecord> _events = <PenEventRecord>[];
|
||||||
|
|
||||||
|
void add(PenEventRecord event) {
|
||||||
|
_events.add(event);
|
||||||
|
if (_events.length > capacity) {
|
||||||
|
_events.removeRange(0, _events.length - capacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordPointer({
|
||||||
|
required String kind,
|
||||||
|
required int pointerId,
|
||||||
|
required String deviceKind,
|
||||||
|
double? pressure,
|
||||||
|
String? decision,
|
||||||
|
String? note,
|
||||||
|
}) {
|
||||||
|
add(PenEventRecord(
|
||||||
|
at: DateTime.now(),
|
||||||
|
kind: kind,
|
||||||
|
pointerId: pointerId,
|
||||||
|
pressure: pressure,
|
||||||
|
decision: decision,
|
||||||
|
note: note ?? deviceKind,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordHardware({
|
||||||
|
required bool barrel,
|
||||||
|
required bool eraser,
|
||||||
|
required bool inverted,
|
||||||
|
required double tiltX,
|
||||||
|
required double tiltY,
|
||||||
|
}) {
|
||||||
|
add(PenEventRecord(
|
||||||
|
at: DateTime.now(),
|
||||||
|
kind: 'hw',
|
||||||
|
pointerId: -1,
|
||||||
|
barrel: barrel,
|
||||||
|
eraser: eraser,
|
||||||
|
inverted: inverted,
|
||||||
|
tiltX: tiltX,
|
||||||
|
tiltY: tiltY,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordArbiter({
|
||||||
|
required int activeCount,
|
||||||
|
required String deviceKind,
|
||||||
|
required bool draw,
|
||||||
|
required bool fingerDrawing,
|
||||||
|
}) {
|
||||||
|
add(PenEventRecord(
|
||||||
|
at: DateTime.now(),
|
||||||
|
kind: 'arbiter',
|
||||||
|
pointerId: -1,
|
||||||
|
decision: draw ? 'draw' : 'pan',
|
||||||
|
note: 'count=$activeCount kind=$deviceKind finger=$fingerDrawing',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PenEventRecord> recent({Duration? window}) {
|
||||||
|
if (window == null) return List.unmodifiable(_events);
|
||||||
|
final cut = DateTime.now().subtract(window);
|
||||||
|
return _events.where((e) => e.at.isAfter(cut)).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object?>> toJsonList({Duration? window}) =>
|
||||||
|
recent(window: window).map((e) => e.toJson()).toList(growable: false);
|
||||||
|
|
||||||
|
void clear() => _events.clear();
|
||||||
|
}
|
||||||
61
lib/editor/canvas/editor_tool.dart
Normal file
61
lib/editor/canvas/editor_tool.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// lib/editor/canvas/editor_tool.dart
|
||||||
|
//
|
||||||
|
// The shared tool model for the pen-first editors (PDF, note, slide). Replaces
|
||||||
|
// the scattered per-editor booleans (`_selectTextMode`, `_placeLinkMode`, the
|
||||||
|
// old `CanvasTool` pen/highlighter/eraser triad) with ONE active-tool enum so
|
||||||
|
// every editor reasons about "which tool is active" the same way.
|
||||||
|
//
|
||||||
|
// [EditorToolKind] is the core, render-path-independent set shared by all three
|
||||||
|
// editors. The PDF editor layers TWO extra page-anchored tools on top
|
||||||
|
// (select-text and place-scratch-link) that the PenCanvas editors don't have —
|
||||||
|
// those remain editor-local because they ride pdfrx's text layer / the page
|
||||||
|
// overlay, not the ink capture path. See `selectTextOrLink` note below.
|
||||||
|
//
|
||||||
|
// TODO(toolbar-batch-2): bookmark-to-paragraph, search+OCR, templates — later
|
||||||
|
// batches add kinds here. (The typed-text tool now exists as [EditorToolKind.
|
||||||
|
// text], PDF-only for now; see the `text` doc below.)
|
||||||
|
|
||||||
|
/// The shared inking/editing tools available on every pen-first canvas.
|
||||||
|
enum EditorToolKind {
|
||||||
|
/// Freehand drawing with the currently-selected [BrushKind] (fountain pen,
|
||||||
|
/// ballpoint, or pencil). Each brush carries its own remembered color.
|
||||||
|
brush,
|
||||||
|
|
||||||
|
/// Freehand drawing with the highlighter brush (its own color + flat width).
|
||||||
|
highlighter,
|
||||||
|
|
||||||
|
/// Stroke eraser (partial / whole-stroke per PenConfig).
|
||||||
|
eraser,
|
||||||
|
|
||||||
|
/// Cursor / selection tool: tap a committed stroke to select it, drag the
|
||||||
|
/// selection to translate it, delete to remove it.
|
||||||
|
select,
|
||||||
|
|
||||||
|
/// Shape tool: pen-drag previews a [ShapeKind] from start→current and commits
|
||||||
|
/// it as a generated [PenStroke] on release.
|
||||||
|
shape,
|
||||||
|
|
||||||
|
/// Typed-text tool (PDF editor only for now): a pen-tap OR a mouse
|
||||||
|
/// double-click on a page drops a text box at that normalized point and
|
||||||
|
/// focuses a real Flutter text field for input (so the OS IME / Windows-Ink
|
||||||
|
/// handwriting panel works). Committed boxes render glued to the page and are
|
||||||
|
/// re-editable; an empty box deletes itself on blur.
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shapes the [EditorToolKind.shape] tool can draw. Each is generated as a
|
||||||
|
/// plain [PenStroke] (a polyline) so it reuses stroke rendering, persistence,
|
||||||
|
/// erase, and undo with no new model — see `shape_geometry.dart`.
|
||||||
|
enum ShapeKind {
|
||||||
|
/// Straight line: 2 points (start → end).
|
||||||
|
line,
|
||||||
|
|
||||||
|
/// Axis-aligned rectangle: 5-point closed polyline (start corner → end corner).
|
||||||
|
rectangle,
|
||||||
|
|
||||||
|
/// Ellipse inscribed in the start→end bounding box: ~48 sampled points.
|
||||||
|
ellipse,
|
||||||
|
|
||||||
|
/// Arrow: shaft (start → end) plus two arrowhead segments at the end.
|
||||||
|
arrow,
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
import '../engine/stroke_geometry.dart'
|
import '../engine/stroke_geometry.dart'
|
||||||
show kDefaultPenThinning, kPenSmoothing, kPenStreamline;
|
show freehandOutlinePoints, kDefaultPenThinning;
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
||||||
@@ -39,22 +40,19 @@ Path buildStrokePath(
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final outline = pf.getStroke(
|
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
|
||||||
pfPoints,
|
// this PDF-overlay path and the note/slide path can never diverge (R7), and
|
||||||
options: pf.StrokeOptions(
|
// resolve the stroke's brush so each brush renders with its own
|
||||||
size: pixelWidth,
|
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
|
||||||
// configurable [thinning] so real Surface-Pen pressure changes width.
|
final outline = freehandOutlinePoints(
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
pfPoints: pfPoints,
|
||||||
smoothing: kPenSmoothing,
|
size: pixelWidth,
|
||||||
streamline: kPenStreamline,
|
isHighlighter: isHighlighter,
|
||||||
// Real stylus pressure → don't simulate; no pressure → let freehand fake
|
hasRealPressure: hasRealPressure,
|
||||||
// it based on velocity. (perfect_freehand 2.x honors REAL pressure when
|
isComplete: isComplete,
|
||||||
// simulatePressure is false — 1.0.4 ignored it, which made width
|
thinning: thinning,
|
||||||
// unresponsive to pen force.)
|
brush: brushProfileFor(stroke.brush),
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
|
||||||
isComplete: isComplete,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final path = Path();
|
final path = Path();
|
||||||
@@ -67,6 +65,34 @@ Path buildStrokePath(
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mean point pressure (`pressure ?? 0.5`) of a [PenStroke], for the per-stroke
|
||||||
|
/// opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to pressure).
|
||||||
|
double _avgPressure(PenStroke stroke) {
|
||||||
|
if (stroke.points.isEmpty) return 0.5;
|
||||||
|
var sum = 0.0;
|
||||||
|
for (final p in stroke.points) {
|
||||||
|
sum += p.pressure ?? 0.5;
|
||||||
|
}
|
||||||
|
return sum / stroke.points.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE single fill [Paint] for a committed/live stroke, with the brush's
|
||||||
|
/// resolved opacity (multiplied into the color's alpha) and blend mode applied
|
||||||
|
/// — closes TODO(brush-opacity). Shared by [StaticInkPainter]/[LiveInkPainter]
|
||||||
|
/// and the PDF overlay painter so both render paths composite identically.
|
||||||
|
Paint paintForStroke(PenStroke stroke) {
|
||||||
|
final resolved = resolveStrokePaint(
|
||||||
|
stroke.brush,
|
||||||
|
stroke.color,
|
||||||
|
pressureAvg: _avgPressure(stroke),
|
||||||
|
);
|
||||||
|
return Paint()
|
||||||
|
..color = resolved.color
|
||||||
|
..blendMode = resolved.blendMode
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Paints all committed strokes for the page. Repaints only when the stroke
|
/// Paints all committed strokes for the page. Repaints only when the stroke
|
||||||
/// list identity or page size changes (kept behind a RepaintBoundary).
|
/// list identity or page size changes (kept behind a RepaintBoundary).
|
||||||
class StaticInkPainter extends CustomPainter {
|
class StaticInkPainter extends CustomPainter {
|
||||||
@@ -88,13 +114,9 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
final path =
|
final path =
|
||||||
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
canvas.drawPath(
|
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
|
||||||
path,
|
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
|
||||||
Paint()
|
canvas.drawPath(path, paintForStroke(stroke));
|
||||||
..color = Color(stroke.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +223,52 @@ class EraserPreviewPainter extends CustomPainter {
|
|||||||
old.pageSize != pageSize;
|
old.pageSize != pageSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paints the SELECT tool's selection: a dashed-ish bounding box around the
|
||||||
|
/// selected stroke(s) so the user sees what is selected and draggable. The box
|
||||||
|
/// is given in normalized page coords and scaled to pixels at paint time.
|
||||||
|
class SelectionOverlayPainter extends CustomPainter {
|
||||||
|
SelectionOverlayPainter({
|
||||||
|
required this.boundsNorm,
|
||||||
|
required this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Selection bounding box in normalized page coords (null = nothing selected).
|
||||||
|
final Rect? boundsNorm;
|
||||||
|
final Size pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final b = boundsNorm;
|
||||||
|
if (b == null) return;
|
||||||
|
// Inflate slightly so the box doesn't clip the stroke's rendered width.
|
||||||
|
const padPx = 6.0;
|
||||||
|
final rect = Rect.fromLTRB(
|
||||||
|
b.left * pageSize.width - padPx,
|
||||||
|
b.top * pageSize.height - padPx,
|
||||||
|
b.right * pageSize.width + padPx,
|
||||||
|
b.bottom * pageSize.height + padPx,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
|
||||||
|
..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFF2962FF)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5
|
||||||
|
..isAntiAlias = true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(SelectionOverlayPainter old) =>
|
||||||
|
old.boundsNorm != boundsNorm || old.pageSize != pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
/// Paints just the in-progress stroke (the live layer), kept behind its own
|
/// Paints just the in-progress stroke (the live layer), kept behind its own
|
||||||
/// RepaintBoundary so committed strokes don't repaint on every move.
|
/// RepaintBoundary so committed strokes don't repaint on every move.
|
||||||
class LiveInkPainter extends CustomPainter {
|
class LiveInkPainter extends CustomPainter {
|
||||||
@@ -224,13 +292,7 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
final path =
|
final path =
|
||||||
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
|
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) return;
|
if (path.getBounds().isEmpty) return;
|
||||||
canvas.drawPath(
|
canvas.drawPath(path, paintForStroke(s));
|
||||||
path,
|
|
||||||
Paint()
|
|
||||||
..color = Color(s.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../diagnostics/frame_sampler.dart';
|
||||||
import '../input/diagnostic_logger.dart';
|
import '../input/diagnostic_logger.dart';
|
||||||
|
|
||||||
class InputDiagnostics extends ChangeNotifier {
|
class InputDiagnostics extends ChangeNotifier {
|
||||||
@@ -15,7 +16,8 @@ class InputDiagnostics extends ChangeNotifier {
|
|||||||
static final InputDiagnostics instance = InputDiagnostics._();
|
static final InputDiagnostics instance = InputDiagnostics._();
|
||||||
|
|
||||||
int frames = 0;
|
int frames = 0;
|
||||||
int scaleDropped = 0; // frames rejected as a scale glitch
|
int scaleDropped = 0; // frames HARD-rejected (legacy; prefer soft-clamp)
|
||||||
|
int softClamped = 0; // frames whose step was soft-clamped (still applied)
|
||||||
int focalDropped = 0; // frames rejected as a focal/position glitch
|
int focalDropped = 0; // frames rejected as a focal/position glitch
|
||||||
int rebaselines = 0; // pointer-count re-baselines
|
int rebaselines = 0; // pointer-count re-baselines
|
||||||
int pointerCountMax = 0;
|
int pointerCountMax = 0;
|
||||||
@@ -41,13 +43,16 @@ class InputDiagnostics extends ChangeNotifier {
|
|||||||
required double focalJumpPx,
|
required double focalJumpPx,
|
||||||
required bool scaleDrop,
|
required bool scaleDrop,
|
||||||
required bool focalDrop,
|
required bool focalDrop,
|
||||||
|
bool softClamped = false,
|
||||||
|
double? liveScale,
|
||||||
}) {
|
}) {
|
||||||
frames++;
|
frames++;
|
||||||
if (scaleDrop) scaleDropped++;
|
if (scaleDrop) scaleDropped++;
|
||||||
|
if (softClamped) this.softClamped++;
|
||||||
if (focalDrop) focalDropped++;
|
if (focalDrop) focalDropped++;
|
||||||
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
|
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
|
||||||
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
|
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
|
||||||
final double resulting = currentScale * appliedChange;
|
final double resulting = currentScale;
|
||||||
if (resulting < scaleMin) scaleMin = resulting;
|
if (resulting < scaleMin) scaleMin = resulting;
|
||||||
if (resulting > scaleMax) scaleMax = resulting;
|
if (resulting > scaleMax) scaleMax = resulting;
|
||||||
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
|
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
|
||||||
@@ -55,19 +60,31 @@ class InputDiagnostics extends ChangeNotifier {
|
|||||||
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
|
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
|
||||||
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
|
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
|
||||||
|
|
||||||
|
final liveBit = liveScale == null
|
||||||
|
? ''
|
||||||
|
: ' live=${liveScale.toStringAsFixed(3)}';
|
||||||
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
|
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
|
||||||
'ch=${appliedChange.toStringAsFixed(3)} '
|
'ch=${appliedChange.toStringAsFixed(3)} '
|
||||||
'cur=${currentScale.toStringAsFixed(3)} '
|
'cur=${currentScale.toStringAsFixed(3)}$liveBit '
|
||||||
'fj=${focalJumpPx.toStringAsFixed(0)}'
|
'fj=${focalJumpPx.toStringAsFixed(0)}'
|
||||||
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
|
'${softClamped ? " SCLAMP" : ""}'
|
||||||
|
'${scaleDrop ? " SDROP" : ""}'
|
||||||
|
'${focalDrop ? " FDROP" : ""}';
|
||||||
_trace.add(line);
|
_trace.add(line);
|
||||||
if (_trace.length > 24) _trace.removeAt(0);
|
if (_trace.length > 24) _trace.removeAt(0);
|
||||||
|
FrameSampler.instance.recordZoom(
|
||||||
|
rawScale: rawScale,
|
||||||
|
scaleDrop: scaleDrop || softClamped,
|
||||||
|
focalDrop: focalDrop,
|
||||||
|
focalJumpPx: focalJumpPx,
|
||||||
|
);
|
||||||
DiagnosticLogger.instance.log('ZOOM $line');
|
DiagnosticLogger.instance.log('ZOOM $line');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0;
|
frames = scaleDropped = softClamped = focalDropped = rebaselines =
|
||||||
|
pointerCountMax = 0;
|
||||||
rawScaleMin = scaleMin = double.infinity;
|
rawScaleMin = scaleMin = double.infinity;
|
||||||
rawScaleMax = scaleMax = 0;
|
rawScaleMax = scaleMax = 0;
|
||||||
maxFocalJumpPx = 0;
|
maxFocalJumpPx = 0;
|
||||||
@@ -76,15 +93,15 @@ class InputDiagnostics extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-';
|
|
||||||
|
|
||||||
String summary() {
|
String summary() {
|
||||||
if (frames == 0) return 'zoom: (pinch to record)';
|
final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
|
||||||
return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped '
|
final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
|
||||||
'rebase=$rebaselines pMax=$pointerCountMax\n'
|
final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
|
||||||
' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} '
|
final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
|
||||||
'scale=${_f(scaleMin)}..${_f(scaleMax)}\n'
|
return 'zoom f=$frames sDrop=$scaleDropped sClamp=$softClamped '
|
||||||
|
'fDrop=$focalDropped rebase=$rebaselines pMax=$pointerCountMax\n'
|
||||||
|
' raw=$rawLo..$rawHi scale=$scLo..$scHi\n'
|
||||||
' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px '
|
' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px '
|
||||||
'maxScaleJump=${_f(maxAppliedScaleJump)}';
|
'maxScaleJump=${maxAppliedScaleJump.toStringAsFixed(2)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
lib/editor/canvas/note_background.dart
Normal file
160
lib/editor/canvas/note_background.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// lib/editor/canvas/note_background.dart
|
||||||
|
//
|
||||||
|
// rnote-style page background TEMPLATES for the blank-note editor. A background
|
||||||
|
// is a repeating PATTERN painted in the note page's local pixel space (the
|
||||||
|
// `pageWidget` is sized to the page rect inside the InteractiveViewer, so a
|
||||||
|
// CustomPainter here scales 1:1 with zoom — no extra transform needed).
|
||||||
|
//
|
||||||
|
// The choice is per-notebook and persists in the sidecar (stored as the enum
|
||||||
|
// `name`; missing/unknown → [NoteBackground.blank] for back-compat).
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// The available page-background templates (rnote: blank + dots/lines/grid +
|
||||||
|
/// the Cornell note layout).
|
||||||
|
enum NoteBackground {
|
||||||
|
/// Plain white sheet, no pattern.
|
||||||
|
blank,
|
||||||
|
|
||||||
|
/// A regular grid of small dots (dotted paper).
|
||||||
|
dots,
|
||||||
|
|
||||||
|
/// Evenly spaced horizontal lines (ruled / lined paper).
|
||||||
|
ruled,
|
||||||
|
|
||||||
|
/// Square grid (graph paper).
|
||||||
|
grid,
|
||||||
|
|
||||||
|
/// Cornell layout: a left cue-column line + a bottom summary line over a
|
||||||
|
/// ruled note-taking body.
|
||||||
|
cornell,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a persisted background name (the enum [NoteBackground.name]); unknown
|
||||||
|
/// or missing values fall back to [NoteBackground.blank] (back-compat).
|
||||||
|
NoteBackground noteBackgroundFromName(String? name) {
|
||||||
|
for (final b in NoteBackground.values) {
|
||||||
|
if (b.name == name) return b;
|
||||||
|
}
|
||||||
|
return NoteBackground.blank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Localized-ish English display label for the picker menu.
|
||||||
|
String noteBackgroundLabel(NoteBackground b) {
|
||||||
|
switch (b) {
|
||||||
|
case NoteBackground.blank:
|
||||||
|
return 'Blank';
|
||||||
|
case NoteBackground.dots:
|
||||||
|
return 'Dots';
|
||||||
|
case NoteBackground.ruled:
|
||||||
|
return 'Ruled lines';
|
||||||
|
case NoteBackground.grid:
|
||||||
|
return 'Grid';
|
||||||
|
case NoteBackground.cornell:
|
||||||
|
return 'Cornell';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon for the picker menu.
|
||||||
|
IconData noteBackgroundIcon(NoteBackground b) {
|
||||||
|
switch (b) {
|
||||||
|
case NoteBackground.blank:
|
||||||
|
return Icons.crop_portrait;
|
||||||
|
case NoteBackground.dots:
|
||||||
|
return Icons.grain;
|
||||||
|
case NoteBackground.ruled:
|
||||||
|
return Icons.notes;
|
||||||
|
case NoteBackground.grid:
|
||||||
|
return Icons.grid_4x4;
|
||||||
|
case NoteBackground.cornell:
|
||||||
|
return Icons.view_quilt_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints a [NoteBackground] template behind the ink, in the page's local pixel
|
||||||
|
/// space. Spacing is page-relative (a fraction of page width) so the template
|
||||||
|
/// looks the same on any logical page size, and the lines are a light, subtle
|
||||||
|
/// grey so they sit behind handwriting.
|
||||||
|
class NoteBackgroundPainter extends CustomPainter {
|
||||||
|
const NoteBackgroundPainter(this.background);
|
||||||
|
|
||||||
|
final NoteBackground background;
|
||||||
|
|
||||||
|
/// Pattern spacing as a fraction of the page WIDTH — a ~28-line page.
|
||||||
|
static const double _spacingFraction = 1 / 28;
|
||||||
|
|
||||||
|
static const Color _lineColor = Color(0x1A000000); // ~10% black, subtle grey.
|
||||||
|
static const Color _dotColor = Color(0x33000000); // dots a touch darker.
|
||||||
|
static const Color _accentColor = Color(0x33335C81); // Cornell margin lines.
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (background == NoteBackground.blank) return;
|
||||||
|
final spacing = size.width * _spacingFraction;
|
||||||
|
if (spacing <= 0) return;
|
||||||
|
|
||||||
|
switch (background) {
|
||||||
|
case NoteBackground.blank:
|
||||||
|
break;
|
||||||
|
case NoteBackground.dots:
|
||||||
|
_paintDots(canvas, size, spacing);
|
||||||
|
case NoteBackground.ruled:
|
||||||
|
_paintRuled(canvas, size, spacing);
|
||||||
|
case NoteBackground.grid:
|
||||||
|
_paintGrid(canvas, size, spacing);
|
||||||
|
case NoteBackground.cornell:
|
||||||
|
_paintCornell(canvas, size, spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintDots(Canvas canvas, Size size, double spacing) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = _dotColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
final r = (spacing * 0.06).clamp(0.6, 2.0);
|
||||||
|
for (double y = spacing; y < size.height; y += spacing) {
|
||||||
|
for (double x = spacing; x < size.width; x += spacing) {
|
||||||
|
canvas.drawCircle(Offset(x, y), r, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintRuled(Canvas canvas, Size size, double spacing) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = _lineColor
|
||||||
|
..strokeWidth = 1.0;
|
||||||
|
for (double y = spacing; y < size.height; y += spacing) {
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintGrid(Canvas canvas, Size size, double spacing) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = _lineColor
|
||||||
|
..strokeWidth = 1.0;
|
||||||
|
for (double y = spacing; y < size.height; y += spacing) {
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||||
|
}
|
||||||
|
for (double x = spacing; x < size.width; x += spacing) {
|
||||||
|
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paintCornell(Canvas canvas, Size size, double spacing) {
|
||||||
|
// Ruled body lines.
|
||||||
|
_paintRuled(canvas, size, spacing);
|
||||||
|
final accent = Paint()
|
||||||
|
..color = _accentColor
|
||||||
|
..strokeWidth = 1.4;
|
||||||
|
// Left cue-column vertical line (~25% of width).
|
||||||
|
final cueX = size.width * 0.25;
|
||||||
|
// Bottom summary horizontal line (~80% down).
|
||||||
|
final summaryY = size.height * 0.80;
|
||||||
|
canvas.drawLine(Offset(cueX, 0), Offset(cueX, summaryY), accent);
|
||||||
|
canvas.drawLine(Offset(0, summaryY), Offset(size.width, summaryY), accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant NoteBackgroundPainter oldDelegate) =>
|
||||||
|
oldDelegate.background != background;
|
||||||
|
}
|
||||||
337
lib/editor/canvas/office_document_screen.dart
Normal file
337
lib/editor/canvas/office_document_screen.dart
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../../diagnostics/badnote_log.dart';
|
||||||
|
import '../../diagnostics/pen_event_ring.dart';
|
||||||
|
import '../../services/office/docx_parser.dart';
|
||||||
|
import '../../services/office/office_document.dart';
|
||||||
|
import '../../services/office/pptx_parser.dart';
|
||||||
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../ui/page_nav_shortcuts.dart';
|
||||||
|
|
||||||
|
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
|
||||||
|
class OfficeDocumentScreen extends StatefulWidget {
|
||||||
|
const OfficeDocumentScreen({
|
||||||
|
super.key,
|
||||||
|
required this.filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String filePath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
ParsedPptx? _pptx;
|
||||||
|
ParsedDocx? _docx;
|
||||||
|
int _pageIndex = 0;
|
||||||
|
final List<_InkStroke> _strokes = [];
|
||||||
|
_InkStroke? _live;
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
|
String get _sidecarPath => '${widget.filePath}.badnote.json';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_open();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _open() async {
|
||||||
|
final ext = p.extension(widget.filePath).toLowerCase();
|
||||||
|
try {
|
||||||
|
if (ext == '.pptx' || ext == '.ppt') {
|
||||||
|
_pptx = await PptxParser().parse(widget.filePath);
|
||||||
|
} else if (ext == '.docx') {
|
||||||
|
_docx = await DocxParser().parse(widget.filePath);
|
||||||
|
} else {
|
||||||
|
throw StateError('Unsupported: $ext');
|
||||||
|
}
|
||||||
|
await _loadSidecar();
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
|
||||||
|
'path': widget.filePath,
|
||||||
|
'pages': pageCount,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
_error = '$e';
|
||||||
|
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
|
||||||
|
'error': '$e',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get pageCount {
|
||||||
|
if (_pptx != null) return _pptx!.slides.length;
|
||||||
|
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSidecar() async {
|
||||||
|
final f = File(_sidecarPath);
|
||||||
|
if (!await f.exists()) return;
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||||
|
final pages = json['pages'] as Map<String, dynamic>? ?? {};
|
||||||
|
final key = '$_pageIndex';
|
||||||
|
final list = pages[key] as List<dynamic>? ?? [];
|
||||||
|
_strokes
|
||||||
|
..clear()
|
||||||
|
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveSidecar() async {
|
||||||
|
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
|
||||||
|
final f = File(_sidecarPath);
|
||||||
|
if (await f.exists()) {
|
||||||
|
try {
|
||||||
|
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
|
||||||
|
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
|
||||||
|
root['pages'] = pages;
|
||||||
|
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _goPage(int i) async {
|
||||||
|
await _saveSidecar();
|
||||||
|
setState(() {
|
||||||
|
_pageIndex = i.clamp(0, pageCount - 1);
|
||||||
|
_strokes.clear();
|
||||||
|
_live = null;
|
||||||
|
});
|
||||||
|
await _loadSidecar();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent e) {
|
||||||
|
if (e.kind != ui.PointerDeviceKind.stylus &&
|
||||||
|
e.kind != ui.PointerDeviceKind.invertedStylus &&
|
||||||
|
e.kind != ui.PointerDeviceKind.mouse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final local = _toScene(e.localPosition);
|
||||||
|
_live = _InkStroke(points: [local], pressures: [e.pressure]);
|
||||||
|
PenEventRing.instance.recordPointer(
|
||||||
|
kind: 'down',
|
||||||
|
pointerId: e.pointer,
|
||||||
|
deviceKind: e.kind.name,
|
||||||
|
pressure: e.pressure,
|
||||||
|
decision: 'draw',
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(PointerMoveEvent e) {
|
||||||
|
final live = _live;
|
||||||
|
if (live == null) return;
|
||||||
|
live.points.add(_toScene(e.localPosition));
|
||||||
|
live.pressures.add(e.pressure);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
|
final live = _live;
|
||||||
|
if (live == null) return;
|
||||||
|
setState(() {
|
||||||
|
_strokes.add(live);
|
||||||
|
_live = null;
|
||||||
|
});
|
||||||
|
unawaited(_saveSidecar());
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _toScene(Offset local) {
|
||||||
|
final inv = Matrix4.inverted(_transform.value);
|
||||||
|
return MatrixUtils.transformPoint(inv, local);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(p.basename(widget.filePath))),
|
||||||
|
body: Center(child: Text(_error!)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNavShortcuts(
|
||||||
|
onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
||||||
|
onNext:
|
||||||
|
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
|
||||||
|
onFirst: pageCount > 0 ? () => _goPage(0) : null,
|
||||||
|
onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(p.basename(widget.filePath)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
),
|
||||||
|
Center(child: Text('${_pageIndex + 1} / $pageCount')),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _pageIndex < pageCount - 1
|
||||||
|
? () => _goPage(_pageIndex + 1)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: InteractiveViewer(
|
||||||
|
transformationController: _transform,
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 4,
|
||||||
|
child: Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _OfficePagePainter(
|
||||||
|
pptx: _pptx,
|
||||||
|
docx: _docx,
|
||||||
|
pageIndex: _pageIndex,
|
||||||
|
strokes: _strokes,
|
||||||
|
live: _live,
|
||||||
|
),
|
||||||
|
size: _pageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Size get _pageSize {
|
||||||
|
if (_pptx != null && _pptx!.slides.isNotEmpty) {
|
||||||
|
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
|
||||||
|
return Size(s.width, s.height);
|
||||||
|
}
|
||||||
|
return const Size(800, 1100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InkStroke {
|
||||||
|
_InkStroke({required this.points, required this.pressures});
|
||||||
|
|
||||||
|
final List<Offset> points;
|
||||||
|
final List<double> pressures;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'points': [
|
||||||
|
for (final p in points) {'x': p.dx, 'y': p.dy},
|
||||||
|
],
|
||||||
|
'pressures': pressures,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory _InkStroke.fromJson(Map<String, dynamic> json) {
|
||||||
|
final pts = (json['points'] as List<dynamic>)
|
||||||
|
.map((e) => Offset(
|
||||||
|
(e['x'] as num).toDouble(),
|
||||||
|
(e['y'] as num).toDouble(),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
final pr = (json['pressures'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toDouble())
|
||||||
|
.toList() ??
|
||||||
|
List.filled(pts.length, 0.5);
|
||||||
|
return _InkStroke(points: pts, pressures: pr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfficePagePainter extends CustomPainter {
|
||||||
|
_OfficePagePainter({
|
||||||
|
required this.pptx,
|
||||||
|
required this.docx,
|
||||||
|
required this.pageIndex,
|
||||||
|
required this.strokes,
|
||||||
|
required this.live,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ParsedPptx? pptx;
|
||||||
|
final ParsedDocx? docx;
|
||||||
|
final int pageIndex;
|
||||||
|
final List<_InkStroke> strokes;
|
||||||
|
final _InkStroke? live;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final bg = Paint()..color = AppTokens.paper;
|
||||||
|
canvas.drawRect(Offset.zero & size, bg);
|
||||||
|
|
||||||
|
if (pptx != null && pptx!.slides.isNotEmpty) {
|
||||||
|
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
|
||||||
|
final border = Paint()
|
||||||
|
..color = AppTokens.rule
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
|
||||||
|
for (final run in slide.runs) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: run.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTokens.ink,
|
||||||
|
fontSize: run.fontSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
|
||||||
|
tp.paint(canvas, Offset(run.x, run.y));
|
||||||
|
}
|
||||||
|
} else if (docx != null) {
|
||||||
|
final start = pageIndex * 12;
|
||||||
|
final blocks = docx!.blocks.skip(start).take(12).toList();
|
||||||
|
var y = 48.0;
|
||||||
|
for (final b in blocks) {
|
||||||
|
final style = TextStyle(
|
||||||
|
color: AppTokens.ink,
|
||||||
|
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
|
||||||
|
fontWeight:
|
||||||
|
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
|
||||||
|
);
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(text: b.text, style: style),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout(maxWidth: size.width - 96);
|
||||||
|
tp.paint(canvas, Offset(48, y));
|
||||||
|
y += tp.height + 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ink = Paint()
|
||||||
|
..color = AppTokens.copper
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.2
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round;
|
||||||
|
for (final s in [...strokes, if (live != null) live!]) {
|
||||||
|
if (s.points.length < 2) continue;
|
||||||
|
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
|
||||||
|
for (var i = 1; i < s.points.length; i++) {
|
||||||
|
path.lineTo(s.points[i].dx, s.points[i].dy);
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, ink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
|
||||||
|
}
|
||||||
@@ -21,22 +21,49 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../engine/pen_physics.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
import '../engine/stroke_model.dart';
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../engine/stroke_predictor.dart';
|
||||||
import '../engine/stroke_store.dart';
|
import '../engine/stroke_store.dart';
|
||||||
import '../input/input_arbiter.dart' as arbiter;
|
import '../input/input_arbiter.dart' as arbiter;
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pressure_curve.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../../diagnostics/pen_event_ring.dart';
|
||||||
|
import '../engine/shape_geometry.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
import '../render/ink_picture_cache.dart';
|
import '../render/ink_picture_cache.dart';
|
||||||
import '../render/live_ink_painter.dart' as render;
|
import '../render/live_ink_painter.dart' as render;
|
||||||
import '../render/static_ink_painter.dart' as render;
|
import '../render/static_ink_painter.dart' as render;
|
||||||
import 'ink_painters.dart' show EraserPreviewPainter;
|
import 'editor_tool.dart';
|
||||||
|
import 'ink_painters.dart' show EraserPreviewPainter, SelectionOverlayPainter;
|
||||||
import 'pen_interactive_viewer.dart';
|
import 'pen_interactive_viewer.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
/// The active tool on the pen canvas.
|
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
|
||||||
enum CanvasTool { pen, highlighter, eraser }
|
/// triad; [select] and [shape] are the core-writing-batch additions. This mirrors
|
||||||
|
/// the shared [EditorToolKind] (the PDF editor uses that enum directly); the
|
||||||
|
/// PenCanvas keeps its own enum because it predates the shared model and is wired
|
||||||
|
/// through many call sites — see [editorToolToCanvas].
|
||||||
|
enum CanvasTool { pen, highlighter, eraser, select, shape }
|
||||||
|
|
||||||
|
/// Map the shared [EditorToolKind] to the PenCanvas's [CanvasTool] so the note/
|
||||||
|
/// slide editors can drive PenCanvas from the shared active-tool state.
|
||||||
|
CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
|
||||||
|
EditorToolKind.brush => CanvasTool.pen,
|
||||||
|
EditorToolKind.highlighter => CanvasTool.highlighter,
|
||||||
|
EditorToolKind.eraser => CanvasTool.eraser,
|
||||||
|
EditorToolKind.select => CanvasTool.select,
|
||||||
|
EditorToolKind.shape => CanvasTool.shape,
|
||||||
|
// The TEXT tool is PDF-editor-only for now (note/slide typed text is a
|
||||||
|
// later increment); the note palette has no text button, so this mapping
|
||||||
|
// is unreachable in practice — fall back to the pen so the switch stays
|
||||||
|
// exhaustive without inventing a PenCanvas typed-text path.
|
||||||
|
EditorToolKind.text => CanvasTool.pen,
|
||||||
|
};
|
||||||
|
|
||||||
class PenCanvas extends StatefulWidget {
|
class PenCanvas extends StatefulWidget {
|
||||||
const PenCanvas({
|
const PenCanvas({
|
||||||
@@ -46,15 +73,26 @@ class PenCanvas extends StatefulWidget {
|
|||||||
required this.strokes,
|
required this.strokes,
|
||||||
required this.transformationController,
|
required this.transformationController,
|
||||||
required this.tool,
|
required this.tool,
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
|
this.shapeKind = ShapeKind.line,
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.strokeWidth,
|
required this.strokeWidth,
|
||||||
required this.onStrokeComplete,
|
required this.onStrokeComplete,
|
||||||
required this.onEraseStroke,
|
required this.onEraseStroke,
|
||||||
|
this.selectedStrokeIndex,
|
||||||
|
this.onSelectStroke,
|
||||||
|
this.onMoveStroke,
|
||||||
this.allowFingerDrawing = false,
|
this.allowFingerDrawing = false,
|
||||||
this.minScale = 0.5,
|
this.minScale = 0.5,
|
||||||
this.maxScale = 8.0,
|
this.maxScale = 8.0,
|
||||||
|
this.scaleEnabled = true,
|
||||||
|
this.panEnabled = true,
|
||||||
this.onPenDebug,
|
this.onPenDebug,
|
||||||
this.thinning = kDefaultPenThinning,
|
this.thinning = kDefaultPenThinning,
|
||||||
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
|
this.pressureFloor = kNaturalPressureFloor,
|
||||||
|
this.eraserRadius = kDefaultEraserRadius,
|
||||||
|
this.eraserWholeStroke = false,
|
||||||
this.sideButtonAction = PenButtonAction.eraser,
|
this.sideButtonAction = PenButtonAction.eraser,
|
||||||
this.eraserEndAction = PenButtonAction.eraser,
|
this.eraserEndAction = PenButtonAction.eraser,
|
||||||
this.onPenButtonAction,
|
this.onPenButtonAction,
|
||||||
@@ -79,6 +117,17 @@ class PenCanvas extends StatefulWidget {
|
|||||||
final TransformationController transformationController;
|
final TransformationController transformationController;
|
||||||
|
|
||||||
final CanvasTool tool;
|
final CanvasTool tool;
|
||||||
|
|
||||||
|
/// The brush selected for the PEN tool (fountain/ballpoint/pencil). The
|
||||||
|
/// highlighter tool always renders with [BrushKind.highlighter] regardless of
|
||||||
|
/// this value; the eraser draws nothing. Drives both the capture-time pressure
|
||||||
|
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
|
||||||
|
final BrushKind brush;
|
||||||
|
|
||||||
|
/// The shape to draw when [tool] is [CanvasTool.shape]. Generated as a
|
||||||
|
/// PenStroke via [generateShapePoints] (no new model).
|
||||||
|
final ShapeKind shapeKind;
|
||||||
|
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
/// Pen width as a fraction of page width (so it zooms with the page).
|
/// Pen width as a fraction of page width (so it zooms with the page).
|
||||||
@@ -93,6 +142,22 @@ class PenCanvas extends StatefulWidget {
|
|||||||
final void Function(int strokeIndex, List<PenStroke> replacements)
|
final void Function(int strokeIndex, List<PenStroke> replacements)
|
||||||
onEraseStroke;
|
onEraseStroke;
|
||||||
|
|
||||||
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||||
|
/// Drives the selection bounding-box overlay.
|
||||||
|
final int? selectedStrokeIndex;
|
||||||
|
|
||||||
|
/// Called when the SELECT tool taps a committed stroke (its index), or null
|
||||||
|
/// when the tap hits empty space (clears the selection).
|
||||||
|
final ValueChanged<int?>? onSelectStroke;
|
||||||
|
|
||||||
|
/// Called when the SELECT tool drags the selected stroke: ([strokeIndex],
|
||||||
|
/// [dx],[dy]) is the normalized translation to apply, and [isDragStart] is true
|
||||||
|
/// on the FIRST delta of a drag so the parent records ONE undo snapshot per
|
||||||
|
/// drag (not per pixel). The parent translates + persists (see
|
||||||
|
/// `translateStroke`).
|
||||||
|
final void Function(int strokeIndex, double dx, double dy, bool isDragStart)?
|
||||||
|
onMoveStroke;
|
||||||
|
|
||||||
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
||||||
/// seen (palm rejection).
|
/// seen (palm rejection).
|
||||||
final bool allowFingerDrawing;
|
final bool allowFingerDrawing;
|
||||||
@@ -100,9 +165,40 @@ class PenCanvas extends StatefulWidget {
|
|||||||
final double minScale;
|
final double minScale;
|
||||||
final double maxScale;
|
final double maxScale;
|
||||||
|
|
||||||
|
/// When false, the canvas cannot be pinch-zoomed (sticky notes lock this so
|
||||||
|
/// writing isn't fighting an inner transform).
|
||||||
|
final bool scaleEnabled;
|
||||||
|
|
||||||
|
/// When false, one-finger pan is disabled (sticky notes often lock pan too).
|
||||||
|
final bool panEnabled;
|
||||||
|
|
||||||
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
||||||
final double thinning;
|
final double thinning;
|
||||||
|
|
||||||
|
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
|
||||||
|
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
|
||||||
|
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
|
||||||
|
///
|
||||||
|
/// TODO(brush-pressure-knob): superseded by the per-brush
|
||||||
|
/// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the
|
||||||
|
/// capture-time warp. This config knob is retained for the API + future
|
||||||
|
/// reconciliation (e.g. a user multiplier on top of the brush curve) but is no
|
||||||
|
/// longer read by [_normalizedPressure].
|
||||||
|
final double pressureGamma;
|
||||||
|
|
||||||
|
/// Minimum shaped pressure, so a light stroke still has body instead of
|
||||||
|
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
|
||||||
|
final double pressureFloor;
|
||||||
|
|
||||||
|
/// Eraser radius as a fraction of page width (live hit area + cursor size).
|
||||||
|
/// From `PenConfig.eraserRadius`.
|
||||||
|
final double eraserRadius;
|
||||||
|
|
||||||
|
/// When true the eraser removes a whole stroke on contact (OneNote-style);
|
||||||
|
/// when false it does a partial / segment erase. From
|
||||||
|
/// `PenConfig.eraserWholeStroke`.
|
||||||
|
final bool eraserWholeStroke;
|
||||||
|
|
||||||
/// Configured action for the pen's side barrel button (W3 — resolved against
|
/// Configured action for the pen's side barrel button (W3 — resolved against
|
||||||
/// the native pen plugin's flags on Windows).
|
/// the native pen plugin's flags on Windows).
|
||||||
final PenButtonAction sideButtonAction;
|
final PenButtonAction sideButtonAction;
|
||||||
@@ -127,10 +223,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
/// In-progress stroke points (normalized).
|
/// In-progress stroke points (normalized).
|
||||||
final List<PenPoint> _livePoints = [];
|
final List<PenPoint> _livePoints = [];
|
||||||
|
final StrokePredictor _predictor = StrokePredictor();
|
||||||
|
/// Count of real (non-predicted) points in [_livePoints].
|
||||||
|
int _realPointCount = 0;
|
||||||
|
|
||||||
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
|
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
|
||||||
PenStroke? _liveStroke;
|
PenStroke? _liveStroke;
|
||||||
|
|
||||||
|
/// SHAPE tool: the normalized start point of the in-progress shape, or null.
|
||||||
|
PenPoint? _shapeStart;
|
||||||
|
|
||||||
|
/// SELECT tool: the last normalized drag position, used to compute the
|
||||||
|
/// incremental translation reported to [PenCanvas.onMoveStroke].
|
||||||
|
PenPoint? _selectLast;
|
||||||
|
|
||||||
|
/// SELECT tool: true once a drag of the selected stroke has begun (so the move
|
||||||
|
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
|
||||||
|
bool _selectDragging = false;
|
||||||
|
|
||||||
|
/// Tip-velocity tracker for [tipVelocityWidthScale] (physical ink starvation).
|
||||||
|
Offset? _lastTipNorm;
|
||||||
|
Duration? _lastTipTime;
|
||||||
|
|
||||||
/// True when the active stylus reports the eraser signal (barrel button or
|
/// True when the active stylus reports the eraser signal (barrel button or
|
||||||
/// inverted stylus), detected on hover/down.
|
/// inverted stylus), detected on hover/down.
|
||||||
bool _eraserActive = false;
|
bool _eraserActive = false;
|
||||||
@@ -168,17 +282,21 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
bool get _isEraserMode =>
|
bool get _isEraserMode =>
|
||||||
widget.tool == CanvasTool.eraser || _eraserActive;
|
widget.tool == CanvasTool.eraser || _eraserActive;
|
||||||
|
|
||||||
/// Eraser radius as a fraction of page width (shared by the live erase and the
|
|
||||||
/// preview overlay so they always agree). A decisive fixed size — the old
|
|
||||||
/// strokeWidth*2 was so small that a pass removed only a couple of points and
|
|
||||||
/// the stroke visibly survived ("选中了的笔画也不见得能删掉").
|
|
||||||
static const double _eraserRadius = 0.02;
|
|
||||||
|
|
||||||
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
/// Page aspect (height / width) so the eraser circle stays round on screen.
|
||||||
double get _pageAspect => widget.pageSize.width <= 0
|
double get _pageAspect => widget.pageSize.width <= 0
|
||||||
? 1.0
|
? 1.0
|
||||||
: widget.pageSize.height / widget.pageSize.width;
|
: widget.pageSize.height / widget.pageSize.width;
|
||||||
|
|
||||||
|
/// Normalized bounding box of the currently selected stroke (SELECT tool), or
|
||||||
|
/// null when nothing valid is selected.
|
||||||
|
Rect? get _selectionBounds {
|
||||||
|
final idx = widget.selectedStrokeIndex;
|
||||||
|
if (idx == null || idx < 0 || idx >= widget.strokes.length) return null;
|
||||||
|
final b = penStrokeBounds(widget.strokes[idx]);
|
||||||
|
if (b == null) return null;
|
||||||
|
return Rect.fromLTRB(b.left, b.top, b.right, b.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
// The explicit user toggle wins: if finger-drawing is ON, a single finger
|
// The explicit user toggle wins: if finger-drawing is ON, a single finger
|
||||||
// draws even after a stylus has been seen. (Palm rejection when the toggle is
|
// draws even after a stylus has been seen. (Palm rejection when the toggle is
|
||||||
// OFF is automatic — fingers simply never draw — and a 2nd pointer always
|
// OFF is automatic — fingers simply never draw — and a 2nd pointer always
|
||||||
@@ -187,10 +305,49 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
||||||
|
|
||||||
|
/// The brush in effect for the current tool: highlighter tool ⇒ highlighter
|
||||||
|
/// brush, otherwise the selected pen brush. (Eraser draws nothing, so its
|
||||||
|
/// brush is irrelevant.)
|
||||||
|
BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter
|
||||||
|
? BrushKind.highlighter
|
||||||
|
: widget.brush;
|
||||||
|
|
||||||
|
/// The brush profile in effect, for the capture-time pressure pre-warp.
|
||||||
|
BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush);
|
||||||
|
|
||||||
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
||||||
/// usable pressure range (then perfect_freehand simulates pressure).
|
/// usable pressure range (then perfect_freehand simulates pressure).
|
||||||
|
///
|
||||||
|
/// The raw normalized force is then shaped by the pressure-response curve
|
||||||
|
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
|
||||||
|
/// — and because the shaping happens at capture, the live stroke and the PDF
|
||||||
|
/// export replay identical pressures (no divergence).
|
||||||
double? _normalizedPressure(PointerEvent event) {
|
double? _normalizedPressure(PointerEvent event) {
|
||||||
if (!_isStylus(event.kind)) return null;
|
if (!_isStylus(event.kind)) return null;
|
||||||
|
final double? raw = _rawNormalizedPressure(event);
|
||||||
|
if (raw == null) return null;
|
||||||
|
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
|
||||||
|
// = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the
|
||||||
|
// existing PressureCurve. Baking the warp in at capture means the live
|
||||||
|
// stroke and the export replay identical pressures (no divergence). The
|
||||||
|
// brush gamma supersedes the legacy per-config `pressureGamma` knob — see
|
||||||
|
// TODO(brush-pressure-knob) on `widget.pressureGamma`.
|
||||||
|
return PressureCurve(
|
||||||
|
floor: widget.pressureFloor,
|
||||||
|
gamma: _currentBrushProfile.pressureGamma,
|
||||||
|
).apply(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
|
||||||
|
///
|
||||||
|
/// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's
|
||||||
|
/// PointerEvent.pressure on Windows is often flat/useless while the driver
|
||||||
|
/// still reports real 0..1024 via GetPointerPenInfo.
|
||||||
|
double? _rawNormalizedPressure(PointerEvent event) {
|
||||||
|
final hw = PenInputService.instance;
|
||||||
|
if (hw.isActive && hw.current.pressureValid) {
|
||||||
|
return hw.current.pressure.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
final range = event.pressureMax - event.pressureMin;
|
final range = event.pressureMax - event.pressureMin;
|
||||||
if (range > 0.0001) {
|
if (range > 0.0001) {
|
||||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||||
@@ -257,7 +414,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
if (action == _lastHwAction) return;
|
if (action == _lastHwAction) return;
|
||||||
_lastHwAction = action;
|
_lastHwAction = action;
|
||||||
if (action == PenButtonAction.undo ||
|
if (action == PenButtonAction.undo ||
|
||||||
action == PenButtonAction.toggleTool) {
|
action == PenButtonAction.toggleTool ||
|
||||||
|
action == PenButtonAction.select) {
|
||||||
widget.onPenButtonAction?.call(action);
|
widget.onPenButtonAction?.call(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,19 +436,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
||||||
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
||||||
/// the tests can never disagree on the rule.
|
/// the tests can never disagree on the rule.
|
||||||
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
|
bool _shouldDraw(PointerDeviceKind kind) {
|
||||||
activePointerCount: _activePointers.length,
|
final draw = arbiter.shouldDraw(
|
||||||
kind: kind,
|
activePointerCount: _activePointers.length,
|
||||||
fingerDrawingEnabled: _fingerDrawingEnabled,
|
kind: kind,
|
||||||
hwPanActive: _hwPanActive,
|
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||||
);
|
hwPanActive: _hwPanActive,
|
||||||
|
);
|
||||||
|
PenEventRing.instance.recordArbiter(
|
||||||
|
activeCount: _activePointers.length,
|
||||||
|
deviceKind: kind.name,
|
||||||
|
draw: draw,
|
||||||
|
fingerDrawing: _fingerDrawingEnabled,
|
||||||
|
);
|
||||||
|
return draw;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Coordinate mapping ---------------------------------------------------
|
// --- Coordinate mapping ---------------------------------------------------
|
||||||
|
|
||||||
/// Map a global pointer position into normalized page coords using the
|
/// Map a global pointer position into normalized page coords using the
|
||||||
/// shared transform (inverse) and this widget's geometry.
|
/// shared transform (inverse) and this widget's geometry.
|
||||||
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
||||||
{double? tilt}) {
|
{double? tilt, Duration? timeStamp}) {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
if (box == null) return null;
|
if (box == null) return null;
|
||||||
final local = box.globalToLocal(globalPosition);
|
final local = box.globalToLocal(globalPosition);
|
||||||
@@ -300,7 +467,23 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
|
|
||||||
final nx = scene.dx / widget.pageSize.width;
|
final nx = scene.dx / widget.pageSize.width;
|
||||||
final ny = scene.dy / widget.pageSize.height;
|
final ny = scene.dy / widget.pageSize.height;
|
||||||
return PenPoint(nx, ny, pressure, tilt: tilt);
|
|
||||||
|
double? shaped = pressure;
|
||||||
|
if (shaped != null && timeStamp != null && _lastTipNorm != null &&
|
||||||
|
_lastTipTime != null) {
|
||||||
|
final dt = (timeStamp - _lastTipTime!).inMicroseconds / 1e6;
|
||||||
|
if (dt > 0) {
|
||||||
|
final dx = nx - _lastTipNorm!.dx;
|
||||||
|
final dy = ny - _lastTipNorm!.dy;
|
||||||
|
final speed = math.sqrt(dx * dx + dy * dy) / dt;
|
||||||
|
shaped = (shaped * tipVelocityWidthScale(_currentBrush, speed))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_lastTipNorm = Offset(nx, ny);
|
||||||
|
_lastTipTime = timeStamp;
|
||||||
|
|
||||||
|
return PenPoint(nx, ny, shaped, tilt: tilt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stroke lifecycle -----------------------------------------------------
|
// --- Stroke lifecycle -----------------------------------------------------
|
||||||
@@ -308,9 +491,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
void _startStroke(PointerDownEvent event) {
|
void _startStroke(PointerDownEvent event) {
|
||||||
_drawPointer = event.pointer;
|
_drawPointer = event.pointer;
|
||||||
_livePoints.clear();
|
_livePoints.clear();
|
||||||
|
_realPointCount = 0;
|
||||||
|
_predictor.reset();
|
||||||
|
_shapeStart = null;
|
||||||
|
_selectLast = null;
|
||||||
|
_selectDragging = false;
|
||||||
|
_lastTipNorm = null;
|
||||||
|
_lastTipTime = null;
|
||||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||||
tilt: _tiltFor(event));
|
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||||
if (p != null) _livePoints.add(p);
|
|
||||||
|
|
||||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||||
_eraserCursor.value = p;
|
_eraserCursor.value = p;
|
||||||
@@ -321,12 +510,33 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
if (_liveStroke != null) setState(() => _liveStroke = null);
|
if (_liveStroke != null) setState(() => _liveStroke = null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SELECT: tap hit-tests the committed strokes (topmost first) and reports
|
||||||
|
// the selection. A subsequent drag translates it (see _extendStroke).
|
||||||
|
if (widget.tool == CanvasTool.select) {
|
||||||
|
if (p != null) {
|
||||||
|
_selectLast = p;
|
||||||
|
widget.onSelectStroke?.call(_hitTestStroke(p));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHAPE: record the start point; the preview shape is built on each move.
|
||||||
|
if (widget.tool == CanvasTool.shape) {
|
||||||
|
_shapeStart = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p != null) {
|
||||||
|
_livePoints.add(p);
|
||||||
|
_realPointCount = _livePoints.length;
|
||||||
|
}
|
||||||
_updateLiveStroke();
|
_updateLiveStroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _extendStroke(PointerMoveEvent event) {
|
void _extendStroke(PointerMoveEvent event) {
|
||||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||||
tilt: _tiltFor(event));
|
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
|
|
||||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||||
@@ -334,29 +544,128 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
_eraseAt(p);
|
_eraseAt(p);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SELECT drag: translate the selected stroke by the incremental delta.
|
||||||
|
if (widget.tool == CanvasTool.select) {
|
||||||
|
final last = _selectLast;
|
||||||
|
final idx = widget.selectedStrokeIndex;
|
||||||
|
if (last != null && idx != null) {
|
||||||
|
final dx = p.x - last.x;
|
||||||
|
final dy = p.y - last.y;
|
||||||
|
if (dx != 0 || dy != 0) {
|
||||||
|
final isStart = !_selectDragging;
|
||||||
|
_selectDragging = true;
|
||||||
|
widget.onMoveStroke?.call(idx, dx, dy, isStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selectLast = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHAPE preview: regenerate the shape from start→current on every move.
|
||||||
|
if (widget.tool == CanvasTool.shape) {
|
||||||
|
_updateShapePreview(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop previous predicted tip before appending the real sample.
|
||||||
|
if (_livePoints.length > _realPointCount) {
|
||||||
|
_livePoints.removeRange(_realPointCount, _livePoints.length);
|
||||||
|
}
|
||||||
_livePoints.add(p);
|
_livePoints.add(p);
|
||||||
|
_realPointCount = _livePoints.length;
|
||||||
|
final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5);
|
||||||
|
if (pred != null) {
|
||||||
|
_livePoints.add(PenPoint(
|
||||||
|
pred.offset.dx.clamp(0.0, 1.0),
|
||||||
|
pred.offset.dy.clamp(0.0, 1.0),
|
||||||
|
pred.pressure,
|
||||||
|
tilt: p.tilt,
|
||||||
|
));
|
||||||
|
}
|
||||||
_updateLiveStroke();
|
_updateLiveStroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endStroke() {
|
void _endStroke() {
|
||||||
if (_drawPointer == null) return;
|
if (_drawPointer == null) return;
|
||||||
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
|
final tool = widget.tool;
|
||||||
if (!wasEraser && _livePoints.isNotEmpty) {
|
final wasEraser = _eraserActive || tool == CanvasTool.eraser;
|
||||||
|
|
||||||
|
if (tool == CanvasTool.shape) {
|
||||||
|
// Commit the generated shape stroke (if the drag spanned any distance).
|
||||||
|
final start = _shapeStart;
|
||||||
|
final end = _livePoints.isNotEmpty ? _livePoints.last : null;
|
||||||
|
if (start != null && end != null) {
|
||||||
|
final pts = generateShapePoints(widget.shapeKind, start, end);
|
||||||
|
widget.onStrokeComplete(PenStroke(
|
||||||
|
points: pts,
|
||||||
|
color: _currentColor().toARGB32(),
|
||||||
|
width: widget.strokeWidth,
|
||||||
|
kind: PenStrokeKind.pen,
|
||||||
|
brush: kShapeBrush,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if (tool == CanvasTool.select) {
|
||||||
|
// Nothing to commit on release: selection + moves were applied live.
|
||||||
|
} else if (!wasEraser && _livePoints.isNotEmpty) {
|
||||||
|
// Never commit predicted tips — only real digitizer samples.
|
||||||
|
if (_livePoints.length > _realPointCount) {
|
||||||
|
_livePoints.removeRange(_realPointCount, _livePoints.length);
|
||||||
|
}
|
||||||
widget.onStrokeComplete(
|
widget.onStrokeComplete(
|
||||||
PenStroke(
|
PenStroke(
|
||||||
points: List.of(_livePoints),
|
points: List.of(_livePoints),
|
||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_drawPointer = null;
|
_drawPointer = null;
|
||||||
|
_shapeStart = null;
|
||||||
|
_selectLast = null;
|
||||||
|
_selectDragging = false;
|
||||||
_livePoints.clear();
|
_livePoints.clear();
|
||||||
|
_realPointCount = 0;
|
||||||
|
_predictor.reset();
|
||||||
_eraserCursor.value = null; // hide the preview when the pen lifts
|
_eraserCursor.value = null; // hide the preview when the pen lifts
|
||||||
setState(() => _liveStroke = null);
|
setState(() => _liveStroke = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hit-test committed strokes (topmost first) at normalized [p]; returns the
|
||||||
|
/// index of the first stroke within the eraser radius, or null. Reuses
|
||||||
|
/// [strokeHit] so tap-select matches the eraser's proximity model.
|
||||||
|
int? _hitTestStroke(PenPoint p) {
|
||||||
|
final radius = widget.eraserRadius;
|
||||||
|
final aspect = _pageAspect;
|
||||||
|
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||||
|
if (strokeHit(widget.strokes[i], p.x, p.y, radius, aspect: aspect)) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SHAPE preview stroke from the recorded start to the current [p].
|
||||||
|
void _updateShapePreview(PenPoint p) {
|
||||||
|
final start = _shapeStart;
|
||||||
|
if (start == null) return;
|
||||||
|
_livePoints
|
||||||
|
..clear()
|
||||||
|
..add(p); // remember the latest end point for commit
|
||||||
|
final pts = generateShapePoints(widget.shapeKind, start, p);
|
||||||
|
setState(() {
|
||||||
|
_liveStroke = PenStroke(
|
||||||
|
points: pts,
|
||||||
|
color: _currentColor().toARGB32(),
|
||||||
|
width: widget.strokeWidth,
|
||||||
|
kind: PenStrokeKind.pen,
|
||||||
|
brush: kShapeBrush,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Discard the in-progress stroke without committing (palm/2nd-finger).
|
/// Discard the in-progress stroke without committing (palm/2nd-finger).
|
||||||
void _cancelStroke() {
|
void _cancelStroke() {
|
||||||
_drawPointer = null;
|
_drawPointer = null;
|
||||||
@@ -372,6 +681,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
color: _currentColor().toARGB32(),
|
color: _currentColor().toARGB32(),
|
||||||
width: widget.strokeWidth,
|
width: widget.strokeWidth,
|
||||||
kind: _currentKind(),
|
kind: _currentKind(),
|
||||||
|
brush: _currentBrush,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -391,13 +701,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// stays round on screen (the page rect is not square).
|
/// stays round on screen (the page rect is not square).
|
||||||
void _eraseAt(PenPoint? p) {
|
void _eraseAt(PenPoint? p) {
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
final radius = _eraserRadius; // normalized (page-width fraction)
|
final radius = widget.eraserRadius; // normalized (page-width fraction)
|
||||||
final aspect = _pageAspect;
|
final aspect = _pageAspect;
|
||||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||||
final stroke = widget.strokes[i];
|
final stroke = widget.strokes[i];
|
||||||
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||||
final pieces =
|
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
|
||||||
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
// Point-eraser mode (default): cut out the touched span, keep the rest.
|
||||||
|
final pieces = widget.eraserWholeStroke
|
||||||
|
? const <PenStroke>[]
|
||||||
|
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
||||||
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
|
||||||
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
||||||
widget.onEraseStroke(i, pieces);
|
widget.onEraseStroke(i, pieces);
|
||||||
@@ -500,7 +813,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
||||||
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
||||||
// so a pinch re-enables pan/zoom immediately.
|
// so a pinch re-enables pan/zoom immediately.
|
||||||
final panEnabled = _drawPointer == null;
|
final panEnabled = widget.panEnabled && _drawPointer == null;
|
||||||
|
|
||||||
// Mirror committed strokes into the revision-tracked store (only re-mirrors
|
// Mirror committed strokes into the revision-tracked store (only re-mirrors
|
||||||
// when the parent handed us a new list identity).
|
// when the parent handed us a new list identity).
|
||||||
@@ -519,7 +832,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
minScale: widget.minScale,
|
minScale: widget.minScale,
|
||||||
maxScale: widget.maxScale,
|
maxScale: widget.maxScale,
|
||||||
panEnabled: panEnabled,
|
panEnabled: panEnabled,
|
||||||
scaleEnabled: true,
|
scaleEnabled: widget.scaleEnabled,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: widget.pageSize.width,
|
width: widget.pageSize.width,
|
||||||
height: widget.pageSize.height,
|
height: widget.pageSize.height,
|
||||||
@@ -559,14 +872,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
painter: EraserPreviewPainter(
|
painter: EraserPreviewPainter(
|
||||||
strokes: widget.strokes,
|
strokes: widget.strokes,
|
||||||
cursor: _eraserCursor,
|
cursor: _eraserCursor,
|
||||||
radius: _eraserRadius,
|
radius: widget.eraserRadius,
|
||||||
aspect: _pageAspect,
|
aspect: _pageAspect,
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Live ink (current stroke only, isolated repaint).
|
// Live ink (current stroke only, isolated repaint). Also carries
|
||||||
|
// the SHAPE tool's preview (built as a live PenStroke).
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@@ -578,6 +892,18 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// SELECT tool: bounding box around the selected stroke.
|
||||||
|
if (widget.tool == CanvasTool.select && _selectionBounds != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: SelectionOverlayPainter(
|
||||||
|
boundsNorm: _selectionBounds,
|
||||||
|
pageSize: widget.pageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
|||||||
// because this canvas always uses an infinite boundary, free pan, and no
|
// because this canvas always uses an infinite boundary, free pan, and no
|
||||||
// rotation — so that code was provably a no-op here.
|
// rotation — so that code was provably a no-op here.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show clampDouble;
|
import 'package:flutter/foundation.dart' show clampDouble;
|
||||||
@@ -46,13 +47,13 @@ const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
|
|||||||
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
|
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
|
||||||
/// demanding far more than this is a Windows multi-touch position glitch, not
|
/// demanding far more than this is a Windows multi-touch position glitch, not
|
||||||
/// intent — that frame is dropped so the zoom can't pop and snap back.
|
/// intent — that frame is dropped so the zoom can't pop and snap back.
|
||||||
const double _kScaleGlitchHi = 1.4;
|
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
|
||||||
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
const double _kScaleGlitchHi = 1.18;
|
||||||
|
|
||||||
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
/// During a 2-finger gesture the focal point (finger midpoint) should move
|
||||||
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
|
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
|
||||||
/// and the frame is dropped (position-jump guard).
|
/// and the frame is dropped (position-jump guard).
|
||||||
const double _kFocalGlitchPx = 250.0;
|
const double _kFocalGlitchPx = 64.0;
|
||||||
|
|
||||||
const double _kDrag = 0.0000135;
|
const double _kDrag = 0.0000135;
|
||||||
|
|
||||||
@@ -106,15 +107,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
/// applying a frame whose scale/focal still refer to the old finger set.
|
/// applying a frame whose scale/focal still refer to the old finger set.
|
||||||
int _lastPointerCount = 0;
|
int _lastPointerCount = 0;
|
||||||
|
|
||||||
/// The recognizer's cumulative `details.scale` and the absolute scale we last
|
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
|
||||||
/// APPLIED, both as of the previous accepted frame. The pinch is driven
|
/// value; we never read the live matrix back into the per-frame scale change.
|
||||||
/// absolutely from these + the gesture-start snapshot — we never read the live
|
|
||||||
/// matrix back into the per-frame scale change. (Re-reading
|
|
||||||
/// `getMaxScaleOnAxis()` per frame was the flicker source: a single transient
|
|
||||||
/// mis-read/interleaved write made `desiredScale/liveScale` demand a ~1.3–1.4x
|
|
||||||
/// jump for one frame and snap back. The glitch guard missed it because the
|
|
||||||
/// spike sat just under the 1.4 threshold.)
|
|
||||||
double _lastRawScale = 1.0;
|
|
||||||
double _lastAppliedScale = 1.0;
|
double _lastAppliedScale = 1.0;
|
||||||
|
|
||||||
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
|
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
|
||||||
@@ -129,6 +123,12 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
/// (the reported flicker). Normalizing kills that pop at the source.
|
/// (the reported flicker). Normalizing kills that pop at the source.
|
||||||
double _rawScaleAtBaseline = 1.0;
|
double _rawScaleAtBaseline = 1.0;
|
||||||
|
|
||||||
|
/// Windows ScaleGestureRecognizer emits one onUpdate per finger move in the
|
||||||
|
/// same event-loop turn. Applying both mutates the matrix twice with an
|
||||||
|
/// intermediate state (Surface diag: √2-ish cur ping-pong). Keep latest only.
|
||||||
|
ScaleUpdateDetails? _pendingScaleUpdate;
|
||||||
|
bool _scaleFlushScheduled = false;
|
||||||
|
|
||||||
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
|
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
|
||||||
|
|
||||||
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
||||||
@@ -176,16 +176,38 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||||
_scaleAnimation = null;
|
_scaleAnimation = null;
|
||||||
}
|
}
|
||||||
|
_pendingScaleUpdate = null;
|
||||||
|
_scaleFlushScheduled = false;
|
||||||
_gestureType = null;
|
_gestureType = null;
|
||||||
_lastPointerCount = details.pointerCount;
|
_lastPointerCount = details.pointerCount;
|
||||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = 1.0;
|
|
||||||
_lastAppliedScale = _scaleStart!;
|
_lastAppliedScale = _scaleStart!;
|
||||||
_rawScaleAtBaseline = 1.0;
|
_rawScaleAtBaseline = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleUpdate(ScaleUpdateDetails details) {
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
||||||
|
// Pointer-count change must apply immediately (re-baseline), not coalesce.
|
||||||
|
if (details.pointerCount != _lastPointerCount) {
|
||||||
|
_pendingScaleUpdate = null;
|
||||||
|
_scaleFlushScheduled = false;
|
||||||
|
_applyScaleUpdate(details);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pendingScaleUpdate = details;
|
||||||
|
if (_scaleFlushScheduled) return;
|
||||||
|
_scaleFlushScheduled = true;
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
_scaleFlushScheduled = false;
|
||||||
|
final pending = _pendingScaleUpdate;
|
||||||
|
_pendingScaleUpdate = null;
|
||||||
|
if (pending != null && mounted && _scaleStart != null) {
|
||||||
|
_applyScaleUpdate(pending);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyScaleUpdate(ScaleUpdateDetails details) {
|
||||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||||
_scaleAnimationFocalPoint = details.localFocalPoint;
|
_scaleAnimationFocalPoint = details.localFocalPoint;
|
||||||
|
|
||||||
@@ -194,13 +216,19 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
// The transitional frame itself is skipped.
|
// The transitional frame itself is skipped.
|
||||||
if (details.pointerCount != _lastPointerCount) {
|
if (details.pointerCount != _lastPointerCount) {
|
||||||
_lastPointerCount = details.pointerCount;
|
_lastPointerCount = details.pointerCount;
|
||||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
|
||||||
|
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
|
||||||
|
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
|
||||||
|
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
|
||||||
|
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
|
||||||
|
// oscillated frame-to-frame (the reported "zoom jump"). Using
|
||||||
|
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
|
||||||
|
// re-baseline: target == _lastAppliedScale at this instant, regardless of
|
||||||
|
// any transient in the live matrix.
|
||||||
|
_scaleStart = _lastAppliedScale;
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
_lastRawScale = details.scale;
|
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
|
||||||
_lastAppliedScale = _scaleStart!;
|
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
|
||||||
// Re-anchor the absolute mapping: from here, cumulative scale is measured
|
|
||||||
// relative to THIS frame's details.scale (so the next good frame starts
|
|
||||||
// from _scaleStart, not _scaleStart * a stale cumulative value).
|
|
||||||
_rawScaleAtBaseline = details.scale;
|
_rawScaleAtBaseline = details.scale;
|
||||||
InputDiagnostics.instance.recordRebaseline();
|
InputDiagnostics.instance.recordRebaseline();
|
||||||
return;
|
return;
|
||||||
@@ -222,14 +250,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
final bool focalDrop =
|
final bool focalDrop =
|
||||||
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
|
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
|
||||||
|
|
||||||
void record(double appliedChange, bool scaleDrop, bool focalDropped) {
|
void record(
|
||||||
|
double currentScale,
|
||||||
|
double appliedChange,
|
||||||
|
bool softClamped,
|
||||||
|
bool focalDropped,
|
||||||
|
) {
|
||||||
InputDiagnostics.instance.recordScaleFrame(
|
InputDiagnostics.instance.recordScaleFrame(
|
||||||
rawScale: details.scale,
|
rawScale: details.scale,
|
||||||
pointerCount: details.pointerCount,
|
pointerCount: details.pointerCount,
|
||||||
currentScale: scale,
|
currentScale: currentScale,
|
||||||
appliedChange: appliedChange,
|
appliedChange: appliedChange,
|
||||||
focalJumpPx: focalJumpPx,
|
focalJumpPx: focalJumpPx,
|
||||||
scaleDrop: scaleDrop,
|
scaleDrop: false,
|
||||||
|
softClamped: softClamped,
|
||||||
focalDrop: focalDropped,
|
focalDrop: focalDropped,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,39 +271,32 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
switch (_gestureType!) {
|
switch (_gestureType!) {
|
||||||
case _GestureType.scale:
|
case _GestureType.scale:
|
||||||
assert(_scaleStart != null);
|
assert(_scaleStart != null);
|
||||||
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
|
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
|
||||||
// scale — the clean, monotonic signal (verified against device logs).
|
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
|
||||||
// Crucially we do NOT divide by the live matrix scale here: feeding
|
final SoftPinchStep step = softClampedPinchStep(
|
||||||
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
|
|
||||||
// and snap back. A ratio outside the glitch band is a real multi-touch
|
|
||||||
// spike → drop the frame; absolute tracking means the next good frame
|
|
||||||
// resumes from the true finger span, so the spike never shows.
|
|
||||||
final double rawRatio =
|
|
||||||
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
|
|
||||||
final bool scaleDrop =
|
|
||||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
|
||||||
if (scaleDrop || focalDrop) {
|
|
||||||
record(1.0, scaleDrop, focalDrop);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
|
|
||||||
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
|
|
||||||
// scene point that was under the focal at gesture start stays under the
|
|
||||||
// CURRENT focal (which also yields 2-finger pan for free). Closed form
|
|
||||||
// for a pure scale+translate matrix — no inversion, no live read-back —
|
|
||||||
// so an interleaved/transient matrix write can't survive into the next
|
|
||||||
// frame: every frame is fully re-derived from clean inputs.
|
|
||||||
// Absolute target scale, normalized against the baseline so a
|
|
||||||
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
|
|
||||||
// pinch_scale_solver.dart for the full rationale.
|
|
||||||
final double targetScale = absolutePinchScale(
|
|
||||||
scaleStart: _scaleStart!,
|
scaleStart: _scaleStart!,
|
||||||
rawScaleAtBaseline: _rawScaleAtBaseline,
|
rawScaleAtBaseline: _rawScaleAtBaseline,
|
||||||
rawScale: details.scale,
|
rawScale: details.scale,
|
||||||
|
lastAppliedScale: _lastAppliedScale,
|
||||||
minScale: widget.minScale,
|
minScale: widget.minScale,
|
||||||
maxScale: widget.maxScale,
|
maxScale: widget.maxScale,
|
||||||
|
maxStepRatio: _kScaleGlitchHi,
|
||||||
);
|
);
|
||||||
|
if (focalDrop) {
|
||||||
|
if (step.reanchor) {
|
||||||
|
_scaleStart = step.appliedScale;
|
||||||
|
_rawScaleAtBaseline = details.scale;
|
||||||
|
_lastAppliedScale = step.appliedScale;
|
||||||
|
}
|
||||||
|
record(_lastAppliedScale, 1.0, step.spiked, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final double targetScale = step.appliedScale;
|
||||||
|
if (step.reanchor) {
|
||||||
|
_scaleStart = targetScale;
|
||||||
|
_rawScaleAtBaseline = details.scale;
|
||||||
|
}
|
||||||
final Offset focal = details.localFocalPoint;
|
final Offset focal = details.localFocalPoint;
|
||||||
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
||||||
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
|
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
|
||||||
@@ -281,9 +308,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
|
|
||||||
final double applied =
|
final double applied =
|
||||||
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
|
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
|
||||||
_lastRawScale = details.scale;
|
|
||||||
_lastAppliedScale = targetScale;
|
_lastAppliedScale = targetScale;
|
||||||
record(applied, false, false);
|
record(targetScale, applied, step.spiked, false);
|
||||||
|
|
||||||
case _GestureType.pan:
|
case _GestureType.pan:
|
||||||
assert(_referenceFocalPoint != null);
|
assert(_referenceFocalPoint != null);
|
||||||
@@ -291,7 +317,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
if (details.scale != 1.0) return;
|
if (details.scale != 1.0) return;
|
||||||
if (focalDrop) {
|
if (focalDrop) {
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
record(1.0, false, true);
|
record(scale, 1.0, false, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final Offset translationChange =
|
final Offset translationChange =
|
||||||
@@ -299,11 +325,17 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_transformer.value =
|
_transformer.value =
|
||||||
_matrixTranslate(_transformer.value, translationChange);
|
_matrixTranslate(_transformer.value, translationChange);
|
||||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||||
record(1.0, false, false);
|
record(scale, 1.0, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleEnd(ScaleEndDetails details) {
|
void _onScaleEnd(ScaleEndDetails details) {
|
||||||
|
final pending = _pendingScaleUpdate;
|
||||||
|
_pendingScaleUpdate = null;
|
||||||
|
_scaleFlushScheduled = false;
|
||||||
|
if (pending != null && _scaleStart != null) {
|
||||||
|
_applyScaleUpdate(pending);
|
||||||
|
}
|
||||||
_scaleStart = null;
|
_scaleStart = null;
|
||||||
_referenceFocalPoint = null;
|
_referenceFocalPoint = null;
|
||||||
_lastPointerCount = 0;
|
_lastPointerCount = 0;
|
||||||
@@ -344,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
|||||||
_animation!.addListener(_handleInertiaAnimation);
|
_animation!.addListener(_handleInertiaAnimation);
|
||||||
_controller.forward();
|
_controller.forward();
|
||||||
case _GestureType.scale:
|
case _GestureType.scale:
|
||||||
if (details.scaleVelocity.abs() < 0.1) return;
|
// No scale fling: Windows touch often reports noisy scaleVelocity that
|
||||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
// animates past the intended zoom and feels like a "jump" after pinch.
|
||||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
return;
|
||||||
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
|
|
||||||
scale,
|
|
||||||
details.scaleVelocity / 10,
|
|
||||||
);
|
|
||||||
final double tFinal = _getFinalTime(
|
|
||||||
details.scaleVelocity.abs(),
|
|
||||||
widget.interactionEndFrictionCoefficient,
|
|
||||||
effectivelyMotionless: 0.1,
|
|
||||||
);
|
|
||||||
_scaleAnimation = Tween<double>(
|
|
||||||
begin: scale,
|
|
||||||
end: frictionSimulation.x(tFinal),
|
|
||||||
).animate(
|
|
||||||
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
|
|
||||||
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
|
|
||||||
_scaleAnimation!.addListener(_handleScaleAnimation);
|
|
||||||
_scaleController.forward();
|
|
||||||
case null:
|
case null:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
921
lib/editor/canvas/pen_note_screen.dart
Normal file
921
lib/editor/canvas/pen_note_screen.dart
Normal file
@@ -0,0 +1,921 @@
|
|||||||
|
// lib/editor/canvas/pen_note_screen.dart
|
||||||
|
//
|
||||||
|
// Pen-first blank-note editor. Reuses the single performant inking engine
|
||||||
|
// (PenCanvas) over a white logical page instead of a PDF page, and persists
|
||||||
|
// strokes back to the Note model via the InkStroke<->PenStroke adapter. This is
|
||||||
|
// the note half of "all note features on the pen-first canvas".
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../models/ink_stroke.dart';
|
||||||
|
import '../../models/note.dart';
|
||||||
|
import '../../providers/note_provider.dart';
|
||||||
|
import '../../providers/ocr_provider.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../engine/shape_geometry.dart';
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../persistence/sidecar_repository.dart';
|
||||||
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
|
import '../layout/viewport_fit.dart';
|
||||||
|
import '../notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../ui/pen_settings_page.dart';
|
||||||
|
import 'editor_tool.dart';
|
||||||
|
import 'note_background.dart';
|
||||||
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_palette_widgets.dart';
|
||||||
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
class PenNoteScreen extends ConsumerStatefulWidget {
|
||||||
|
const PenNoteScreen({super.key, this.note});
|
||||||
|
|
||||||
|
/// Existing note to edit, or null for a new note.
|
||||||
|
final Note? note;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PenNoteScreen> createState() => _PenNoteScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Per-page live strokes in normalized coords (the canvas source of truth).
|
||||||
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
|
|
||||||
|
/// Current page index (0-based) and total page count (min 1).
|
||||||
|
int _pageIndex = 0;
|
||||||
|
int _pageCount = 1;
|
||||||
|
|
||||||
|
/// Snapshot-before-change undo/redo scoped to the current page. Cleared on
|
||||||
|
/// page switch so undo never crosses pages.
|
||||||
|
final List<List<PenStroke>> _undo = [];
|
||||||
|
final List<List<PenStroke>> _redo = [];
|
||||||
|
|
||||||
|
bool _showPageScrubber = false;
|
||||||
|
double? _pageScrub;
|
||||||
|
|
||||||
|
/// The single active-tool state (shared model across the 3 editors).
|
||||||
|
EditorToolKind _tool = EditorToolKind.brush;
|
||||||
|
|
||||||
|
/// Selected shape for the SHAPE tool.
|
||||||
|
ShapeKind _shapeKind = ShapeKind.line;
|
||||||
|
|
||||||
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||||
|
int? _selectedStroke;
|
||||||
|
|
||||||
|
/// Highlighter keeps its own color (not a pen slot).
|
||||||
|
Color _highlighterColor = Colors.orange;
|
||||||
|
|
||||||
|
/// Active pen brush from the selected slot (fallback until slots load).
|
||||||
|
BrushKind get _penBrush =>
|
||||||
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
||||||
|
|
||||||
|
/// Active drawing color: highlighter tool uses [_highlighterColor], else the
|
||||||
|
/// active pen slot's color.
|
||||||
|
Color get _color => _tool == EditorToolKind.highlighter
|
||||||
|
? _highlighterColor
|
||||||
|
: (_penSlots?.active.color ?? Colors.black);
|
||||||
|
|
||||||
|
/// The page-background template painted behind the ink (rnote-style). Default
|
||||||
|
/// blank; persisted per-notebook in the sidecar.
|
||||||
|
NoteBackground _background = NoteBackground.blank;
|
||||||
|
|
||||||
|
bool _allowFingerDrawing = false;
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _needsCenter = true;
|
||||||
|
|
||||||
|
/// The note's synthetic source path `<folder>/notebook` (also the note id).
|
||||||
|
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
|
||||||
|
String? _notePath;
|
||||||
|
|
||||||
|
/// Per-file sidecar persistence sink (strokes + pageCount + title), debounced
|
||||||
|
/// and atomic — replaces the old SQLite Note/noteListProvider write path here.
|
||||||
|
SidecarRepository? _repo;
|
||||||
|
|
||||||
|
final TextEditingController _titleController = TextEditingController();
|
||||||
|
|
||||||
|
PenConfigController? _penConfig;
|
||||||
|
PenSlotsController? _penSlots;
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
|
static const List<Color> _palette = kInkPalette;
|
||||||
|
|
||||||
|
/// Current page's stroke list (PenCanvas source of truth).
|
||||||
|
List<PenStroke> get _strokes =>
|
||||||
|
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||||||
|
|
||||||
|
set _strokes(List<PenStroke> value) {
|
||||||
|
_strokesByPage[_pageIndex] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
PenInputService.instance.start();
|
||||||
|
final note = widget.note;
|
||||||
|
if (note != null) {
|
||||||
|
_notePath = note.id;
|
||||||
|
_titleController.text = note.title;
|
||||||
|
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
|
||||||
|
// load resolves and (if present) overrides with persisted strokes.
|
||||||
|
_strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage);
|
||||||
|
} else {
|
||||||
|
_titleController.text = 'Untitled';
|
||||||
|
}
|
||||||
|
_initPenConfig();
|
||||||
|
if (_notePath != null) _initPersistence(_notePath!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the note's `notebook.badnote.json` sidecar and hydrate every page of
|
||||||
|
/// strokes plus title / background / pageCount.
|
||||||
|
Future<void> _initPersistence(String notePath) async {
|
||||||
|
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
|
||||||
|
if (!mounted) {
|
||||||
|
repo.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_repo = repo;
|
||||||
|
setState(() {
|
||||||
|
_hydrateFromRepo(repo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1,
|
||||||
|
/// highest stroke key + 1). Persists pageCount when the sidecar omitted it.
|
||||||
|
void _hydrateFromRepo(SidecarRepository repo) {
|
||||||
|
// Only replace in-memory strokes when the sidecar actually holds ink —
|
||||||
|
// otherwise keep the seed from widget.note (widget tests / cold open).
|
||||||
|
if (repo.loadedStrokes.isNotEmpty) {
|
||||||
|
_strokesByPage.clear();
|
||||||
|
for (final entry in repo.loadedStrokes.entries) {
|
||||||
|
if (entry.value.isEmpty) continue;
|
||||||
|
_strokesByPage[entry.key] = [
|
||||||
|
for (final es in entry.value) _penStrokeFromEditor(es),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final fromKeys = _strokesByPage.isEmpty
|
||||||
|
? 1
|
||||||
|
: _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1;
|
||||||
|
final declared = repo.sidecar.pageCount ?? 1;
|
||||||
|
_pageCount = declared > fromKeys ? declared : fromKeys;
|
||||||
|
if (_pageCount < 1) _pageCount = 1;
|
||||||
|
if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1;
|
||||||
|
_undo.clear();
|
||||||
|
_redo.clear();
|
||||||
|
_selectedStroke = null;
|
||||||
|
|
||||||
|
final title = repo.loadedTitle;
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
_titleController.text = title;
|
||||||
|
}
|
||||||
|
_background = noteBackgroundFromName(repo.loadedBackground);
|
||||||
|
|
||||||
|
if (repo.sidecar.pageCount != _pageCount) {
|
||||||
|
repo.schedulePageCountSave(_pageCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
|
||||||
|
/// is persisted on the EditorStroke now, so carry it through; old sidecars
|
||||||
|
/// without the field decode to fountainPen (back-compat default).
|
||||||
|
PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke(
|
||||||
|
points: es.points
|
||||||
|
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
|
||||||
|
.toList(),
|
||||||
|
color: es.color,
|
||||||
|
width: es.width,
|
||||||
|
kind: es.tool == EditorTool.highlighter
|
||||||
|
? PenStrokeKind.highlighter
|
||||||
|
: PenStrokeKind.pen,
|
||||||
|
brush: es.brush,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _initPenConfig() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
PenConfigController.load(),
|
||||||
|
PenSlotsController.load(),
|
||||||
|
]);
|
||||||
|
final config = results[0] as PenConfigController;
|
||||||
|
final slots = results[1] as PenSlotsController;
|
||||||
|
if (!mounted) {
|
||||||
|
config.dispose();
|
||||||
|
slots.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.addListener(_onPenConfigChanged);
|
||||||
|
slots.addListener(_onPenSlotsChanged);
|
||||||
|
setState(() {
|
||||||
|
_penConfig = config;
|
||||||
|
_penSlots = slots;
|
||||||
|
_allowFingerDrawing = config.value.fingerDrawing;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenConfigChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenSlotsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Flush any pending sidecar write before tearing down (atomic write
|
||||||
|
// completes off the widget tree).
|
||||||
|
final repo = _repo;
|
||||||
|
if (repo != null) {
|
||||||
|
repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
}
|
||||||
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
|
_penConfig?.dispose();
|
||||||
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
||||||
|
_penSlots?.dispose();
|
||||||
|
_titleController.dispose();
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _pushUndo() {
|
||||||
|
_undo.add(List<PenStroke>.from(_strokes));
|
||||||
|
_redo.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _commitStroke(PenStroke stroke) {
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokes = [..._strokes, stroke];
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
||||||
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokes = [
|
||||||
|
..._strokes.sublist(0, index),
|
||||||
|
...replacements,
|
||||||
|
..._strokes.sublist(index + 1),
|
||||||
|
];
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performUndo() {
|
||||||
|
if (_undo.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_redo.add(List<PenStroke>.from(_strokes));
|
||||||
|
_strokes = _undo.removeLast();
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performRedo() {
|
||||||
|
if (_redo.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_undo.add(List<PenStroke>.from(_strokes));
|
||||||
|
_strokes = _redo.removeLast();
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFingerDrawing() {
|
||||||
|
final next = !_allowFingerDrawing;
|
||||||
|
setState(() => _allowFingerDrawing = next);
|
||||||
|
_penConfig?.setFingerDrawing(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Schedule a stroke save for [pageIndex] (defaults to current) without
|
||||||
|
/// flushing. Used when switching pages so ink isn't lost mid-edit.
|
||||||
|
void _schedulePageStrokeSave([int? pageIndex]) {
|
||||||
|
final repo = _repo;
|
||||||
|
if (repo == null) return;
|
||||||
|
final idx = pageIndex ?? _pageIndex;
|
||||||
|
final pageStrokes = _strokesByPage[idx] ?? const <PenStroke>[];
|
||||||
|
final editorStrokes = <EditorStroke>[
|
||||||
|
for (final s in pageStrokes) EditorStroke.fromPenStroke(s),
|
||||||
|
];
|
||||||
|
repo.scheduleStrokeSave(idx, editorStrokes);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToPage(int index) {
|
||||||
|
if (_pageCount < 1) return;
|
||||||
|
final clamped = index.clamp(0, _pageCount - 1);
|
||||||
|
if (clamped == _pageIndex) {
|
||||||
|
setState(() {
|
||||||
|
_pageScrub = null;
|
||||||
|
_showPageScrubber = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_schedulePageStrokeSave(_pageIndex);
|
||||||
|
setState(() {
|
||||||
|
_pageIndex = clamped;
|
||||||
|
_pageScrub = null;
|
||||||
|
_showPageScrubber = false;
|
||||||
|
_selectedStroke = null;
|
||||||
|
_undo.clear();
|
||||||
|
_redo.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addPage() {
|
||||||
|
_schedulePageStrokeSave(_pageIndex);
|
||||||
|
setState(() {
|
||||||
|
_pageCount += 1;
|
||||||
|
_pageIndex = _pageCount - 1;
|
||||||
|
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||||||
|
_pageScrub = null;
|
||||||
|
_showPageScrubber = false;
|
||||||
|
_selectedStroke = null;
|
||||||
|
_undo.clear();
|
||||||
|
_redo.clear();
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
_repo?.schedulePageCountSave(_pageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the live pen strokes + title + pageCount to the note's
|
||||||
|
/// `notebook.badnote.json` sidecar, debounced/atomic via [SidecarRepository].
|
||||||
|
/// Creates the notebook folder lazily on first save when the screen was opened
|
||||||
|
/// without a path. Refreshes the home list and triggers local OCR for search.
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_dirty) return;
|
||||||
|
final notifier = ref.read(noteListProvider.notifier);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final title = _titleController.text.trim().isEmpty
|
||||||
|
? 'Untitled'
|
||||||
|
: _titleController.text.trim();
|
||||||
|
|
||||||
|
// Lazily create the notebook folder + sidecar repo on first save.
|
||||||
|
if (_repo == null) {
|
||||||
|
final created = await notifier.createNote(title: title);
|
||||||
|
if (!mounted) return;
|
||||||
|
_notePath = created.id;
|
||||||
|
final repo =
|
||||||
|
await SidecarRepository.open(created.id, docType: 'notebook');
|
||||||
|
if (!mounted) {
|
||||||
|
repo.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_repo = repo;
|
||||||
|
}
|
||||||
|
final repo = _repo!;
|
||||||
|
|
||||||
|
repo.scheduleTitleSave(title);
|
||||||
|
repo.scheduleBackgroundSave(_background.name);
|
||||||
|
repo.schedulePageCountSave(_pageCount);
|
||||||
|
// Persist every page that has (or had) strokes in this session. Empty pages
|
||||||
|
// clear their sidecar entry via scheduleStrokeSave.
|
||||||
|
for (final idx in _strokesByPage.keys.toList()..sort()) {
|
||||||
|
_schedulePageStrokeSave(idx);
|
||||||
|
}
|
||||||
|
// Also ensure the current page is written even if never putIfAbsent'd empty.
|
||||||
|
_schedulePageStrokeSave(_pageIndex);
|
||||||
|
await repo.flush();
|
||||||
|
|
||||||
|
// Refresh the home list so the title/recency update is visible on return.
|
||||||
|
await notifier.loadNotes();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _dirty = false);
|
||||||
|
|
||||||
|
// Build an in-memory Note (id = note path) for OCR/FTS indexing only —
|
||||||
|
// flatten all pages into one stroke list.
|
||||||
|
final inkStrokes = <InkStroke>[
|
||||||
|
for (final page in _strokesByPage.values)
|
||||||
|
for (final s in page)
|
||||||
|
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
|
||||||
|
];
|
||||||
|
_runLocalOcr(Note(
|
||||||
|
id: _notePath!,
|
||||||
|
title: title,
|
||||||
|
strokes: inkStrokes,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runLocalOcr(Note note) {
|
||||||
|
final id = note.id;
|
||||||
|
ref.read(ocrStatusProvider.notifier).state = {
|
||||||
|
...ref.read(ocrStatusProvider),
|
||||||
|
id: OcrStatus.processing,
|
||||||
|
};
|
||||||
|
ref.read(ocrServiceProvider).processNote(note).then((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(ocrStatusProvider.notifier).state = {
|
||||||
|
...ref.read(ocrStatusProvider),
|
||||||
|
id: OcrStatus.done,
|
||||||
|
};
|
||||||
|
}).catchError((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref.read(ocrStatusProvider.notifier).state = {
|
||||||
|
...ref.read(ocrStatusProvider),
|
||||||
|
id: OcrStatus.failed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _centerPage(Size viewport, Size pageSize) {
|
||||||
|
final o = centerOffset(pageSize, viewport, 1.0);
|
||||||
|
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||||
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
|
: (_penSlots?.active.width ?? 0.006);
|
||||||
|
|
||||||
|
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
|
||||||
|
|
||||||
|
/// Set (or clear) the selected stroke from a SELECT-tool tap.
|
||||||
|
void _selectStroke(int? index) {
|
||||||
|
setState(() => _selectedStroke = index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta
|
||||||
|
/// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a
|
||||||
|
/// single undo step.
|
||||||
|
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
|
||||||
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
if (isDragStart) _pushUndo();
|
||||||
|
final next = List<PenStroke>.from(_strokes);
|
||||||
|
next[index] = translateStroke(next[index], dx, dy);
|
||||||
|
_strokes = next;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the selected stroke (button or long-press), as one undo step.
|
||||||
|
void _deleteSelected() {
|
||||||
|
final idx = _selectedStroke;
|
||||||
|
if (idx == null || idx < 0 || idx >= _strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokes = [
|
||||||
|
..._strokes.sublist(0, idx),
|
||||||
|
..._strokes.sublist(idx + 1),
|
||||||
|
];
|
||||||
|
_selectedStroke = null;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (didPop && _dirty) _save();
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _buildCanvas()),
|
||||||
|
// Tool palette (top-center) — identical chrome to the PDF editor.
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _buildToolPalette(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Back (saves on the way out).
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.arrow_back,
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
await _save();
|
||||||
|
if (mounted) navigator.maybePop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title + page chrome (bottom-center).
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildPagePill(cs),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildTitlePill(cs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCanvas() {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// Fit the logical note page into the viewport at scale 1.0.
|
||||||
|
final fitW = constraints.maxWidth / kNoteLogicalPage.width;
|
||||||
|
final fitH = constraints.maxHeight / kNoteLogicalPage.height;
|
||||||
|
final scale = fitW < fitH ? fitW : fitH;
|
||||||
|
final pageSize = Size(
|
||||||
|
kNoteLogicalPage.width * scale,
|
||||||
|
kNoteLogicalPage.height * scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_needsCenter) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_centerPage(
|
||||||
|
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
|
||||||
|
setState(() => _needsCenter = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PenCanvas(
|
||||||
|
pageSize: pageSize,
|
||||||
|
strokes: _strokes,
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: editorToolToCanvas(_tool),
|
||||||
|
brush: _penBrush,
|
||||||
|
shapeKind: _shapeKind,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: _strokeWidth,
|
||||||
|
selectedStrokeIndex: _selectedStroke,
|
||||||
|
onSelectStroke: _selectStroke,
|
||||||
|
onMoveStroke: _moveStroke,
|
||||||
|
pressureGamma:
|
||||||
|
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
|
||||||
|
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||||
|
sideButtonAction:
|
||||||
|
_penConfig?.value.sideButton ?? PenButtonAction.select,
|
||||||
|
eraserEndAction:
|
||||||
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||||
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
|
onStrokeComplete: _commitStroke,
|
||||||
|
onEraseStroke: _eraseStroke,
|
||||||
|
onPenButtonAction: (action) {
|
||||||
|
if (action == PenButtonAction.select) {
|
||||||
|
setState(() => _tool = EditorToolKind.select);
|
||||||
|
} else if (action == PenButtonAction.undo) {
|
||||||
|
if (_undo.isNotEmpty) _performUndo();
|
||||||
|
} else if (action == PenButtonAction.toggleTool) {
|
||||||
|
setState(() {
|
||||||
|
_tool = _tool == EditorToolKind.eraser
|
||||||
|
? EditorToolKind.brush
|
||||||
|
: EditorToolKind.eraser;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// A white sheet with a soft shadow — the note "paper" — overlaid with
|
||||||
|
// the selected background template, painted in page-pixel space (so it
|
||||||
|
// scales with zoom) and BEHIND the ink layers.
|
||||||
|
pageWidget: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 12,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: NoteBackgroundPainter(_background),
|
||||||
|
size: Size.infinite,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildToolPalette(ColorScheme cs) {
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// OneNote-style: each pen slot restores brush + color + thickness.
|
||||||
|
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
|
||||||
|
PenSlotButton(
|
||||||
|
kind: slot.brush,
|
||||||
|
selected: _tool == EditorToolKind.brush &&
|
||||||
|
(_penSlots?.activeId ?? 'slot_0') == slot.id,
|
||||||
|
color: slot.color,
|
||||||
|
widthHint: slot.width,
|
||||||
|
tooltip: brushLabelEn(slot.brush),
|
||||||
|
onPressed: () {
|
||||||
|
_penSlots?.select(slot.id);
|
||||||
|
setState(() => _tool = EditorToolKind.brush);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.brush_outlined,
|
||||||
|
selected: _tool == EditorToolKind.highlighter,
|
||||||
|
tooltip: 'Highlighter',
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _tool = EditorToolKind.highlighter),
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.cleaning_services_outlined,
|
||||||
|
selected: _tool == EditorToolKind.eraser,
|
||||||
|
tooltip: 'Eraser',
|
||||||
|
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
|
||||||
|
),
|
||||||
|
// Select (cursor) + shape tools.
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.ads_click,
|
||||||
|
selected: _tool == EditorToolKind.select,
|
||||||
|
tooltip: 'Select',
|
||||||
|
onPressed: () => setState(() => _tool = EditorToolKind.select),
|
||||||
|
),
|
||||||
|
ShapePickerButton(
|
||||||
|
selected: _shapeKind,
|
||||||
|
active: _tool == EditorToolKind.shape,
|
||||||
|
tooltip: 'Shape',
|
||||||
|
labelFor: shapeLabelEn,
|
||||||
|
onActivate: () => setState(() => _tool = EditorToolKind.shape),
|
||||||
|
onSelected: (s) => setState(() {
|
||||||
|
_shapeKind = s;
|
||||||
|
_tool = EditorToolKind.shape;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_tool == EditorToolKind.select && _selectedStroke != null)
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Delete selection',
|
||||||
|
onPressed: _deleteSelected,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.undo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Undo',
|
||||||
|
onPressed: _undo.isNotEmpty ? _performUndo : null,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.redo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Redo',
|
||||||
|
onPressed: _redo.isNotEmpty ? _performRedo : null,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
|
ThicknessPickerButton(
|
||||||
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
// Page-background template picker (rnote-style: blank / dots / ruled
|
||||||
|
// / grid / cornell). Persists per-notebook in the sidecar.
|
||||||
|
PopupMenuButton<NoteBackground>(
|
||||||
|
tooltip: 'Page background',
|
||||||
|
initialValue: _background,
|
||||||
|
onSelected: (b) {
|
||||||
|
setState(() {
|
||||||
|
_background = b;
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final b in NoteBackground.values)
|
||||||
|
PopupMenuItem<NoteBackground>(
|
||||||
|
value: b,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(noteBackgroundIcon(b), size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(noteBackgroundLabel(b)),
|
||||||
|
if (b == _background) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
noteBackgroundIcon(_background),
|
||||||
|
size: 22,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
size: 18,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon:
|
||||||
|
_allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
|
selected: _allowFingerDrawing,
|
||||||
|
tooltip: _allowFingerDrawing
|
||||||
|
? 'Finger drawing ON'
|
||||||
|
: 'Finger drawing OFF (pen only)',
|
||||||
|
onPressed: _toggleFingerDrawing,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Pen settings (width, pressure, eraser…)',
|
||||||
|
onPressed: _penConfig != null
|
||||||
|
? () => showPenSettingsSheet(context, _penConfig!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
|
// Selected against the ACTIVE slot (or highlighter) color. A color tap
|
||||||
|
// updates only the active slot / highlighter — not other slots.
|
||||||
|
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||||
|
_tool != EditorToolKind.eraser &&
|
||||||
|
_tool != EditorToolKind.select;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_tool == EditorToolKind.eraser ||
|
||||||
|
_tool == EditorToolKind.select) {
|
||||||
|
setState(() => _tool = EditorToolKind.brush);
|
||||||
|
}
|
||||||
|
if (_tool == EditorToolKind.highlighter) {
|
||||||
|
setState(() => _highlighterColor = c);
|
||||||
|
} else {
|
||||||
|
_penSlots?.setActiveColor(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? cs.onSurface : cs.outlineVariant,
|
||||||
|
width: selected ? 3 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitlePill(ColorScheme cs) {
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
child: TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'Note title…',
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => _dirty = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact page chrome: prev / "n / total" / next, plus add-page. Tapping the
|
||||||
|
/// center label toggles a scrubber Slider when there is more than one page.
|
||||||
|
Widget _buildPagePill(ColorScheme cs) {
|
||||||
|
final total = _pageCount;
|
||||||
|
final scrub = _pageScrub;
|
||||||
|
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (total > 1 && _showPageScrubber)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Slider(
|
||||||
|
min: 1,
|
||||||
|
max: total.toDouble(),
|
||||||
|
value: (scrub ?? (_pageIndex + 1).toDouble())
|
||||||
|
.clamp(1, total.toDouble()),
|
||||||
|
divisions: total > 1 ? total - 1 : null,
|
||||||
|
onChanged: (v) => setState(() => _pageScrub = v),
|
||||||
|
onChangeEnd: (v) {
|
||||||
|
setState(() => _pageScrub = v);
|
||||||
|
_goToPage(v.round() - 1);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Previous page',
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed: _pageIndex > 0
|
||||||
|
? () => _goToPage(_pageIndex - 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (total > 1) {
|
||||||
|
setState(() => _showPageScrubber = !_showPageScrubber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'$shown / $total',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Next page',
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: _pageIndex < total - 1
|
||||||
|
? () => _goToPage(_pageIndex + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Add page',
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
onPressed: _addPage,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
547
lib/editor/canvas/pen_palette_widgets.dart
Normal file
547
lib/editor/canvas/pen_palette_widgets.dart
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
// lib/editor/canvas/pen_palette_widgets.dart
|
||||||
|
//
|
||||||
|
// Shared Material 3 chrome for the pen-first editors (PDF, note, slide) so the
|
||||||
|
// floating tool palette looks and behaves identically everywhere — one source
|
||||||
|
// of truth for the inking UI.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
|
import 'editor_tool.dart';
|
||||||
|
|
||||||
|
/// Shared ink color palette for PDF / note / slide / scratch editors.
|
||||||
|
const List<Color> kInkPalette = <Color>[
|
||||||
|
Color(0xFF1A1A1A),
|
||||||
|
Color(0xFFC62828),
|
||||||
|
Color(0xFF1565C0),
|
||||||
|
Color(0xFF2E7D32),
|
||||||
|
Color(0xFFEF6C00),
|
||||||
|
Color(0xFF6A1B9A),
|
||||||
|
Color(0xFF00838F),
|
||||||
|
Color(0xFF5D4037),
|
||||||
|
Color(0xFFF9A825),
|
||||||
|
Color(0xFFE91E63),
|
||||||
|
Color(0xFF455A64),
|
||||||
|
Color(0xFF37474F),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Localized display name for a brush (single source so all three editors agree).
|
||||||
|
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
|
||||||
|
BrushKind.fountainPen => l.brushFountainPen,
|
||||||
|
BrushKind.ballpoint => l.brushBallpoint,
|
||||||
|
BrushKind.pencil => l.brushPencil,
|
||||||
|
BrushKind.highlighter => l.brushHighlighter,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// English fallback brush name, for the note/slide editors which (like their
|
||||||
|
/// other chrome) use hardcoded English strings rather than [AppLocalizations]
|
||||||
|
/// (their test harness mounts a MaterialApp without localization delegates).
|
||||||
|
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
|
||||||
|
String brushLabelEn(BrushKind kind) => switch (kind) {
|
||||||
|
BrushKind.fountainPen => 'Fountain pen',
|
||||||
|
BrushKind.ballpoint => 'Ballpoint',
|
||||||
|
BrushKind.pencil => 'Pencil',
|
||||||
|
BrushKind.highlighter => 'Highlighter',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Localized display name for a shape kind.
|
||||||
|
String shapeLabel(ShapeKind kind, AppLocalizations l) => switch (kind) {
|
||||||
|
ShapeKind.line => l.shapeLine,
|
||||||
|
ShapeKind.rectangle => l.shapeRectangle,
|
||||||
|
ShapeKind.ellipse => l.shapeEllipse,
|
||||||
|
ShapeKind.arrow => l.shapeArrow,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// English fallback shape name (for the note/slide editors which use hardcoded
|
||||||
|
/// English strings — see [brushLabelEn]).
|
||||||
|
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
|
||||||
|
String shapeLabelEn(ShapeKind kind) => switch (kind) {
|
||||||
|
ShapeKind.line => 'Line',
|
||||||
|
ShapeKind.rectangle => 'Rectangle',
|
||||||
|
ShapeKind.ellipse => 'Ellipse',
|
||||||
|
ShapeKind.arrow => 'Arrow',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The brushes selectable as the PEN tool. The highlighter is its own tool, so
|
||||||
|
/// it is NOT offered here (eraser is also a separate tool).
|
||||||
|
const List<BrushKind> kPenToolBrushes = [
|
||||||
|
BrushKind.fountainPen,
|
||||||
|
BrushKind.ballpoint,
|
||||||
|
BrushKind.pencil,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Material icon for a brush (used in the brush picker + the pen tool button).
|
||||||
|
IconData brushIcon(BrushKind kind) => switch (kind) {
|
||||||
|
BrushKind.fountainPen => Icons.edit_outlined, // nib pen
|
||||||
|
BrushKind.ballpoint => Icons.create_outlined, // ballpoint
|
||||||
|
BrushKind.pencil => Icons.draw_outlined, // pencil
|
||||||
|
BrushKind.highlighter => Icons.brush_outlined, // marker
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Material icon for a [ShapeKind] (used in the shape picker + tool button).
|
||||||
|
IconData shapeIcon(ShapeKind kind) => switch (kind) {
|
||||||
|
ShapeKind.line => Icons.show_chart, // straight line
|
||||||
|
ShapeKind.rectangle => Icons.crop_square,
|
||||||
|
ShapeKind.ellipse => Icons.circle_outlined,
|
||||||
|
ShapeKind.arrow => Icons.arrow_outward,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// OneNote-style pen slot: each slot is its own toolbar button with a color
|
||||||
|
/// underline (slot-remembered color). Prefer this over [BrushPickerButton]
|
||||||
|
/// when the UX wants pens visible side-by-side.
|
||||||
|
class PenSlotButton extends StatelessWidget {
|
||||||
|
const PenSlotButton({
|
||||||
|
super.key,
|
||||||
|
required this.kind,
|
||||||
|
required this.selected,
|
||||||
|
required this.color,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
this.widthHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BrushKind kind;
|
||||||
|
final bool selected;
|
||||||
|
final Color color;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
/// Optional page-width fraction; when set, underline height scales slightly
|
||||||
|
/// so thicker slots read visually thicker. Null keeps the fixed 3px bar.
|
||||||
|
final double? widthHint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final iconColor =
|
||||||
|
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||||
|
final barHeight = widthHint == null
|
||||||
|
? 3.0
|
||||||
|
: (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0);
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(brushIcon(kind), size: 22, color: iconColor),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: barHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact thickness control: S / M / L presets + a custom slider. Writes the
|
||||||
|
/// chosen page-width fraction via [onChanged] (typically
|
||||||
|
/// [PenSlotsController.setActiveWidth]).
|
||||||
|
class ThicknessPickerButton extends StatelessWidget {
|
||||||
|
const ThicknessPickerButton({
|
||||||
|
super.key,
|
||||||
|
required this.width,
|
||||||
|
required this.onChanged,
|
||||||
|
this.tooltip = 'Thickness',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Current stroke width (page-width fraction).
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
|
static String _labelFor(double w) {
|
||||||
|
if ((w - kThicknessSmall).abs() < 0.0003) return 'S';
|
||||||
|
if ((w - kThicknessMedium).abs() < 0.0003) return 'M';
|
||||||
|
if ((w - kThicknessLarge).abs() < 0.0003) return 'L';
|
||||||
|
return '·';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return PopupMenuButton<double>(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onSelected: onChanged,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem<double>(
|
||||||
|
value: kThicknessSmall,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('S'),
|
||||||
|
const Spacer(),
|
||||||
|
if ((width - kThicknessSmall).abs() < 0.0003)
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<double>(
|
||||||
|
value: kThicknessMedium,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('M'),
|
||||||
|
const Spacer(),
|
||||||
|
if ((width - kThicknessMedium).abs() < 0.0003)
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<double>(
|
||||||
|
value: kThicknessLarge,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('L'),
|
||||||
|
const Spacer(),
|
||||||
|
if ((width - kThicknessLarge).abs() < 0.0003)
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<double>(
|
||||||
|
enabled: false,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setLocal) {
|
||||||
|
final v = width.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
|
||||||
|
return Slider(
|
||||||
|
value: v,
|
||||||
|
min: kPenSlotWidthMin,
|
||||||
|
max: kPenSlotWidthMax,
|
||||||
|
onChanged: (next) {
|
||||||
|
onChanged(next);
|
||||||
|
setLocal(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.line_weight, size: 20, color: cs.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
_labelFor(width),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
|
||||||
|
///
|
||||||
|
/// Highlighter and eraser remain separate tools. Tapping the button opens a
|
||||||
|
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
|
||||||
|
/// [labelFor] localizes each brush name so the menu honors the app locale.
|
||||||
|
/// [colorFor] returns each brush's REMEMBERED color (rnote-style per-brush color
|
||||||
|
/// memory): the active brush's color is shown as an underline on the button and
|
||||||
|
/// as a dot beside each menu item, so the toolbar makes each brush's color
|
||||||
|
/// visible at a glance.
|
||||||
|
class BrushPickerButton extends StatelessWidget {
|
||||||
|
const BrushPickerButton({
|
||||||
|
super.key,
|
||||||
|
required this.selected,
|
||||||
|
required this.active,
|
||||||
|
required this.onSelected,
|
||||||
|
required this.labelFor,
|
||||||
|
required this.colorFor,
|
||||||
|
required this.tooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The currently selected pen brush.
|
||||||
|
final BrushKind selected;
|
||||||
|
|
||||||
|
/// True when the pen tool (this brush) is the active tool — drives highlight.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
final ValueChanged<BrushKind> onSelected;
|
||||||
|
|
||||||
|
/// Localized display name for a brush.
|
||||||
|
final String Function(BrushKind) labelFor;
|
||||||
|
|
||||||
|
/// The remembered color for a brush (drives the underline + menu dots).
|
||||||
|
final Color Function(BrushKind) colorFor;
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final iconColor =
|
||||||
|
active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||||
|
return PopupMenuButton<BrushKind>(
|
||||||
|
tooltip: tooltip,
|
||||||
|
initialValue: selected,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final b in kPenToolBrushes)
|
||||||
|
PopupMenuItem<BrushKind>(
|
||||||
|
value: b,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(brushIcon(b), size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(labelFor(b)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// The brush's remembered color (rnote per-brush color memory).
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorFor(b),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: cs.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (b == selected) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(brushIcon(selected), size: 22, color: iconColor),
|
||||||
|
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Per-brush color underline: shows the active brush's remembered
|
||||||
|
// color so switching brushes visibly switches the color.
|
||||||
|
Container(
|
||||||
|
height: 3,
|
||||||
|
width: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorFor(selected),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toggle-style tool button that doubles as a [ShapeKind] picker: a short tap
|
||||||
|
/// activates the shape tool with the current shape; a long-press (or the dropdown
|
||||||
|
/// caret) opens the line / rectangle / ellipse / arrow submenu.
|
||||||
|
class ShapePickerButton extends StatelessWidget {
|
||||||
|
const ShapePickerButton({
|
||||||
|
super.key,
|
||||||
|
required this.selected,
|
||||||
|
required this.active,
|
||||||
|
required this.onActivate,
|
||||||
|
required this.onSelected,
|
||||||
|
required this.labelFor,
|
||||||
|
required this.tooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The currently selected shape kind.
|
||||||
|
final ShapeKind selected;
|
||||||
|
|
||||||
|
/// True when the shape tool is the active tool — drives highlight.
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
/// Called when the button body is tapped (activate the shape tool).
|
||||||
|
final VoidCallback onActivate;
|
||||||
|
|
||||||
|
/// Called when a shape kind is picked from the submenu (also activates).
|
||||||
|
final ValueChanged<ShapeKind> onSelected;
|
||||||
|
|
||||||
|
/// Localized display name for a shape kind.
|
||||||
|
final String Function(ShapeKind) labelFor;
|
||||||
|
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final iconColor = active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||||
|
return PopupMenuButton<ShapeKind>(
|
||||||
|
tooltip: tooltip,
|
||||||
|
initialValue: selected,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (final s in ShapeKind.values)
|
||||||
|
PopupMenuItem<ShapeKind>(
|
||||||
|
value: s,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(shapeIcon(s), size: 20),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(labelFor(s)),
|
||||||
|
if (s == selected) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onActivate,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(shapeIcon(selected), size: 22, color: iconColor),
|
||||||
|
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Material 3 toggle-style icon button for the floating tool palette.
|
||||||
|
class ToolButton extends StatelessWidget {
|
||||||
|
const ToolButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.selected,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final bool selected;
|
||||||
|
final String tooltip;
|
||||||
|
|
||||||
|
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final enabled = onPressed != null;
|
||||||
|
final iconColor = !enabled
|
||||||
|
? cs.onSurfaceVariant.withValues(alpha: 0.38)
|
||||||
|
: selected
|
||||||
|
? cs.onSecondaryContainer
|
||||||
|
: cs.onSurfaceVariant;
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onPressed,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 22, color: iconColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin vertical divider between palette groups.
|
||||||
|
class PaletteDivider extends StatelessWidget {
|
||||||
|
const PaletteDivider({super.key, required this.cs});
|
||||||
|
final ColorScheme cs;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
color: cs.outlineVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round, tonal icon button (used for the floating back button).
|
||||||
|
class RoundIconButton extends StatelessWidget {
|
||||||
|
const RoundIconButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: tooltip,
|
||||||
|
icon: Icon(icon),
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
681
lib/editor/canvas/pen_slide_screen.dart
Normal file
681
lib/editor/canvas/pen_slide_screen.dart
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
// lib/editor/canvas/pen_slide_screen.dart
|
||||||
|
//
|
||||||
|
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
|
||||||
|
// (PenCanvas) over each slide image, with per-slide normalized strokes and
|
||||||
|
// prev/next navigation. Export to PDF maps the normalized strokes into each
|
||||||
|
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
|
||||||
|
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
|
||||||
|
// to PDF is how annotations are kept), matching the previous behavior.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../engine/shape_geometry.dart';
|
||||||
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
|
import '../layout/viewport_fit.dart';
|
||||||
|
import '../pdf/slide_export.dart';
|
||||||
|
import '../ui/page_nav_shortcuts.dart';
|
||||||
|
import '../ui/pen_settings_page.dart';
|
||||||
|
import 'editor_tool.dart';
|
||||||
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_palette_widgets.dart';
|
||||||
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
class PenSlideScreen extends StatefulWidget {
|
||||||
|
const PenSlideScreen({
|
||||||
|
super.key,
|
||||||
|
required this.filePath,
|
||||||
|
required this.slideImagePaths,
|
||||||
|
this.extractedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String filePath;
|
||||||
|
final List<String> slideImagePaths;
|
||||||
|
final String? extractedText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PenSlideScreen> createState() => _PenSlideScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||||
|
int _slideIndex = 0;
|
||||||
|
final Map<int, List<PenStroke>> _strokesBySlide = {};
|
||||||
|
final Map<int, List<List<PenStroke>>> _undo = {};
|
||||||
|
final Map<int, List<List<PenStroke>>> _redo = {};
|
||||||
|
|
||||||
|
/// Intrinsic pixel size of each slide image, loaded async so the page rect
|
||||||
|
/// keeps the slide's aspect (no distortion). Null until loaded.
|
||||||
|
Map<int, Size>? _slideSizes;
|
||||||
|
|
||||||
|
/// The single active-tool state (shared model across the 3 editors).
|
||||||
|
EditorToolKind _tool = EditorToolKind.brush;
|
||||||
|
|
||||||
|
/// Selected shape for the SHAPE tool.
|
||||||
|
ShapeKind _shapeKind = ShapeKind.line;
|
||||||
|
|
||||||
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||||
|
int? _selectedStroke;
|
||||||
|
|
||||||
|
/// Highlighter keeps its own color (not a pen slot).
|
||||||
|
Color _highlighterColor = Colors.orange;
|
||||||
|
|
||||||
|
BrushKind get _penBrush =>
|
||||||
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
||||||
|
|
||||||
|
Color get _color => _tool == EditorToolKind.highlighter
|
||||||
|
? _highlighterColor
|
||||||
|
: (_penSlots?.active.color ?? Colors.black);
|
||||||
|
|
||||||
|
bool _allowFingerDrawing = false;
|
||||||
|
bool _needsCenter = true;
|
||||||
|
bool _showSlider = false;
|
||||||
|
double? _scrub;
|
||||||
|
|
||||||
|
PenConfigController? _penConfig;
|
||||||
|
PenSlotsController? _penSlots;
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
static const Size _fallbackSlide = Size(1600, 900);
|
||||||
|
|
||||||
|
static const List<Color> _palette = kInkPalette;
|
||||||
|
|
||||||
|
int get _slideCount => widget.slideImagePaths.length;
|
||||||
|
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
PenInputService.instance.start();
|
||||||
|
_loadSlideSizes();
|
||||||
|
_initPenConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSlideSizes() async {
|
||||||
|
final sizes = <int, Size>{};
|
||||||
|
for (var i = 0; i < _slideCount; i++) {
|
||||||
|
try {
|
||||||
|
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
||||||
|
final codec = await ui.instantiateImageCodec(bytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
sizes[i] = Size(
|
||||||
|
frame.image.width.toDouble(), frame.image.height.toDouble());
|
||||||
|
frame.image.dispose();
|
||||||
|
} catch (_) {
|
||||||
|
sizes[i] = _fallbackSlide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _slideSizes = sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPenConfig() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
PenConfigController.load(),
|
||||||
|
PenSlotsController.load(),
|
||||||
|
]);
|
||||||
|
final config = results[0] as PenConfigController;
|
||||||
|
final slots = results[1] as PenSlotsController;
|
||||||
|
if (!mounted) {
|
||||||
|
config.dispose();
|
||||||
|
slots.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.addListener(_onPenConfigChanged);
|
||||||
|
slots.addListener(_onPenSlotsChanged);
|
||||||
|
setState(() {
|
||||||
|
_penConfig = config;
|
||||||
|
_penSlots = slots;
|
||||||
|
_allowFingerDrawing = config.value.fingerDrawing;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenConfigChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPenSlotsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
|
_penConfig?.dispose();
|
||||||
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
||||||
|
_penSlots?.dispose();
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _pushUndo() {
|
||||||
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_redo[_slideIndex]?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _commitStroke(PenStroke stroke) {
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
||||||
|
final strokes = _currentStrokes;
|
||||||
|
if (index < 0 || index >= strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokesBySlide[_slideIndex] = [
|
||||||
|
...strokes.sublist(0, index),
|
||||||
|
...replacements,
|
||||||
|
...strokes.sublist(index + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performUndo() {
|
||||||
|
final stack = _undo[_slideIndex];
|
||||||
|
if (stack == null || stack.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _performRedo() {
|
||||||
|
final stack = _redo[_slideIndex];
|
||||||
|
if (stack == null || stack.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
|
||||||
|
_strokesBySlide[_slideIndex] = stack.removeLast();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFingerDrawing() {
|
||||||
|
final next = !_allowFingerDrawing;
|
||||||
|
setState(() => _allowFingerDrawing = next);
|
||||||
|
_penConfig?.setFingerDrawing(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToSlide(int i) {
|
||||||
|
final clamped = i.clamp(0, _slideCount - 1);
|
||||||
|
if (clamped == _slideIndex) return;
|
||||||
|
setState(() {
|
||||||
|
_slideIndex = clamped;
|
||||||
|
_needsCenter = true;
|
||||||
|
_selectedStroke = null; // selection is per-slide
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _exportPdf() async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Exporting PDF...')));
|
||||||
|
try {
|
||||||
|
final bytes = await _buildPdfBytes();
|
||||||
|
final dir = await _exportDir();
|
||||||
|
final base = p.basenameWithoutExtension(widget.filePath);
|
||||||
|
final outPath = p.join(dir.path, '${base}_annotated.pdf');
|
||||||
|
await File(outPath).writeAsBytes(bytes);
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> _exportDir() 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;
|
||||||
|
final sizes = _slideSizes ?? const {};
|
||||||
|
for (var i = 0; i < _slideCount; i++) {
|
||||||
|
final page = doc.pages.add();
|
||||||
|
final pageSize = page.getClientSize();
|
||||||
|
try {
|
||||||
|
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
|
||||||
|
final bitmap = PdfBitmap(imgBytes);
|
||||||
|
final imageSize = sizes[i] ??
|
||||||
|
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
|
||||||
|
final draw = slideDrawRect(
|
||||||
|
Size(pageSize.width, pageSize.height), imageSize);
|
||||||
|
page.graphics.drawImage(bitmap, draw);
|
||||||
|
|
||||||
|
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
|
||||||
|
if (stroke.points.length < 2) continue;
|
||||||
|
final r = (stroke.color >> 16) & 0xFF;
|
||||||
|
final g = (stroke.color >> 8) & 0xFF;
|
||||||
|
final b = stroke.color & 0xFF;
|
||||||
|
final path = PdfPath();
|
||||||
|
path.startFigure();
|
||||||
|
for (var j = 0; j < stroke.points.length - 1; j++) {
|
||||||
|
final p1 = stroke.points[j];
|
||||||
|
final p2 = stroke.points[j + 1];
|
||||||
|
path.addLine(
|
||||||
|
normToSlide(p1.x, p1.y, draw),
|
||||||
|
normToSlide(p2.x, p2.y, draw),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
page.graphics.drawPath(
|
||||||
|
path,
|
||||||
|
pen: PdfPen(PdfColor(r, g, b),
|
||||||
|
width: slideStrokeWidth(stroke.width, draw)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _centerPage(Size viewport, Size pageSize) {
|
||||||
|
final o = centerOffset(pageSize, viewport, 1.0);
|
||||||
|
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||||
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
|
: (_penSlots?.active.width ?? 0.006);
|
||||||
|
|
||||||
|
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
|
||||||
|
|
||||||
|
void _selectStroke(int? index) {
|
||||||
|
setState(() => _selectedStroke = index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
|
||||||
|
final strokes = _currentStrokes;
|
||||||
|
if (index < 0 || index >= strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
if (isDragStart) _pushUndo();
|
||||||
|
final next = List<PenStroke>.from(strokes);
|
||||||
|
next[index] = translateStroke(next[index], dx, dy);
|
||||||
|
_strokesBySlide[_slideIndex] = next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteSelected() {
|
||||||
|
final idx = _selectedStroke;
|
||||||
|
final strokes = _currentStrokes;
|
||||||
|
if (idx == null || idx < 0 || idx >= strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
_pushUndo();
|
||||||
|
_strokesBySlide[_slideIndex] = [
|
||||||
|
...strokes.sublist(0, idx),
|
||||||
|
...strokes.sublist(idx + 1),
|
||||||
|
];
|
||||||
|
_selectedStroke = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return pageNavShortcuts(
|
||||||
|
onPrevious:
|
||||||
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
||||||
|
onNext: _slideIndex < _slideCount - 1
|
||||||
|
? () => _goToSlide(_slideIndex + 1)
|
||||||
|
: null,
|
||||||
|
onFirst: _slideCount > 0 ? () => _goToSlide(0) : null,
|
||||||
|
onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null,
|
||||||
|
child: Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(child: _buildCanvas()),
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: _buildToolPalette(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.arrow_back,
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.picture_as_pdf_outlined,
|
||||||
|
tooltip: 'Export to PDF',
|
||||||
|
onPressed: _exportPdf,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_slideCount > 0)
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: _buildSlidePill(cs),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCanvas() {
|
||||||
|
final sizes = _slideSizes;
|
||||||
|
if (sizes == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_slideCount == 0) {
|
||||||
|
return const Center(child: Text('No slides.'));
|
||||||
|
}
|
||||||
|
final slide = sizes[_slideIndex] ?? _fallbackSlide;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final fitW = constraints.maxWidth / slide.width;
|
||||||
|
final fitH = constraints.maxHeight / slide.height;
|
||||||
|
final scale = fitW < fitH ? fitW : fitH;
|
||||||
|
final pageSize = Size(slide.width * scale, slide.height * scale);
|
||||||
|
|
||||||
|
if (_needsCenter) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_centerPage(
|
||||||
|
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
|
||||||
|
setState(() => _needsCenter = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return PenCanvas(
|
||||||
|
key: ValueKey(_slideIndex),
|
||||||
|
pageSize: pageSize,
|
||||||
|
strokes: _currentStrokes,
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: editorToolToCanvas(_tool),
|
||||||
|
brush: _penBrush,
|
||||||
|
shapeKind: _shapeKind,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: _strokeWidth,
|
||||||
|
selectedStrokeIndex: _selectedStroke,
|
||||||
|
onSelectStroke: _selectStroke,
|
||||||
|
onMoveStroke: _moveStroke,
|
||||||
|
pressureGamma:
|
||||||
|
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
|
||||||
|
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
||||||
|
sideButtonAction:
|
||||||
|
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
||||||
|
eraserEndAction:
|
||||||
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||||
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
|
onStrokeComplete: _commitStroke,
|
||||||
|
onEraseStroke: _eraseStroke,
|
||||||
|
pageWidget: Image.file(
|
||||||
|
File(widget.slideImagePaths[_slideIndex]),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildToolPalette(ColorScheme cs) {
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// OneNote-style: each pen slot restores brush + color + thickness.
|
||||||
|
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
|
||||||
|
PenSlotButton(
|
||||||
|
kind: slot.brush,
|
||||||
|
selected: _tool == EditorToolKind.brush &&
|
||||||
|
(_penSlots?.activeId ?? 'slot_0') == slot.id,
|
||||||
|
color: slot.color,
|
||||||
|
widthHint: slot.width,
|
||||||
|
tooltip: brushLabelEn(slot.brush),
|
||||||
|
onPressed: () {
|
||||||
|
_penSlots?.select(slot.id);
|
||||||
|
setState(() => _tool = EditorToolKind.brush);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.brush_outlined,
|
||||||
|
selected: _tool == EditorToolKind.highlighter,
|
||||||
|
tooltip: 'Highlighter',
|
||||||
|
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.cleaning_services_outlined,
|
||||||
|
selected: _tool == EditorToolKind.eraser,
|
||||||
|
tooltip: 'Eraser',
|
||||||
|
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.ads_click,
|
||||||
|
selected: _tool == EditorToolKind.select,
|
||||||
|
tooltip: 'Select',
|
||||||
|
onPressed: () => setState(() => _tool = EditorToolKind.select),
|
||||||
|
),
|
||||||
|
ShapePickerButton(
|
||||||
|
selected: _shapeKind,
|
||||||
|
active: _tool == EditorToolKind.shape,
|
||||||
|
tooltip: 'Shape',
|
||||||
|
labelFor: shapeLabelEn,
|
||||||
|
onActivate: () => setState(() => _tool = EditorToolKind.shape),
|
||||||
|
onSelected: (s) => setState(() {
|
||||||
|
_shapeKind = s;
|
||||||
|
_tool = EditorToolKind.shape;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (_tool == EditorToolKind.select && _selectedStroke != null)
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Delete selection',
|
||||||
|
onPressed: _deleteSelected,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.undo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Undo',
|
||||||
|
onPressed:
|
||||||
|
(_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.redo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Redo',
|
||||||
|
onPressed:
|
||||||
|
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
|
ThicknessPickerButton(
|
||||||
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
|
selected: _allowFingerDrawing,
|
||||||
|
tooltip: _allowFingerDrawing
|
||||||
|
? 'Finger drawing ON'
|
||||||
|
: 'Finger drawing OFF (pen only)',
|
||||||
|
onPressed: _toggleFingerDrawing,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Pen settings (width, pressure, eraser…)',
|
||||||
|
onPressed: _penConfig != null
|
||||||
|
? () => showPenSettingsSheet(context, _penConfig!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
|
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||||
|
_tool != EditorToolKind.eraser &&
|
||||||
|
_tool != EditorToolKind.select;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_tool == EditorToolKind.eraser ||
|
||||||
|
_tool == EditorToolKind.select) {
|
||||||
|
setState(() => _tool = EditorToolKind.brush);
|
||||||
|
}
|
||||||
|
if (_tool == EditorToolKind.highlighter) {
|
||||||
|
setState(() => _highlighterColor = c);
|
||||||
|
} else {
|
||||||
|
_penSlots?.setActiveColor(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? cs.onSurface : cs.outlineVariant,
|
||||||
|
width: selected ? 3 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSlidePill(ColorScheme cs) {
|
||||||
|
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (_showSlider && _slideCount > 1)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Slider(
|
||||||
|
min: 1,
|
||||||
|
max: _slideCount.toDouble(),
|
||||||
|
value: (_scrub ?? (_slideIndex + 1).toDouble())
|
||||||
|
.clamp(1, _slideCount.toDouble()),
|
||||||
|
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
||||||
|
onChanged: (v) => setState(() => _scrub = v),
|
||||||
|
onChangeEnd: (v) {
|
||||||
|
final target = v.round() - 1;
|
||||||
|
setState(() {
|
||||||
|
_scrub = v;
|
||||||
|
_slideIndex = target;
|
||||||
|
});
|
||||||
|
_goToSlide(target);
|
||||||
|
setState(() {
|
||||||
|
_scrub = null;
|
||||||
|
_showSlider = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Previous slide',
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed:
|
||||||
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _slideCount > 1
|
||||||
|
? () => setState(() => _showSlider = !_showSlider)
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
'$shown / $_slideCount',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Next slide',
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: _slideIndex < _slideCount - 1
|
||||||
|
? () => _goToSlide(_slideIndex + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
|
||||||
/// A single captured sample of a stroke.
|
/// A single captured sample of a stroke.
|
||||||
///
|
///
|
||||||
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
||||||
@@ -35,6 +37,7 @@ class PenStroke {
|
|||||||
required this.color,
|
required this.color,
|
||||||
required this.width,
|
required this.width,
|
||||||
required this.kind,
|
required this.kind,
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Normalized points (see [PenPoint]).
|
/// Normalized points (see [PenPoint]).
|
||||||
@@ -48,4 +51,12 @@ class PenStroke {
|
|||||||
final double width;
|
final double width;
|
||||||
|
|
||||||
final PenStrokeKind kind;
|
final PenStrokeKind kind;
|
||||||
|
|
||||||
|
/// The brush this stroke was drawn with — drives the perfect_freehand
|
||||||
|
/// geometry (thinning/streamline/smoothing/caps) at render time via
|
||||||
|
/// [brushProfileFor]. The pressure pre-warp ([BrushProfile.pressureGamma]) is
|
||||||
|
/// applied at CAPTURE so it is already baked into [points]. Defaults to
|
||||||
|
/// [BrushKind.fountainPen] (the legacy pen visual) so old/loaded strokes keep
|
||||||
|
/// rendering as before.
|
||||||
|
final BrushKind brush;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
|
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
|
||||||
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
|
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
|
||||||
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
|
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
|
||||||
|
//
|
||||||
|
// Soft-clamp (Surface 2026-08-05 diag): a HARD drop of frames whose per-step
|
||||||
|
// ratio exceeds the glitch band caused an avalanche — lastRaw never advanced,
|
||||||
|
// so every subsequent frame also dropped while pdfrx/live zoom still crawled.
|
||||||
|
// [softClampedPinchStep] always returns an applied scale, clamping the step,
|
||||||
|
// and tells the caller to re-anchor when a spike was clipped.
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show clampDouble;
|
import 'package:flutter/foundation.dart' show clampDouble;
|
||||||
|
|
||||||
@@ -39,3 +45,59 @@ double absolutePinchScale({
|
|||||||
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
||||||
return clampDouble(scaleStart * cumulative, minScale, maxScale);
|
return clampDouble(scaleStart * cumulative, minScale, maxScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of one soft-clamped pinch step.
|
||||||
|
class SoftPinchStep {
|
||||||
|
const SoftPinchStep({
|
||||||
|
required this.appliedScale,
|
||||||
|
required this.reanchor,
|
||||||
|
required this.spiked,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Scale to write into the matrix / controller this frame.
|
||||||
|
final double appliedScale;
|
||||||
|
|
||||||
|
/// When true the caller must set `scaleStart = appliedScale` and
|
||||||
|
/// `rawScaleAtBaseline = rawScale` so absolute tracking does not keep
|
||||||
|
/// fighting the clamp on later frames.
|
||||||
|
final bool reanchor;
|
||||||
|
|
||||||
|
/// True when the ideal absolute target was clipped by the per-step band.
|
||||||
|
final bool spiked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-clamp the per-frame scale change instead of dropping the frame.
|
||||||
|
///
|
||||||
|
/// Ideal scale comes from [absolutePinchScale]. The step from
|
||||||
|
/// [lastAppliedScale] is then limited to `[1/maxStepRatio, maxStepRatio]`.
|
||||||
|
/// Spikes still get partially applied (smooth catch-up) and the caller
|
||||||
|
/// re-anchors so the next frame starts clean.
|
||||||
|
SoftPinchStep softClampedPinchStep({
|
||||||
|
required double scaleStart,
|
||||||
|
required double rawScaleAtBaseline,
|
||||||
|
required double rawScale,
|
||||||
|
required double lastAppliedScale,
|
||||||
|
required double minScale,
|
||||||
|
required double maxScale,
|
||||||
|
required double maxStepRatio,
|
||||||
|
}) {
|
||||||
|
final ideal = absolutePinchScale(
|
||||||
|
scaleStart: scaleStart,
|
||||||
|
rawScaleAtBaseline: rawScaleAtBaseline,
|
||||||
|
rawScale: rawScale,
|
||||||
|
minScale: minScale,
|
||||||
|
maxScale: maxScale,
|
||||||
|
);
|
||||||
|
if (lastAppliedScale <= 0 || maxStepRatio <= 1.0) {
|
||||||
|
return SoftPinchStep(appliedScale: ideal, reanchor: false, spiked: false);
|
||||||
|
}
|
||||||
|
final lo = lastAppliedScale / maxStepRatio;
|
||||||
|
final hi = lastAppliedScale * maxStepRatio;
|
||||||
|
final applied = clampDouble(ideal, lo, hi);
|
||||||
|
final spiked = applied != ideal;
|
||||||
|
return SoftPinchStep(
|
||||||
|
appliedScale: clampDouble(applied, minScale, maxScale),
|
||||||
|
reanchor: spiked,
|
||||||
|
spiked: spiked,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
291
lib/editor/canvas/sticky_note_overlay.dart
Normal file
291
lib/editor/canvas/sticky_note_overlay.dart
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
// lib/editor/canvas/sticky_note_overlay.dart
|
||||||
|
//
|
||||||
|
// Page-anchored paper sticky: sized/positioned by the parent in page space,
|
||||||
|
// shares the editor brush/color/tool, locks inner pan/zoom so writing feels
|
||||||
|
// like drawing on the sticky surface itself.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../models/ink_stroke.dart';
|
||||||
|
import '../../models/scratch_link.dart';
|
||||||
|
import '../../storage/badnote_sidecar.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
import '../input/pen_config.dart' show kDefaultEraserRadius;
|
||||||
|
import '../notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../persistence/sidecar_repository.dart';
|
||||||
|
import 'editor_tool.dart';
|
||||||
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
/// Default world size for a fresh sticky scratchpad (absolute px).
|
||||||
|
const Size kStickyWorldSize = Size(1200, 900);
|
||||||
|
|
||||||
|
/// Floating sticky-note card glued to a PDF page (parent supplies pixel size).
|
||||||
|
class StickyNoteOverlay extends StatefulWidget {
|
||||||
|
const StickyNoteOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.link,
|
||||||
|
required this.repo,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.onDragPx,
|
||||||
|
required this.onResizePx,
|
||||||
|
this.brush = BrushKind.ballpoint,
|
||||||
|
this.color = const Color(0xFF1A1A1A),
|
||||||
|
this.tool = EditorToolKind.brush,
|
||||||
|
this.strokeWidth = 0.008,
|
||||||
|
this.allowFingerDrawing = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ScratchLink link;
|
||||||
|
final SidecarRepository repo;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
/// Header drag delta in viewer/page pixels.
|
||||||
|
final void Function(double dx, double dy) onDragPx;
|
||||||
|
|
||||||
|
/// Corner resize delta in viewer/page pixels.
|
||||||
|
final void Function(double dx, double dy) onResizePx;
|
||||||
|
|
||||||
|
final BrushKind brush;
|
||||||
|
final Color color;
|
||||||
|
final EditorToolKind tool;
|
||||||
|
final double strokeWidth;
|
||||||
|
final bool allowFingerDrawing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
final TransformationController _transform = TransformationController();
|
||||||
|
List<InkStroke> _strokes = [];
|
||||||
|
Size _world = kStickyWorldSize;
|
||||||
|
Timer? _saveTimer;
|
||||||
|
bool _dirty = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final pad = widget.repo.scratchpadFor(widget.link.id);
|
||||||
|
if (pad != null) {
|
||||||
|
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
||||||
|
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
if (_dirty) {
|
||||||
|
_persist(flush: true);
|
||||||
|
}
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSave() {
|
||||||
|
_dirty = true;
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
_saveTimer = Timer(const Duration(milliseconds: 600), () => _persist());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist({bool flush = false}) async {
|
||||||
|
if (!_dirty && !flush) return;
|
||||||
|
widget.repo.scheduleScratchpadSave(
|
||||||
|
widget.link.id,
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: _world.width,
|
||||||
|
canvasHeight: _world.height,
|
||||||
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_dirty = false;
|
||||||
|
if (flush) await widget.repo.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStrokeComplete(PenStroke pen) {
|
||||||
|
setState(() {
|
||||||
|
_strokes = [
|
||||||
|
..._strokes,
|
||||||
|
inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onErase(int index, List<PenStroke> replacements) {
|
||||||
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
|
setState(() {
|
||||||
|
final next = List<InkStroke>.of(_strokes)..removeAt(index);
|
||||||
|
for (final r in replacements) {
|
||||||
|
next.insert(
|
||||||
|
index,
|
||||||
|
inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_strokes = next;
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _close() async {
|
||||||
|
_saveTimer?.cancel();
|
||||||
|
await _persist(flush: true);
|
||||||
|
widget.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasTool get _canvasTool {
|
||||||
|
switch (widget.tool) {
|
||||||
|
case EditorToolKind.eraser:
|
||||||
|
return CanvasTool.eraser;
|
||||||
|
case EditorToolKind.select:
|
||||||
|
return CanvasTool.select;
|
||||||
|
case EditorToolKind.highlighter:
|
||||||
|
return CanvasTool.highlighter;
|
||||||
|
case EditorToolKind.brush:
|
||||||
|
case EditorToolKind.shape:
|
||||||
|
case EditorToolKind.text:
|
||||||
|
return CanvasTool.pen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BrushKind get _canvasBrush =>
|
||||||
|
widget.tool == EditorToolKind.highlighter
|
||||||
|
? BrushKind.highlighter
|
||||||
|
: widget.brush;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Material(
|
||||||
|
elevation: 8,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
color: const Color(0xFFFFF8E1),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
_StickyHeader(
|
||||||
|
onDragDelta: widget.onDragPx,
|
||||||
|
onClose: _close,
|
||||||
|
onDelete: () async {
|
||||||
|
await _persist(flush: true);
|
||||||
|
widget.onDelete();
|
||||||
|
},
|
||||||
|
cs: cs,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: PenCanvas(
|
||||||
|
pageSize: _world,
|
||||||
|
strokes: penStrokesFromInk(_strokes, _world),
|
||||||
|
transformationController: _transform,
|
||||||
|
tool: _canvasTool,
|
||||||
|
brush: _canvasBrush,
|
||||||
|
color: widget.color,
|
||||||
|
strokeWidth: widget.strokeWidth,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
|
allowFingerDrawing: widget.allowFingerDrawing,
|
||||||
|
scaleEnabled: false,
|
||||||
|
panEnabled: false,
|
||||||
|
minScale: 1.0,
|
||||||
|
maxScale: 1.0,
|
||||||
|
onStrokeComplete: _onStrokeComplete,
|
||||||
|
onEraseStroke: _onErase,
|
||||||
|
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy),
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeUpLeftDownRight,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: Icon(
|
||||||
|
Icons.south_east,
|
||||||
|
size: 16,
|
||||||
|
color: cs.onSurface.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickyHeader extends StatelessWidget {
|
||||||
|
const _StickyHeader({
|
||||||
|
required this.onDragDelta,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.cs,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(double dx, double dy) onDragDelta;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
final ColorScheme cs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy),
|
||||||
|
child: Container(
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFFE082),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.drag_indicator, size: 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'便利贴',
|
||||||
|
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'拖标题定位 · 角缩放',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: cs.onSurface.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '删除',
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: onDelete,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '收起',
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
292
lib/editor/engine/brush.dart
Normal file
292
lib/editor/engine/brush.dart
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
// lib/editor/engine/brush.dart
|
||||||
|
//
|
||||||
|
// Data-driven, Krita-compatible brush model — the extensibility seam for the
|
||||||
|
// pen engine. Each [BrushKind] maps to an immutable [BrushProfile] that fully
|
||||||
|
// describes how a stroke is captured (pressure pre-warp) and rendered
|
||||||
|
// (perfect_freehand geometry params + caps/taper). Adding a brush = adding one
|
||||||
|
// const entry to [kBrushPresets]; no render-path branching.
|
||||||
|
//
|
||||||
|
// Mirrors Krita's sensor→curve design (Pixel brush: each property is driven by
|
||||||
|
// a sensor through a response curve). Here the response curve is a pure power
|
||||||
|
// law `p^gamma` applied to pressure BEFORE perfect_freehand (rnote's
|
||||||
|
// `PressureCurve`: Pow2 = quadratic, Sqrt = √p), and the geometry knobs are
|
||||||
|
// perfect_freehand's `thinning`/`streamline`/`smoothing`/caps. A future `.kpp`
|
||||||
|
// (Krita brush preset) importer can produce [BrushProfile]s from the same
|
||||||
|
// fields — see TODO(brush-kpp-import).
|
||||||
|
//
|
||||||
|
// Source spec: docs/research/pen-brush-spec.md §1 (rnote pressure curve) and §4
|
||||||
|
// (per-brush perfect_freehand option tables). The numbers below are lifted from
|
||||||
|
// that spec verbatim.
|
||||||
|
|
||||||
|
import 'dart:ui' show Color, BlendMode;
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
/// The four selectable brushes. Extensible: add a kind here + a preset in
|
||||||
|
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
|
||||||
|
///
|
||||||
|
/// The `@JsonValue` names are the STABLE on-disk identifiers persisted in the
|
||||||
|
/// sidecar (`EditorStroke.brush`); they are decoupled from the Dart enum
|
||||||
|
/// identifiers so renaming a constant here never breaks existing sidecars. A
|
||||||
|
/// brush whose stored name is unknown (e.g. a future brush opened by an older
|
||||||
|
/// build) is read back as [fountainPen] (see `EditorStroke.brush`'s JsonKey).
|
||||||
|
enum BrushKind {
|
||||||
|
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
|
||||||
|
@JsonValue('fountainPen')
|
||||||
|
fountainPen,
|
||||||
|
|
||||||
|
/// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell").
|
||||||
|
@JsonValue('ballpoint')
|
||||||
|
ballpoint,
|
||||||
|
|
||||||
|
/// Broad, flat width, translucent, square (uncapped) ends.
|
||||||
|
@JsonValue('highlighter')
|
||||||
|
highlighter,
|
||||||
|
|
||||||
|
/// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy.
|
||||||
|
@JsonValue('pencil')
|
||||||
|
pencil,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable, const description of one brush.
|
||||||
|
///
|
||||||
|
/// The capture path reads [pressureGamma] (the rnote power-law warp applied via
|
||||||
|
/// `PressureCurve(gamma: pressureGamma)` BEFORE perfect_freehand) and the render
|
||||||
|
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
|
||||||
|
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
|
||||||
|
///
|
||||||
|
/// [opacity] / [blendMultiply] drive the painters' compositing via
|
||||||
|
/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied
|
||||||
|
/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see
|
||||||
|
/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply].
|
||||||
|
class BrushProfile {
|
||||||
|
const BrushProfile({
|
||||||
|
required this.kind,
|
||||||
|
required this.baseWidthFraction,
|
||||||
|
required this.pressureGamma,
|
||||||
|
required this.pfThinning,
|
||||||
|
required this.pfStreamline,
|
||||||
|
required this.pfSmoothing,
|
||||||
|
required this.simulatePressure,
|
||||||
|
required this.capStart,
|
||||||
|
required this.capEnd,
|
||||||
|
required this.taper,
|
||||||
|
required this.opacity,
|
||||||
|
required this.blendMultiply,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Which brush this profile is for.
|
||||||
|
final BrushKind kind;
|
||||||
|
|
||||||
|
/// Suggested base stroke width as a fraction of page width (so it scales with
|
||||||
|
/// zoom, matching `PenStroke.width`). The editors may override with their own
|
||||||
|
/// configured pen/highlighter widths; this is the spec's nominal default
|
||||||
|
/// (spec §4 diameters, expressed as a page-width fraction).
|
||||||
|
final double baseWidthFraction;
|
||||||
|
|
||||||
|
/// rnote `PressureCurve` exponent applied to raw pressure at CAPTURE, before
|
||||||
|
/// perfect_freehand. `2.0` = Pow2 (quadratic, fountain pen); `0.5` = Sqrt
|
||||||
|
/// (pencil); `1.0` = Linear (ballpoint / highlighter). Fed through the
|
||||||
|
/// existing `PressureCurve(gamma: …)` — no new pow function (spec §1).
|
||||||
|
final double pressureGamma;
|
||||||
|
|
||||||
|
/// perfect_freehand `thinning`: how strongly (pre-warped) pressure modulates
|
||||||
|
/// width. `0.0` = constant width (highlighter); high = wide dynamic range
|
||||||
|
/// (fountain pen) (spec §4).
|
||||||
|
final double pfThinning;
|
||||||
|
|
||||||
|
/// perfect_freehand `streamline`: EMA low-pass on input positions (spec §4).
|
||||||
|
final double pfStreamline;
|
||||||
|
|
||||||
|
/// perfect_freehand `smoothing`: outline corner-softening (spec §4).
|
||||||
|
final double pfSmoothing;
|
||||||
|
|
||||||
|
/// perfect_freehand `simulatePressure`: when true, fakes pressure from
|
||||||
|
/// velocity. All four presets ship `false` so REAL stylus pressure (already
|
||||||
|
/// pre-warped by [pressureGamma]) drives width (spec §4). The render path
|
||||||
|
/// still falls back to simulation when the device reports NO usable pressure.
|
||||||
|
final bool simulatePressure;
|
||||||
|
|
||||||
|
/// Round cap on the start of the stroke (false = square end, highlighter).
|
||||||
|
final bool capStart;
|
||||||
|
|
||||||
|
/// Round cap on the end of the stroke (false = square end, highlighter).
|
||||||
|
final bool capEnd;
|
||||||
|
|
||||||
|
/// Whether the ends taper to a point (fountain pen) (spec §4).
|
||||||
|
final bool taper;
|
||||||
|
|
||||||
|
/// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter
|
||||||
|
/// this flat value is used; ballpoint/pencil derive opacity from pressure
|
||||||
|
/// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters
|
||||||
|
/// via [resolveStrokePaint] (multiplied into the stroke color's alpha).
|
||||||
|
final double opacity;
|
||||||
|
|
||||||
|
/// Whether the brush composites with [BlendMode.multiply] (highlighter
|
||||||
|
/// build-up / marker feel). Applied by [resolveStrokePaint].
|
||||||
|
final bool blendMultiply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 4 brush presets, populated from the spec §4 tables.
|
||||||
|
///
|
||||||
|
/// Widths are the spec's logical-px diameters re-expressed as page-width
|
||||||
|
/// fractions against the project's ~1000px logical page (the existing
|
||||||
|
/// pen/highlighter widths are 0.006 / 0.02). Fountain pen ≈ pen (0.006),
|
||||||
|
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
|
||||||
|
/// the fountainPen/highlighter presets (no regression).
|
||||||
|
const Map<BrushKind, BrushProfile> kBrushPresets = {
|
||||||
|
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
|
||||||
|
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
|
||||||
|
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
|
||||||
|
BrushKind.fountainPen: BrushProfile(
|
||||||
|
kind: BrushKind.fountainPen,
|
||||||
|
baseWidthFraction: 0.006,
|
||||||
|
pressureGamma: 2.0,
|
||||||
|
pfThinning: 0.75,
|
||||||
|
pfStreamline: 0.22,
|
||||||
|
pfSmoothing: 0.5,
|
||||||
|
simulatePressure: false,
|
||||||
|
capStart: true,
|
||||||
|
capEnd: true,
|
||||||
|
// Light taper only; full taper made Chinese characters look frayed.
|
||||||
|
taper: false,
|
||||||
|
opacity: 1.0,
|
||||||
|
blendMultiply: false,
|
||||||
|
),
|
||||||
|
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
|
||||||
|
// for lower latency; thinning 0.12 keeps width almost flat.
|
||||||
|
BrushKind.ballpoint: BrushProfile(
|
||||||
|
kind: BrushKind.ballpoint,
|
||||||
|
baseWidthFraction: 0.0022,
|
||||||
|
pressureGamma: 1.0,
|
||||||
|
pfThinning: 0.12,
|
||||||
|
pfStreamline: 0.35,
|
||||||
|
pfSmoothing: 0.5,
|
||||||
|
simulatePressure: false,
|
||||||
|
capStart: true,
|
||||||
|
capEnd: true,
|
||||||
|
taper: false,
|
||||||
|
opacity: 1.0,
|
||||||
|
blendMultiply: false,
|
||||||
|
),
|
||||||
|
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
|
||||||
|
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
|
||||||
|
BrushKind.highlighter: BrushProfile(
|
||||||
|
kind: BrushKind.highlighter,
|
||||||
|
baseWidthFraction: 0.02,
|
||||||
|
pressureGamma: 1.0,
|
||||||
|
pfThinning: 0.0,
|
||||||
|
pfStreamline: 0.3,
|
||||||
|
pfSmoothing: 0.4,
|
||||||
|
simulatePressure: false,
|
||||||
|
capStart: false,
|
||||||
|
capEnd: false,
|
||||||
|
taper: false,
|
||||||
|
opacity: 0.35,
|
||||||
|
blendMultiply: true,
|
||||||
|
),
|
||||||
|
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
|
||||||
|
BrushKind.pencil: BrushProfile(
|
||||||
|
kind: BrushKind.pencil,
|
||||||
|
baseWidthFraction: 0.003,
|
||||||
|
pressureGamma: 0.5,
|
||||||
|
pfThinning: 0.45,
|
||||||
|
pfStreamline: 0.25,
|
||||||
|
pfSmoothing: 0.45,
|
||||||
|
simulatePressure: false,
|
||||||
|
capStart: true,
|
||||||
|
capEnd: true,
|
||||||
|
taper: false,
|
||||||
|
opacity: 0.88,
|
||||||
|
blendMultiply: false,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve the [BrushProfile] for [kind] (always present; const map).
|
||||||
|
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
|
||||||
|
|
||||||
|
// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) -------------
|
||||||
|
//
|
||||||
|
// perfect_freehand produces a single closed fill polygon per stroke; the
|
||||||
|
// painters then fill it with ONE Paint. These helpers resolve that Paint's
|
||||||
|
// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel
|
||||||
|
// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in
|
||||||
|
// the freehand path. Both render paths (PenCanvas + the PDF
|
||||||
|
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
|
||||||
|
|
||||||
|
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
|
||||||
|
/// stroke's AVERAGE pressure [pressureAvg].
|
||||||
|
///
|
||||||
|
/// Krita-inspired (not a full brush engine): ballpoint stays essentially solid
|
||||||
|
/// (width carries the pressure feel); pencil uses a soft √p curve capped below
|
||||||
|
/// 1 so light strokes stay grey without multiply-style mud; fountain/highlighter
|
||||||
|
/// use the flat profile opacity. Per-dab / textured brushes remain deferred.
|
||||||
|
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
|
||||||
|
final p = pressureAvg.clamp(0.0, 1.0);
|
||||||
|
switch (profile.kind) {
|
||||||
|
// Solid ink — tiny residual so "hover contact" can't punch full black holes
|
||||||
|
// into overlapping strokes, but no 0.55 floor translucency stacking.
|
||||||
|
case BrushKind.ballpoint:
|
||||||
|
return (0.92 + 0.08 * p).clamp(0.0, 1.0);
|
||||||
|
// Soft graphite: √p darkens quickly under pressure, capped by profile.
|
||||||
|
case BrushKind.pencil:
|
||||||
|
final soft = 0.50 + 0.38 * _sqrt01(p);
|
||||||
|
return soft.clamp(0.0, profile.opacity);
|
||||||
|
case BrushKind.fountainPen:
|
||||||
|
case BrushKind.highlighter:
|
||||||
|
return profile.opacity.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double _sqrt01(double v) {
|
||||||
|
if (v <= 0) return 0;
|
||||||
|
if (v >= 1) return 1;
|
||||||
|
var x = v;
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
x = 0.5 * (x + v / x);
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
|
||||||
|
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
|
||||||
|
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
|
||||||
|
/// double-counting — the profile opacity scales whatever alpha the color has.
|
||||||
|
int applyOpacityToArgb(int argb, double opacity) {
|
||||||
|
final baseAlpha = (argb >> 24) & 0xFF;
|
||||||
|
final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255);
|
||||||
|
return (scaled << 24) | (argb & 0x00FFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every
|
||||||
|
/// painter can configure its `Paint` identically. [argb] is the stroke's stored
|
||||||
|
/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`).
|
||||||
|
///
|
||||||
|
/// - [color]: stroke color with `profile`-resolved opacity multiplied into its
|
||||||
|
/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter).
|
||||||
|
/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up:
|
||||||
|
/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke
|
||||||
|
/// is still drawn ONCE per render (single fill polygon) so its OWN self-
|
||||||
|
/// overlap never darkens — that single-draw invariant lives in the painters.
|
||||||
|
class ResolvedStrokePaint {
|
||||||
|
const ResolvedStrokePaint({required this.color, required this.blendMode});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final BlendMode blendMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the paint config for a stroke drawn with [kind]. See
|
||||||
|
/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is
|
||||||
|
/// still deferred — opacity is enough for this increment.
|
||||||
|
ResolvedStrokePaint resolveStrokePaint(
|
||||||
|
BrushKind kind,
|
||||||
|
int argb, {
|
||||||
|
double pressureAvg = 0.5,
|
||||||
|
}) {
|
||||||
|
final profile = brushProfileFor(kind);
|
||||||
|
final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg);
|
||||||
|
return ResolvedStrokePaint(
|
||||||
|
color: Color(applyOpacityToArgb(argb, opacity)),
|
||||||
|
blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver,
|
||||||
|
);
|
||||||
|
}
|
||||||
32
lib/editor/engine/pen_physics.dart
Normal file
32
lib/editor/engine/pen_physics.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// lib/editor/engine/pen_physics.dart
|
||||||
|
//
|
||||||
|
// Simple physical tip model: modulate stroke width by tip velocity so fountain
|
||||||
|
// ink feels slightly thinner when moving fast (starvation), while ballpoint
|
||||||
|
// stays nearly velocity-invariant.
|
||||||
|
//
|
||||||
|
// Wired at capture: PenCanvas._toNormalized and PenEditorScreen._pressureWithPhysics.
|
||||||
|
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
|
/// Modulate width fraction by tip velocity (page-normalized units per second).
|
||||||
|
///
|
||||||
|
/// Fountain: faster → slightly thinner (ink starvation feel).
|
||||||
|
/// Ballpoint: nearly ignore velocity.
|
||||||
|
/// Pencil: mild thinning at speed.
|
||||||
|
/// Highlighter: ignore velocity (flat marker).
|
||||||
|
double tipVelocityWidthScale(BrushKind kind, double speedNormPerSec) {
|
||||||
|
final speed =
|
||||||
|
speedNormPerSec.isNaN || speedNormPerSec < 0 ? 0.0 : speedNormPerSec;
|
||||||
|
// Reference: ~2 page-widths/sec ≈ fast handwriting; clamp influence to [0,1].
|
||||||
|
final t = (speed / 2.0).clamp(0.0, 1.0);
|
||||||
|
switch (kind) {
|
||||||
|
case BrushKind.fountainPen:
|
||||||
|
return 1.0 - 0.15 * t;
|
||||||
|
case BrushKind.ballpoint:
|
||||||
|
return 1.0 - 0.02 * t;
|
||||||
|
case BrushKind.pencil:
|
||||||
|
return 1.0 - 0.08 * t;
|
||||||
|
case BrushKind.highlighter:
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/editor/engine/shape_geometry.dart
Normal file
143
lib/editor/engine/shape_geometry.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// lib/editor/engine/shape_geometry.dart
|
||||||
|
//
|
||||||
|
// Pure geometry for the SHAPE tool. Each shape is generated as a list of
|
||||||
|
// NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is
|
||||||
|
// just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo
|
||||||
|
// with NO new model or storage. Points carry a constant pressure (1.0) so the
|
||||||
|
// brush renders them at a steady width (shapes don't taper with pressure).
|
||||||
|
//
|
||||||
|
// All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the
|
||||||
|
// caller wraps the points in a PenStroke with the current brush color/width.
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import '../canvas/editor_tool.dart';
|
||||||
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
|
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
|
||||||
|
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
|
||||||
|
/// last point repeats the first ⇒ [kEllipseSamples] + 1 total points.
|
||||||
|
const int kEllipseSamples = 48;
|
||||||
|
|
||||||
|
/// Constant pressure baked into every shape point so the brush renders a steady
|
||||||
|
/// width (no pressure taper for geometric shapes).
|
||||||
|
const double _kShapePressure = 1.0;
|
||||||
|
|
||||||
|
/// Geometric shapes must NOT inherit fountain thinning/taper — force a near-
|
||||||
|
/// constant-width brush so line/rect/ellipse look like ruler ink.
|
||||||
|
const BrushKind kShapeBrush = BrushKind.ballpoint;
|
||||||
|
|
||||||
|
/// Generate the normalized polyline for [kind] spanning [start] → [end].
|
||||||
|
///
|
||||||
|
/// * [ShapeKind.line] → 2 points.
|
||||||
|
/// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the
|
||||||
|
/// first), an axis-aligned box whose opposite corners are [start]/[end].
|
||||||
|
/// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed
|
||||||
|
/// in the [start]→[end] bounding box.
|
||||||
|
/// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments,
|
||||||
|
/// emitted as a single polyline so it renders as one stroke.
|
||||||
|
List<PenPoint> generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) {
|
||||||
|
switch (kind) {
|
||||||
|
case ShapeKind.line:
|
||||||
|
return [
|
||||||
|
PenPoint(start.x, start.y, _kShapePressure),
|
||||||
|
PenPoint(end.x, end.y, _kShapePressure),
|
||||||
|
];
|
||||||
|
|
||||||
|
case ShapeKind.rectangle:
|
||||||
|
final l = math.min(start.x, end.x);
|
||||||
|
final r = math.max(start.x, end.x);
|
||||||
|
final t = math.min(start.y, end.y);
|
||||||
|
final b = math.max(start.y, end.y);
|
||||||
|
return [
|
||||||
|
PenPoint(l, t, _kShapePressure),
|
||||||
|
PenPoint(r, t, _kShapePressure),
|
||||||
|
PenPoint(r, b, _kShapePressure),
|
||||||
|
PenPoint(l, b, _kShapePressure),
|
||||||
|
PenPoint(l, t, _kShapePressure), // close
|
||||||
|
];
|
||||||
|
|
||||||
|
case ShapeKind.ellipse:
|
||||||
|
final cx = (start.x + end.x) / 2;
|
||||||
|
final cy = (start.y + end.y) / 2;
|
||||||
|
final rx = (end.x - start.x).abs() / 2;
|
||||||
|
final ry = (end.y - start.y).abs() / 2;
|
||||||
|
final pts = <PenPoint>[];
|
||||||
|
for (var i = 0; i <= kEllipseSamples; i++) {
|
||||||
|
final a = (i / kEllipseSamples) * 2 * math.pi;
|
||||||
|
pts.add(PenPoint(
|
||||||
|
cx + rx * math.cos(a),
|
||||||
|
cy + ry * math.sin(a),
|
||||||
|
_kShapePressure,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
|
||||||
|
case ShapeKind.arrow:
|
||||||
|
// Shaft start→end, then back up the shaft to draw the two head barbs so
|
||||||
|
// the whole arrow is one continuous polyline (no pen lifts).
|
||||||
|
final dx = end.x - start.x;
|
||||||
|
final dy = end.y - start.y;
|
||||||
|
final len = math.sqrt(dx * dx + dy * dy);
|
||||||
|
final pts = <PenPoint>[
|
||||||
|
PenPoint(start.x, start.y, _kShapePressure),
|
||||||
|
PenPoint(end.x, end.y, _kShapePressure),
|
||||||
|
];
|
||||||
|
if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft
|
||||||
|
// Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction,
|
||||||
|
// [_kArrowHead] of the shaft length (capped) long.
|
||||||
|
final ang = math.atan2(dy, dx);
|
||||||
|
final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax);
|
||||||
|
for (final sign in const [1.0, -1.0]) {
|
||||||
|
final a = ang + math.pi + sign * _kArrowAngle;
|
||||||
|
pts.add(PenPoint(
|
||||||
|
end.x + head * math.cos(a),
|
||||||
|
end.y + head * math.sin(a),
|
||||||
|
_kShapePressure,
|
||||||
|
));
|
||||||
|
pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arrowhead barb length as a fraction of the shaft length.
|
||||||
|
const double _kArrowHeadFraction = 0.25;
|
||||||
|
|
||||||
|
/// Hard cap on the barb length (normalized) so a long arrow's head stays sane.
|
||||||
|
const double _kArrowHeadMax = 0.06;
|
||||||
|
|
||||||
|
/// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°).
|
||||||
|
const double _kArrowAngle = 0.5;
|
||||||
|
|
||||||
|
/// Return a copy of [points] translated by ([dx],[dy]) in normalized coords,
|
||||||
|
/// preserving pressure/tilt. Used by the SELECT tool to drag a stroke.
|
||||||
|
List<PenPoint> translatePoints(List<PenPoint> points, double dx, double dy) =>
|
||||||
|
[for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)];
|
||||||
|
|
||||||
|
/// A translated copy of [stroke] (its points shifted by [dx],[dy]); color,
|
||||||
|
/// width, kind, and brush are preserved.
|
||||||
|
PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke(
|
||||||
|
points: translatePoints(stroke.points, dx, dy),
|
||||||
|
color: stroke.color,
|
||||||
|
width: stroke.width,
|
||||||
|
kind: stroke.kind,
|
||||||
|
brush: stroke.brush,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Tight normalized bounds of [stroke]'s points, or null when it has no points.
|
||||||
|
/// Used by the SELECT tool to draw the selection bounding box.
|
||||||
|
({double left, double top, double right, double bottom})? penStrokeBounds(
|
||||||
|
PenStroke stroke) {
|
||||||
|
if (stroke.points.isEmpty) return null;
|
||||||
|
var l = double.infinity, t = double.infinity;
|
||||||
|
var r = double.negativeInfinity, b = double.negativeInfinity;
|
||||||
|
for (final p in stroke.points) {
|
||||||
|
if (p.x < l) l = p.x;
|
||||||
|
if (p.y < t) t = p.y;
|
||||||
|
if (p.x > r) r = p.x;
|
||||||
|
if (p.y > b) b = p.y;
|
||||||
|
}
|
||||||
|
return (left: l, top: t, right: r, bottom: b);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import 'brush.dart';
|
||||||
import 'stroke_model.dart';
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
|
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
|
||||||
@@ -24,12 +25,11 @@ const double kDefaultPenThinning = 0.85;
|
|||||||
/// perfect_freehand input-smoothing parameters, shared (single source of truth)
|
/// perfect_freehand input-smoothing parameters, shared (single source of truth)
|
||||||
/// by the on-screen painter and the export path so the two can never diverge
|
/// by the on-screen painter and the export path so the two can never diverge
|
||||||
/// (guarded by the screen==export parity test). [kPenStreamline] lowers the
|
/// (guarded by the screen==export parity test). [kPenStreamline] lowers the
|
||||||
/// per-point lag from freehand's 0.5 default to 0.32: at 0.5 a quick flick lags
|
/// per-point lag from freehand's 0.5 default to 0.28: paired with
|
||||||
/// so far behind the pen that a short fast stroke collapsed toward its start and
|
/// [StrokePredictor] lookahead this tracks the Surface Pen more tightly while
|
||||||
/// rendered as a dot ("写字识别成单击") and the pen felt sluggish; 0.32 tracks the
|
/// still damping digitizer jitter. [kPenSmoothing] keeps freehand's 0.5 corner
|
||||||
/// real path closely (crisper, lower-latency feel) while still damping digitizer
|
/// rounding.
|
||||||
/// jitter. [kPenSmoothing] keeps freehand's 0.5 corner rounding.
|
const double kPenStreamline = 0.28;
|
||||||
const double kPenStreamline = 0.32;
|
|
||||||
const double kPenSmoothing = 0.5;
|
const double kPenSmoothing = 0.5;
|
||||||
|
|
||||||
/// THE single perfect_freehand outline recipe — the raw outline points for a
|
/// THE single perfect_freehand outline recipe — the raw outline points for a
|
||||||
@@ -51,23 +51,68 @@ List<Offset> freehandOutlinePoints({
|
|||||||
required bool hasRealPressure,
|
required bool hasRealPressure,
|
||||||
required bool isComplete,
|
required bool isComplete,
|
||||||
double thinning = kDefaultPenThinning,
|
double thinning = kDefaultPenThinning,
|
||||||
|
BrushProfile? brush,
|
||||||
}) {
|
}) {
|
||||||
if (pfPoints.isEmpty) return const <Offset>[];
|
if (pfPoints.isEmpty) return const <Offset>[];
|
||||||
return pf.getStroke(
|
return pf.getStroke(
|
||||||
pfPoints,
|
pfPoints,
|
||||||
options: pf.StrokeOptions(
|
options: brush != null
|
||||||
size: size,
|
// Brush-driven path: every geometry knob (thinning / streamline /
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
// smoothing / caps / taper / simulatePressure) comes from the
|
||||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
// BrushProfile so each brush renders distinctly. Pressure was already
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
// pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the
|
||||||
smoothing: kPenSmoothing,
|
// pre-warp is baked into pfPoints — perfect_freehand stays linear here.
|
||||||
streamline: kPenStreamline,
|
// simulatePressure is forced true only when the device gave us NO real
|
||||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
// pressure, so velocity-thinning still kicks in for mice/trackpads.
|
||||||
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
? _optionsFromBrush(brush,
|
||||||
// 2.x honors real pressure when simulatePressure is false.
|
size: size,
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
isComplete: isComplete,
|
||||||
isComplete: isComplete,
|
hasRealPressure: hasRealPressure)
|
||||||
|
: pf.StrokeOptions(
|
||||||
|
size: size,
|
||||||
|
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||||
|
// configurable [thinning] so Surface-Pen pressure changes width.
|
||||||
|
thinning: isHighlighter ? 0.0 : thinning,
|
||||||
|
smoothing: kPenSmoothing,
|
||||||
|
streamline: kPenStreamline,
|
||||||
|
// Real stylus pressure -> don't simulate; no pressure -> let
|
||||||
|
// freehand fake it based on velocity (highlighter never simulates).
|
||||||
|
// perfect_freehand 2.x honors real pressure when simulatePressure
|
||||||
|
// is false.
|
||||||
|
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||||
|
isComplete: isComplete,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
|
||||||
|
///
|
||||||
|
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
|
||||||
|
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
|
||||||
|
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
|
||||||
|
pf.StrokeOptions _optionsFromBrush(
|
||||||
|
BrushProfile brush, {
|
||||||
|
required double size,
|
||||||
|
required bool isComplete,
|
||||||
|
required bool hasRealPressure,
|
||||||
|
}) {
|
||||||
|
return pf.StrokeOptions(
|
||||||
|
size: size,
|
||||||
|
thinning: brush.pfThinning,
|
||||||
|
smoothing: brush.pfSmoothing,
|
||||||
|
streamline: brush.pfStreamline,
|
||||||
|
// Honor REAL pressure (already gamma-pre-warped at capture). Only fall back
|
||||||
|
// to velocity simulation when the device reported no usable pressure.
|
||||||
|
simulatePressure: brush.simulatePressure || !hasRealPressure,
|
||||||
|
start: pf.StrokeEndOptions.start(
|
||||||
|
cap: brush.capStart,
|
||||||
|
taperEnabled: brush.taper,
|
||||||
),
|
),
|
||||||
|
end: pf.StrokeEndOptions.end(
|
||||||
|
cap: brush.capEnd,
|
||||||
|
taperEnabled: brush.taper,
|
||||||
|
),
|
||||||
|
isComplete: isComplete,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +146,11 @@ Path buildStrokeOutline(
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
// Resolve the brush so each stroke renders with its own geometry. The
|
||||||
|
// pressure pre-warp ([BrushProfile.pressureGamma]) was already applied at
|
||||||
|
// capture, so it is baked into the points here.
|
||||||
|
final brush = brushProfileFor(stroke.brush);
|
||||||
|
|
||||||
final outline = freehandOutlinePoints(
|
final outline = freehandOutlinePoints(
|
||||||
pfPoints: pfPoints,
|
pfPoints: pfPoints,
|
||||||
size: stroke.width * pageSize.width,
|
size: stroke.width * pageSize.width,
|
||||||
@@ -108,6 +158,7 @@ Path buildStrokeOutline(
|
|||||||
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
||||||
isComplete: isComplete,
|
isComplete: isComplete,
|
||||||
thinning: thinning,
|
thinning: thinning,
|
||||||
|
brush: brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outline.isEmpty) return path;
|
if (outline.isEmpty) return path;
|
||||||
@@ -118,3 +169,36 @@ Path buildStrokeOutline(
|
|||||||
path.close();
|
path.close();
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the
|
||||||
|
/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to
|
||||||
|
/// pressure).
|
||||||
|
double _avgPressure(EditorStroke stroke) {
|
||||||
|
if (stroke.points.isEmpty) return 0.5;
|
||||||
|
var sum = 0.0;
|
||||||
|
for (final p in stroke.points) {
|
||||||
|
sum += p.pressure ?? 0.5;
|
||||||
|
}
|
||||||
|
return sum / stroke.points.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved
|
||||||
|
/// opacity (multiplied into the color's alpha) and blend mode applied — closes
|
||||||
|
/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so
|
||||||
|
/// the EditorStroke render path composites exactly like the PenStroke one.
|
||||||
|
///
|
||||||
|
/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap
|
||||||
|
/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply]
|
||||||
|
/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred.
|
||||||
|
Paint paintForEditorStroke(EditorStroke stroke) {
|
||||||
|
final resolved = resolveStrokePaint(
|
||||||
|
stroke.brush,
|
||||||
|
stroke.color,
|
||||||
|
pressureAvg: _avgPressure(stroke),
|
||||||
|
);
|
||||||
|
return Paint()
|
||||||
|
..color = resolved.color
|
||||||
|
..blendMode = resolved.blendMode
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
|
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
|
||||||
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
|
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
|
||||||
|
|
||||||
|
// @JsonKey is applied to freezed factory parameters (e.g. EditorStroke.brush)
|
||||||
|
// for fine-grained serialization control; freezed re-emits those annotations on
|
||||||
|
// generated getters where they're valid, so suppress the source-level
|
||||||
|
// invalid_annotation_target for the whole file (the documented freezed pattern).
|
||||||
|
// ignore_for_file: invalid_annotation_target
|
||||||
|
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
@@ -21,6 +27,7 @@ import '../../models/ink_stroke.dart';
|
|||||||
import '../../models/pen_tool.dart';
|
import '../../models/pen_tool.dart';
|
||||||
import '../../models/pointer_device_kind.dart';
|
import '../../models/pointer_device_kind.dart';
|
||||||
import '../canvas/pen_stroke.dart';
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import 'brush.dart';
|
||||||
|
|
||||||
part 'stroke_model.freezed.dart';
|
part 'stroke_model.freezed.dart';
|
||||||
part 'stroke_model.g.dart';
|
part 'stroke_model.g.dart';
|
||||||
@@ -74,6 +81,19 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
@Default(false) bool filled,
|
@Default(false) bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
@Default(14.0) double fontSize,
|
@Default(14.0) double fontSize,
|
||||||
|
// Brush the stroke was drawn with — drives the committed render path's
|
||||||
|
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
|
||||||
|
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
|
||||||
|
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
|
||||||
|
// BACK-COMPAT: sidecars written before this field existed have no `brush`
|
||||||
|
// key, and an unknown name (a future brush opened by an older build) is
|
||||||
|
// tolerated — both fall back to fountainPen via the JsonKey below.
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
@Default(BrushKind.fountainPen)
|
||||||
|
BrushKind brush,
|
||||||
}) = _EditorStroke;
|
}) = _EditorStroke;
|
||||||
|
|
||||||
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
||||||
@@ -86,6 +106,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
bool filled = false,
|
bool filled = false,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize = 14.0,
|
double fontSize = 14.0,
|
||||||
|
BrushKind brush = BrushKind.fountainPen,
|
||||||
}) =>
|
}) =>
|
||||||
EditorStroke(
|
EditorStroke(
|
||||||
id: id ?? _uuid.v4(),
|
id: id ?? _uuid.v4(),
|
||||||
@@ -96,6 +117,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
filled: filled,
|
filled: filled,
|
||||||
textContent: textContent,
|
textContent: textContent,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
brush: brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -118,6 +140,7 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
},
|
},
|
||||||
color: stroke.color,
|
color: stroke.color,
|
||||||
width: stroke.width,
|
width: stroke.width,
|
||||||
|
brush: stroke.brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
||||||
@@ -141,6 +164,11 @@ abstract class EditorStroke with _$EditorStroke {
|
|||||||
filled: stroke.filled,
|
filled: stroke.filled,
|
||||||
textContent: stroke.textContent,
|
textContent: stroke.textContent,
|
||||||
fontSize: stroke.fontSize,
|
fontSize: stroke.fontSize,
|
||||||
|
// InkStroke has no brush field; derive from the tool so a loaded
|
||||||
|
// highlighter keeps the flat highlighter brush (pens → fountainPen).
|
||||||
|
brush: stroke.tool == PenTool.highlighter
|
||||||
|
? BrushKind.highlighter
|
||||||
|
: BrushKind.fountainPen,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset
|
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset
|
||||||
|
|||||||
@@ -302,7 +302,19 @@ mixin _$EditorStroke {
|
|||||||
double get width => throw _privateConstructorUsedError;
|
double get width => throw _privateConstructorUsedError;
|
||||||
bool get filled => throw _privateConstructorUsedError;
|
bool get filled => throw _privateConstructorUsedError;
|
||||||
String? get textContent => throw _privateConstructorUsedError;
|
String? get textContent => throw _privateConstructorUsedError;
|
||||||
double get fontSize => throw _privateConstructorUsedError;
|
double get fontSize =>
|
||||||
|
throw _privateConstructorUsedError; // Brush the stroke was drawn with — drives the committed render path's
|
||||||
|
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
|
||||||
|
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
|
||||||
|
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
|
||||||
|
// BACK-COMPAT: sidecars written before this field existed have no `brush`
|
||||||
|
// key, and an unknown name (a future brush opened by an older build) is
|
||||||
|
// tolerated — both fall back to fountainPen via the JsonKey below.
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
BrushKind get brush => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this EditorStroke to a JSON map.
|
/// Serializes this EditorStroke to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@@ -330,6 +342,11 @@ abstract class $EditorStrokeCopyWith<$Res> {
|
|||||||
bool filled,
|
bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
BrushKind brush,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +373,7 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
|||||||
Object? filled = null,
|
Object? filled = null,
|
||||||
Object? textContent = freezed,
|
Object? textContent = freezed,
|
||||||
Object? fontSize = null,
|
Object? fontSize = null,
|
||||||
|
Object? brush = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@@ -391,6 +409,10 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
|||||||
? _value.fontSize
|
? _value.fontSize
|
||||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
as double,
|
as double,
|
||||||
|
brush: null == brush
|
||||||
|
? _value.brush
|
||||||
|
: brush // ignore: cast_nullable_to_non_nullable
|
||||||
|
as BrushKind,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@@ -415,6 +437,11 @@ abstract class _$$EditorStrokeImplCopyWith<$Res>
|
|||||||
bool filled,
|
bool filled,
|
||||||
String? textContent,
|
String? textContent,
|
||||||
double fontSize,
|
double fontSize,
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
BrushKind brush,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +467,7 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
|||||||
Object? filled = null,
|
Object? filled = null,
|
||||||
Object? textContent = freezed,
|
Object? textContent = freezed,
|
||||||
Object? fontSize = null,
|
Object? fontSize = null,
|
||||||
|
Object? brush = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$EditorStrokeImpl(
|
_$EditorStrokeImpl(
|
||||||
@@ -475,6 +503,10 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
|||||||
? _value.fontSize
|
? _value.fontSize
|
||||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
as double,
|
as double,
|
||||||
|
brush: null == brush
|
||||||
|
? _value.brush
|
||||||
|
: brush // ignore: cast_nullable_to_non_nullable
|
||||||
|
as BrushKind,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +524,11 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
this.filled = false,
|
this.filled = false,
|
||||||
this.textContent,
|
this.textContent,
|
||||||
this.fontSize = 14.0,
|
this.fontSize = 14.0,
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
this.brush = BrushKind.fountainPen,
|
||||||
}) : _points = points,
|
}) : _points = points,
|
||||||
super._();
|
super._();
|
||||||
|
|
||||||
@@ -525,10 +562,23 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
@override
|
@override
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final double fontSize;
|
final double fontSize;
|
||||||
|
// Brush the stroke was drawn with — drives the committed render path's
|
||||||
|
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
|
||||||
|
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
|
||||||
|
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
|
||||||
|
// BACK-COMPAT: sidecars written before this field existed have no `brush`
|
||||||
|
// key, and an unknown name (a future brush opened by an older build) is
|
||||||
|
// tolerated — both fall back to fountainPen via the JsonKey below.
|
||||||
|
@override
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
final BrushKind brush;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
|
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize, brush: $brush)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -545,7 +595,8 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
(identical(other.textContent, textContent) ||
|
(identical(other.textContent, textContent) ||
|
||||||
other.textContent == textContent) &&
|
other.textContent == textContent) &&
|
||||||
(identical(other.fontSize, fontSize) ||
|
(identical(other.fontSize, fontSize) ||
|
||||||
other.fontSize == fontSize));
|
other.fontSize == fontSize) &&
|
||||||
|
(identical(other.brush, brush) || other.brush == brush));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -560,6 +611,7 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
|||||||
filled,
|
filled,
|
||||||
textContent,
|
textContent,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
brush,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Create a copy of EditorStroke
|
/// Create a copy of EditorStroke
|
||||||
@@ -586,6 +638,11 @@ abstract class _EditorStroke extends EditorStroke {
|
|||||||
final bool filled,
|
final bool filled,
|
||||||
final String? textContent,
|
final String? textContent,
|
||||||
final double fontSize,
|
final double fontSize,
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
final BrushKind brush,
|
||||||
}) = _$EditorStrokeImpl;
|
}) = _$EditorStrokeImpl;
|
||||||
_EditorStroke._() : super._();
|
_EditorStroke._() : super._();
|
||||||
|
|
||||||
@@ -607,7 +664,19 @@ abstract class _EditorStroke extends EditorStroke {
|
|||||||
@override
|
@override
|
||||||
String? get textContent;
|
String? get textContent;
|
||||||
@override
|
@override
|
||||||
double get fontSize;
|
double get fontSize; // Brush the stroke was drawn with — drives the committed render path's
|
||||||
|
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
|
||||||
|
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
|
||||||
|
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
|
||||||
|
// BACK-COMPAT: sidecars written before this field existed have no `brush`
|
||||||
|
// key, and an unknown name (a future brush opened by an older build) is
|
||||||
|
// tolerated — both fall back to fountainPen via the JsonKey below.
|
||||||
|
@override
|
||||||
|
@JsonKey(
|
||||||
|
defaultValue: BrushKind.fountainPen,
|
||||||
|
unknownEnumValue: BrushKind.fountainPen,
|
||||||
|
)
|
||||||
|
BrushKind get brush;
|
||||||
|
|
||||||
/// Create a copy of EditorStroke
|
/// Create a copy of EditorStroke
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ _$EditorStrokeImpl _$$EditorStrokeImplFromJson(Map<String, dynamic> json) =>
|
|||||||
filled: json['filled'] as bool? ?? false,
|
filled: json['filled'] as bool? ?? false,
|
||||||
textContent: json['textContent'] as String?,
|
textContent: json['textContent'] as String?,
|
||||||
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
|
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
|
||||||
|
brush:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$BrushKindEnumMap,
|
||||||
|
json['brush'],
|
||||||
|
unknownValue: BrushKind.fountainPen,
|
||||||
|
) ??
|
||||||
|
BrushKind.fountainPen,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
||||||
@@ -64,6 +71,7 @@ Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
|||||||
'filled': instance.filled,
|
'filled': instance.filled,
|
||||||
'textContent': instance.textContent,
|
'textContent': instance.textContent,
|
||||||
'fontSize': instance.fontSize,
|
'fontSize': instance.fontSize,
|
||||||
|
'brush': _$BrushKindEnumMap[instance.brush]!,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$EditorToolEnumMap = {
|
const _$EditorToolEnumMap = {
|
||||||
@@ -71,3 +79,10 @@ const _$EditorToolEnumMap = {
|
|||||||
EditorTool.highlighter: 'highlighter',
|
EditorTool.highlighter: 'highlighter',
|
||||||
EditorTool.eraser: 'eraser',
|
EditorTool.eraser: 'eraser',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _$BrushKindEnumMap = {
|
||||||
|
BrushKind.fountainPen: 'fountainPen',
|
||||||
|
BrushKind.ballpoint: 'ballpoint',
|
||||||
|
BrushKind.highlighter: 'highlighter',
|
||||||
|
BrushKind.pencil: 'pencil',
|
||||||
|
};
|
||||||
|
|||||||
56
lib/editor/engine/stroke_predictor.dart
Normal file
56
lib/editor/engine/stroke_predictor.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Lightweight stroke prediction — extrapolates the next point from recent
|
||||||
|
// velocity so the live stroke tip leads the digitizer slightly (lower perceived
|
||||||
|
// latency). Not a full ink-stroke-modeler; intentionally small and testable.
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
class PredictedPoint {
|
||||||
|
const PredictedPoint(this.offset, this.pressure);
|
||||||
|
final Offset offset;
|
||||||
|
final double pressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StrokePredictor {
|
||||||
|
StrokePredictor({this.lookaheadMs = 8});
|
||||||
|
|
||||||
|
/// How far ahead to project, in milliseconds of recent velocity.
|
||||||
|
final double lookaheadMs;
|
||||||
|
|
||||||
|
Offset? _prev;
|
||||||
|
double? _prevPressure;
|
||||||
|
DateTime? _prevAt;
|
||||||
|
Offset _velocity = Offset.zero;
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_prev = null;
|
||||||
|
_prevPressure = null;
|
||||||
|
_prevAt = null;
|
||||||
|
_velocity = Offset.zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a real sample; returns an optional predicted tip ahead of [point].
|
||||||
|
PredictedPoint? observe(Offset point, double pressure, {DateTime? at}) {
|
||||||
|
final now = at ?? DateTime.now();
|
||||||
|
if (_prev != null && _prevAt != null) {
|
||||||
|
final dtMs = now.difference(_prevAt!).inMicroseconds / 1000.0;
|
||||||
|
if (dtMs > 0.5 && dtMs < 80) {
|
||||||
|
final raw = (point - _prev!) * (1000.0 / dtMs);
|
||||||
|
// EMA blend to avoid jerky predictions.
|
||||||
|
_velocity = Offset(
|
||||||
|
_velocity.dx * 0.55 + raw.dx * 0.45,
|
||||||
|
_velocity.dy * 0.55 + raw.dy * 0.45,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_prev = point;
|
||||||
|
_prevPressure = pressure;
|
||||||
|
_prevAt = now;
|
||||||
|
|
||||||
|
if (_velocity.distance < 40) return null; // idle / slow — no predict
|
||||||
|
final tip = point + _velocity * (lookaheadMs / 1000.0);
|
||||||
|
return PredictedPoint(tip, pressure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last known pressure (for predicted tip).
|
||||||
|
double get lastPressure => _prevPressure ?? 0.5;
|
||||||
|
}
|
||||||
@@ -1,90 +1,38 @@
|
|||||||
// lib/editor/input/diagnostic_logger.dart
|
// lib/editor/input/diagnostic_logger.dart
|
||||||
//
|
//
|
||||||
// On-device input diagnostics. When the user manually enables the diagnostic
|
// Compatibility facade over [BadNoteLog] + [PenEventRing]. The PDF editor's
|
||||||
// (the toolbar toggle), every native pen event (raw button/flag/tilt fields)
|
// toolbar toggle still calls start/stop; globally, [BadNoteLog.start] runs at
|
||||||
// and every zoom frame is emitted through the standard `dart:developer` log
|
// app launch so packaged builds always have a session file.
|
||||||
// channel (name 'badnote.input') — capturable via `flutter run`, DevTools, or
|
|
||||||
// any log tool — AND mirrored to a text file as a fallback for the packaged
|
|
||||||
// GUI build, which has no attached console. Disabled by default (no overhead).
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer' as developer;
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import '../../diagnostics/badnote_log.dart';
|
||||||
|
|
||||||
class DiagnosticLogger {
|
class DiagnosticLogger {
|
||||||
DiagnosticLogger._();
|
DiagnosticLogger._();
|
||||||
static final DiagnosticLogger instance = DiagnosticLogger._();
|
static final DiagnosticLogger instance = DiagnosticLogger._();
|
||||||
|
|
||||||
final List<String> _buffer = <String>[];
|
bool _verbose = false;
|
||||||
File? _file;
|
bool get isActive => _verbose || BadNoteLog.instance.path != null;
|
||||||
Timer? _timer;
|
|
||||||
int _epochMs = 0;
|
|
||||||
|
|
||||||
bool _active = false;
|
/// Absolute path of the structured session log (preferred), else null.
|
||||||
bool get isActive => _active;
|
String? get path => BadNoteLog.instance.path;
|
||||||
|
|
||||||
/// Absolute path of the current log file (shown in the overlay), or null.
|
/// Begin a verbose input session (also ensures the global log is running).
|
||||||
String? path;
|
|
||||||
|
|
||||||
/// Begin a session. Enables the `dart:developer` log channel immediately and
|
|
||||||
/// opens the fallback file (best-effort). Safe to call repeatedly.
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
if (_active) return;
|
_verbose = true;
|
||||||
_active = true; // developer.log works even if the file can't be opened
|
await BadNoteLog.instance.start();
|
||||||
_epochMs = DateTime.now().millisecondsSinceEpoch;
|
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_on');
|
||||||
developer.log('--- session start ${DateTime.now().toIso8601String()} ---',
|
|
||||||
name: 'badnote.input');
|
|
||||||
try {
|
|
||||||
Directory dir;
|
|
||||||
try {
|
|
||||||
dir = await getApplicationDocumentsDirectory();
|
|
||||||
} catch (_) {
|
|
||||||
dir = await getTemporaryDirectory();
|
|
||||||
}
|
|
||||||
final file = File('${dir.path}${Platform.pathSeparator}badnote_input_log.txt');
|
|
||||||
await file.writeAsString(
|
|
||||||
'# BadNote input diagnostic log\n'
|
|
||||||
'# started ${DateTime.now().toIso8601String()}\n'
|
|
||||||
'# columns: <ms> <kind> <fields...>\n',
|
|
||||||
flush: true,
|
|
||||||
);
|
|
||||||
_file = file;
|
|
||||||
path = file.path;
|
|
||||||
_buffer.clear();
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
|
|
||||||
} catch (_) {
|
|
||||||
// File is a fallback; never break the app over it.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit one diagnostic line through the standard log channel and the file.
|
|
||||||
void log(String line) {
|
void log(String line) {
|
||||||
if (!_active) return;
|
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
|
||||||
developer.log(line, name: 'badnote.input');
|
|
||||||
if (_file == null) return;
|
|
||||||
final t = DateTime.now().millisecondsSinceEpoch - _epochMs;
|
|
||||||
_buffer.add('$t $line');
|
|
||||||
if (_buffer.length >= 1000) _flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _flush() async {
|
|
||||||
final file = _file;
|
|
||||||
if (file == null || _buffer.isEmpty) return;
|
|
||||||
final chunk = '${_buffer.join('\n')}\n';
|
|
||||||
_buffer.clear();
|
|
||||||
try {
|
|
||||||
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flush and stop. The file remains on disk for retrieval.
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
if (!_active) return;
|
if (!_verbose) return;
|
||||||
_active = false;
|
_verbose = false;
|
||||||
_timer?.cancel();
|
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_off');
|
||||||
_timer = null;
|
await BadNoteLog.instance.flush();
|
||||||
await _flush();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
|
import 'pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
|
|
||||||
|
/// Default eraser radius as a fraction of page width (the legacy fixed value,
|
||||||
|
/// now the default of the configurable [PenConfig.eraserRadius]).
|
||||||
|
const double kDefaultEraserRadius = 0.02;
|
||||||
|
|
||||||
/// Action that can be triggered by a hardware pen button or the eraser end.
|
/// Action that can be triggered by a hardware pen button or the eraser end.
|
||||||
enum PenButtonAction {
|
enum PenButtonAction {
|
||||||
@@ -13,6 +18,12 @@ enum PenButtonAction {
|
|||||||
undo,
|
undo,
|
||||||
toggleTool,
|
toggleTool,
|
||||||
pan,
|
pan,
|
||||||
|
|
||||||
|
/// Rising-edge: switch to the universal stroke [select] tool (OneNote-like).
|
||||||
|
select,
|
||||||
|
|
||||||
|
/// Hold to temporarily enable PDF text selection.
|
||||||
|
selectText,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immutable configuration for pen input behaviour.
|
/// Immutable configuration for pen input behaviour.
|
||||||
@@ -20,20 +31,24 @@ enum PenButtonAction {
|
|||||||
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
||||||
class PenConfig {
|
class PenConfig {
|
||||||
const PenConfig({
|
const PenConfig({
|
||||||
this.sideButton = PenButtonAction.eraser,
|
this.sideButton = PenButtonAction.selectText,
|
||||||
this.eraserEnd = PenButtonAction.eraser,
|
this.eraserEnd = PenButtonAction.eraser,
|
||||||
this.pressureGamma = 1.0,
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
this.palmRejectionMs = 150.0,
|
this.palmRejectionMs = 150.0,
|
||||||
this.fingerDrawing = false,
|
this.fingerDrawing = false,
|
||||||
this.penWidth = 0.004,
|
this.penWidth = 0.004,
|
||||||
this.highlighterWidth = 0.02,
|
this.highlighterWidth = 0.02,
|
||||||
this.pressureSensitivity = kDefaultPenThinning,
|
this.pressureSensitivity = kDefaultPenThinning,
|
||||||
|
this.eraserRadius = kDefaultEraserRadius,
|
||||||
|
this.eraserWholeStroke = false,
|
||||||
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
|
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
|
||||||
'pressureGamma must be in [0.3, 3.0]'),
|
'pressureGamma must be in [0.3, 3.0]'),
|
||||||
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
|
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
|
||||||
'palmRejectionMs must be in [0, 500]'),
|
'palmRejectionMs must be in [0, 500]'),
|
||||||
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
|
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
|
||||||
'pressureSensitivity must be in [0, 1]');
|
'pressureSensitivity must be in [0, 1]'),
|
||||||
|
assert(eraserRadius >= 0.005 && eraserRadius <= 0.1,
|
||||||
|
'eraserRadius must be in [0.005, 0.1]');
|
||||||
|
|
||||||
/// Which action fires when the side barrel button is held.
|
/// Which action fires when the side barrel button is held.
|
||||||
final PenButtonAction sideButton;
|
final PenButtonAction sideButton;
|
||||||
@@ -66,6 +81,16 @@ class PenConfig {
|
|||||||
/// export golden are unchanged.
|
/// export golden are unchanged.
|
||||||
final double pressureSensitivity;
|
final double pressureSensitivity;
|
||||||
|
|
||||||
|
/// Eraser radius as a fraction of page width. Range [0.005, 0.1], default
|
||||||
|
/// [kDefaultEraserRadius]. Controls both the live erase hit area and the
|
||||||
|
/// on-screen eraser cursor.
|
||||||
|
final double eraserRadius;
|
||||||
|
|
||||||
|
/// When true the eraser removes a WHOLE stroke on contact (OneNote-style
|
||||||
|
/// stroke eraser); when false it does a partial / segment erase (the default,
|
||||||
|
/// rnote-style point eraser).
|
||||||
|
final bool eraserWholeStroke;
|
||||||
|
|
||||||
PenConfig copyWith({
|
PenConfig copyWith({
|
||||||
PenButtonAction? sideButton,
|
PenButtonAction? sideButton,
|
||||||
PenButtonAction? eraserEnd,
|
PenButtonAction? eraserEnd,
|
||||||
@@ -75,6 +100,8 @@ class PenConfig {
|
|||||||
double? penWidth,
|
double? penWidth,
|
||||||
double? highlighterWidth,
|
double? highlighterWidth,
|
||||||
double? pressureSensitivity,
|
double? pressureSensitivity,
|
||||||
|
double? eraserRadius,
|
||||||
|
bool? eraserWholeStroke,
|
||||||
}) {
|
}) {
|
||||||
return PenConfig(
|
return PenConfig(
|
||||||
sideButton: sideButton ?? this.sideButton,
|
sideButton: sideButton ?? this.sideButton,
|
||||||
@@ -85,6 +112,8 @@ class PenConfig {
|
|||||||
penWidth: penWidth ?? this.penWidth,
|
penWidth: penWidth ?? this.penWidth,
|
||||||
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
|
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
|
||||||
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
|
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
|
||||||
|
eraserRadius: eraserRadius ?? this.eraserRadius,
|
||||||
|
eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +126,8 @@ class PenConfig {
|
|||||||
'penWidth': penWidth,
|
'penWidth': penWidth,
|
||||||
'highlighterWidth': highlighterWidth,
|
'highlighterWidth': highlighterWidth,
|
||||||
'pressureSensitivity': pressureSensitivity,
|
'pressureSensitivity': pressureSensitivity,
|
||||||
|
'eraserRadius': eraserRadius,
|
||||||
|
'eraserWholeStroke': eraserWholeStroke,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory PenConfig.fromJson(Map<String, dynamic> json) {
|
factory PenConfig.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -107,13 +138,17 @@ class PenConfig {
|
|||||||
eraserEnd:
|
eraserEnd:
|
||||||
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
|
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
|
||||||
PenButtonAction.eraser,
|
PenButtonAction.eraser,
|
||||||
pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0,
|
pressureGamma:
|
||||||
|
(json['pressureGamma'] as num?)?.toDouble() ?? kNaturalPressureGamma,
|
||||||
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
|
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
|
||||||
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
|
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
|
||||||
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
|
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
|
||||||
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
|
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
|
||||||
pressureSensitivity:
|
pressureSensitivity:
|
||||||
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
|
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
|
||||||
|
eraserRadius:
|
||||||
|
(json['eraserRadius'] as num?)?.toDouble() ?? kDefaultEraserRadius,
|
||||||
|
eraserWholeStroke: json['eraserWholeStroke'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +164,9 @@ class PenConfig {
|
|||||||
fingerDrawing == other.fingerDrawing &&
|
fingerDrawing == other.fingerDrawing &&
|
||||||
penWidth == other.penWidth &&
|
penWidth == other.penWidth &&
|
||||||
highlighterWidth == other.highlighterWidth &&
|
highlighterWidth == other.highlighterWidth &&
|
||||||
pressureSensitivity == other.pressureSensitivity;
|
pressureSensitivity == other.pressureSensitivity &&
|
||||||
|
eraserRadius == other.eraserRadius &&
|
||||||
|
eraserWholeStroke == other.eraserWholeStroke;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(
|
int get hashCode => Object.hash(
|
||||||
@@ -141,6 +178,8 @@ class PenConfig {
|
|||||||
penWidth,
|
penWidth,
|
||||||
highlighterWidth,
|
highlighterWidth,
|
||||||
pressureSensitivity,
|
pressureSensitivity,
|
||||||
|
eraserRadius,
|
||||||
|
eraserWholeStroke,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +200,9 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
/// The SharedPreferences key under which [PenConfig] JSON is stored.
|
/// The SharedPreferences key under which [PenConfig] JSON is stored.
|
||||||
static const prefsKey = 'pen_config_v1';
|
static const prefsKey = 'pen_config_v1';
|
||||||
|
|
||||||
|
/// Marker so the legacy-gamma migration in [load] runs at most once.
|
||||||
|
static const _gammaMigratedKey = 'pen_config_gamma_migrated_v1';
|
||||||
|
|
||||||
final SharedPreferences _prefs;
|
final SharedPreferences _prefs;
|
||||||
PenConfig _value;
|
PenConfig _value;
|
||||||
|
|
||||||
@@ -184,6 +226,18 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
config = const PenConfig();
|
config = const PenConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One-time migration: before this build, pressureGamma was never applied to
|
||||||
|
// strokes (a dead slider), so a stored 1.0 is the legacy inert default, not
|
||||||
|
// a deliberate "linear feel" choice. Upgrade it ONCE to the natural curve so
|
||||||
|
// the pen feels right out of the box. Guarded by a marker key so that, after
|
||||||
|
// migrating, the user is free to set gamma back to 1.0 and have it stick.
|
||||||
|
if (!(prefs.getBool(_gammaMigratedKey) ?? false)) {
|
||||||
|
if (config.pressureGamma == 1.0) {
|
||||||
|
config = config.copyWith(pressureGamma: kNaturalPressureGamma);
|
||||||
|
await prefs.setString(prefsKey, jsonEncode(config.toJson()));
|
||||||
|
}
|
||||||
|
await prefs.setBool(_gammaMigratedKey, true);
|
||||||
|
}
|
||||||
return PenConfigController._(prefs, config);
|
return PenConfigController._(prefs, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +290,18 @@ class PenConfigController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
|
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
|
||||||
|
/// Sets [PenConfig.eraserRadius]. Clamped to [0.005, 0.1].
|
||||||
|
Future<void> setEraserRadius(double radius) async {
|
||||||
|
_value = _value.copyWith(eraserRadius: radius.clamp(0.005, 0.1));
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets [PenConfig.eraserWholeStroke] (true = OneNote-style stroke eraser).
|
||||||
|
Future<void> setEraserWholeStroke(bool whole) async {
|
||||||
|
_value = _value.copyWith(eraserWholeStroke: whole);
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setPressureSensitivity(double sensitivity) async {
|
Future<void> setPressureSensitivity(double sensitivity) async {
|
||||||
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
|
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -2,27 +2,16 @@
|
|||||||
//
|
//
|
||||||
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
|
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
|
||||||
//
|
//
|
||||||
// WHY THIS EXISTS: Flutter 3.44 on Windows delivers stylus PRESSURE but drops
|
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
|
||||||
// the pen's barrel button, eraser/inverted end, and tilt (it does not map
|
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
|
||||||
// POINTER_PEN_FLAG_* into `PointerEvent.buttons`/`invertedStylus`/`tilt`). The
|
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
|
||||||
// native plugin observes WM_POINTER + GetPointerPenInfo and streams the missing
|
|
||||||
// hardware state over an EventChannel; this service latches the LATEST value.
|
|
||||||
//
|
|
||||||
// CORRELATION (plan M2): we do NOT key state by Win32 pointerId joined to
|
|
||||||
// Flutter's `event.pointer` — those are different id spaces. Only one pen is
|
|
||||||
// active at a time, so a single latched "current" state is correct. The native
|
|
||||||
// observer runs at the TOP of the window proc (BEFORE Flutter synthesizes its
|
|
||||||
// pointer event, plan M1), so by the time Dart's pointer-down handler reads
|
|
||||||
// [current], the latch already reflects that exact contact — no hover required.
|
|
||||||
//
|
|
||||||
// GRACEFUL DEGRADATION: on non-Windows (or if the channel is silent) the stream
|
|
||||||
// simply never emits / errors are swallowed, and [current] stays [PenHardwareState.empty]
|
|
||||||
// so the canvas falls back to its normal Flutter-pressure drawing.
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../../diagnostics/badnote_log.dart';
|
||||||
|
import '../../diagnostics/pen_event_ring.dart';
|
||||||
import 'diagnostic_logger.dart';
|
import 'diagnostic_logger.dart';
|
||||||
|
|
||||||
/// Latest hardware pen state delivered by the native observer.
|
/// Latest hardware pen state delivered by the native observer.
|
||||||
@@ -33,22 +22,20 @@ class PenHardwareState {
|
|||||||
this.eraser = false,
|
this.eraser = false,
|
||||||
this.tiltX = 0.0,
|
this.tiltX = 0.0,
|
||||||
this.tiltY = 0.0,
|
this.tiltY = 0.0,
|
||||||
|
this.pressure = 0.0,
|
||||||
|
this.pressureValid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Side barrel button held.
|
|
||||||
final bool barrel;
|
final bool barrel;
|
||||||
|
|
||||||
/// Pen flipped to the inverted (eraser) end.
|
|
||||||
final bool inverted;
|
final bool inverted;
|
||||||
|
|
||||||
/// Hardware eraser flag set.
|
|
||||||
final bool eraser;
|
final bool eraser;
|
||||||
|
|
||||||
/// Tilt in degrees along X / Y ([-90, 90]); 0 = perpendicular.
|
|
||||||
final double tiltX;
|
final double tiltX;
|
||||||
final double tiltY;
|
final double tiltY;
|
||||||
|
|
||||||
/// Combined tilt magnitude in degrees (for [PenPoint.tilt]).
|
/// Normalized stylus pressure in [0,1] when [pressureValid] is true.
|
||||||
|
final double pressure;
|
||||||
|
final bool pressureValid;
|
||||||
|
|
||||||
double get tiltMagnitude {
|
double get tiltMagnitude {
|
||||||
final t = tiltX * tiltX + tiltY * tiltY;
|
final t = tiltX * tiltX + tiltY * tiltY;
|
||||||
return t <= 0 ? 0.0 : _sqrt(t);
|
return t <= 0 ? 0.0 : _sqrt(t);
|
||||||
@@ -57,12 +44,10 @@ class PenHardwareState {
|
|||||||
static const empty = PenHardwareState();
|
static const empty = PenHardwareState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoids importing dart:math for a single call.
|
|
||||||
double _sqrt(double v) {
|
double _sqrt(double v) {
|
||||||
if (v <= 0) return 0;
|
if (v <= 0) return 0;
|
||||||
var x = v;
|
var x = v;
|
||||||
var last = 0.0;
|
var last = 0.0;
|
||||||
// Newton's method; converges fast for the small (<=~127) magnitudes here.
|
|
||||||
for (var i = 0; i < 12 && x != last; i++) {
|
for (var i = 0; i < 12 && x != last; i++) {
|
||||||
last = x;
|
last = x;
|
||||||
x = 0.5 * (x + v / x);
|
x = 0.5 * (x + v / x);
|
||||||
@@ -70,33 +55,34 @@ double _sqrt(double v) {
|
|||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latches the most recent [PenHardwareState] streamed by the native pen plugin.
|
|
||||||
///
|
|
||||||
/// Use the singleton [PenInputService.instance]. Call [start] once (e.g. in the
|
|
||||||
/// editor's `initState`) and [stop] on dispose.
|
|
||||||
class PenInputService {
|
class PenInputService {
|
||||||
PenInputService._();
|
PenInputService._();
|
||||||
|
|
||||||
/// Process-wide singleton (one physical pen).
|
|
||||||
static final PenInputService instance = PenInputService._();
|
static final PenInputService instance = PenInputService._();
|
||||||
|
|
||||||
/// Must match the native `EventChannel` name in `pen_channel.cpp`.
|
|
||||||
static const EventChannel _channel = EventChannel('badnote/pen');
|
static const EventChannel _channel = EventChannel('badnote/pen');
|
||||||
|
|
||||||
StreamSubscription<dynamic>? _sub;
|
StreamSubscription<dynamic>? _sub;
|
||||||
PenHardwareState _current = PenHardwareState.empty;
|
PenHardwareState _current = PenHardwareState.empty;
|
||||||
|
|
||||||
/// The latest hardware pen state (or [PenHardwareState.empty] when no native
|
|
||||||
/// data has arrived — non-Windows, plugin absent, or channel silent).
|
|
||||||
PenHardwareState get current => _current;
|
PenHardwareState get current => _current;
|
||||||
|
|
||||||
/// Whether the native channel has delivered at least one event (i.e. the
|
|
||||||
/// native pen plugin is present and active). Used to prefer hardware signals
|
|
||||||
/// over the Flutter fallback only when they are actually available.
|
|
||||||
bool get isActive => _active;
|
bool get isActive => _active;
|
||||||
bool _active = false;
|
bool _active = false;
|
||||||
|
|
||||||
// Native-side diagnostics (see windows/runner/pen_channel.cpp).
|
final List<VoidCallback> _listeners = <VoidCallback>[];
|
||||||
|
|
||||||
|
/// Notify when native hardware state changes (barrel / pressure / tilt).
|
||||||
|
void addListener(VoidCallback listener) => _listeners.add(listener);
|
||||||
|
|
||||||
|
void removeListener(VoidCallback listener) => _listeners.remove(listener);
|
||||||
|
|
||||||
|
void _notifyListeners() {
|
||||||
|
for (final l in List<VoidCallback>.of(_listeners)) {
|
||||||
|
l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _diagPtr = 0;
|
int _diagPtr = 0;
|
||||||
int _diagPen = 0;
|
int _diagPen = 0;
|
||||||
int _diagMouse = 0;
|
int _diagMouse = 0;
|
||||||
@@ -106,47 +92,42 @@ class PenInputService {
|
|||||||
int _orPenMask = 0;
|
int _orPenMask = 0;
|
||||||
int _btnChangeLast = 0;
|
int _btnChangeLast = 0;
|
||||||
int _tiltAbsMax = 0;
|
int _tiltAbsMax = 0;
|
||||||
|
double _pressureMaxSeen = 0;
|
||||||
|
|
||||||
String _hex(int v) => '0x${v.toRadixString(16)}';
|
String _hex(int v) => '0x${v.toRadixString(16)}';
|
||||||
|
|
||||||
/// Multi-line native readout for the diagnostic overlay. The OR-accumulated
|
|
||||||
/// flag fields are the ground truth for which field carries the side/eraser
|
|
||||||
/// button: e.g. orPtrFlags with bit 0x20 (POINTER_FLAG_SECONDBUTTON) set means
|
|
||||||
/// the barrel button IS detectable.
|
|
||||||
String get debugSummary => _active
|
String get debugSummary => _active
|
||||||
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
|
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
|
||||||
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
|
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
|
||||||
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
|
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
|
||||||
|
'\n pressure=${_current.pressureValid ? _current.pressure.toStringAsFixed(3) : "n/a"}'
|
||||||
|
' maxSeen=${_pressureMaxSeen.toStringAsFixed(3)}'
|
||||||
: 'native: channel silent (no events)';
|
: 'native: channel silent (no events)';
|
||||||
|
|
||||||
/// Begins listening to the native channel. Idempotent; safe on any platform
|
|
||||||
/// (no-ops where the channel has no handler).
|
|
||||||
void start() {
|
void start() {
|
||||||
if (_sub != null) return;
|
if (_sub != null) return;
|
||||||
try {
|
try {
|
||||||
_sub = _channel.receiveBroadcastStream().listen(
|
_sub = _channel.receiveBroadcastStream().listen(
|
||||||
_onEvent,
|
_onEvent,
|
||||||
onError: (Object _) {
|
onError: (Object _) {},
|
||||||
// No native handler (e.g. Linux/macOS) or transient error — ignore
|
|
||||||
// and keep the empty fallback state.
|
|
||||||
},
|
|
||||||
cancelOnError: false,
|
cancelOnError: false,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// receiveBroadcastStream can throw synchronously if the platform side is
|
|
||||||
// unavailable; degrade silently.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(dynamic event) {
|
void _onEvent(dynamic event) {
|
||||||
if (event is! Map) return;
|
if (event is! Map) return;
|
||||||
final flags = (event['flags'] as num?)?.toInt() ?? 0;
|
final flags = (event['flags'] as num?)?.toInt() ?? 0;
|
||||||
|
final pressureValid = ((event['pressureValid'] as num?)?.toInt() ?? 0) != 0;
|
||||||
|
final pressure = (event['pressure'] as num?)?.toDouble() ?? 0.0;
|
||||||
_current = PenHardwareState(
|
_current = PenHardwareState(
|
||||||
barrel: flags & 0x1 != 0,
|
barrel: flags & 0x1 != 0,
|
||||||
inverted: flags & 0x2 != 0,
|
inverted: flags & 0x2 != 0,
|
||||||
eraser: flags & 0x4 != 0,
|
eraser: flags & 0x4 != 0,
|
||||||
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
|
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
|
||||||
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
|
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
pressure: pressure.clamp(0.0, 1.0),
|
||||||
|
pressureValid: pressureValid,
|
||||||
);
|
);
|
||||||
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
|
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
|
||||||
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
||||||
@@ -157,31 +138,60 @@ class PenInputService {
|
|||||||
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
||||||
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
||||||
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
||||||
|
_pressureMaxSeen =
|
||||||
|
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
|
||||||
|
if (pressureValid && pressure > _pressureMaxSeen) {
|
||||||
|
_pressureMaxSeen = pressure;
|
||||||
|
}
|
||||||
_active = true;
|
_active = true;
|
||||||
|
|
||||||
// Log a PEN line whenever the raw per-event button/flag fields change, so
|
|
||||||
// the file captures exactly which field a button press sets (without
|
|
||||||
// flooding on every high-rate WM_POINTERUPDATE).
|
|
||||||
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
|
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
|
||||||
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
|
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
|
||||||
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
|
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
|
||||||
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
|
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
|
||||||
final key = '$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY}';
|
final key =
|
||||||
|
'$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY},'
|
||||||
|
'${pressureValid ? pressure.toStringAsFixed(2) : "x"}';
|
||||||
if (key != _lastPenLogKey) {
|
if (key != _lastPenLogKey) {
|
||||||
_lastPenLogKey = key;
|
_lastPenLogKey = key;
|
||||||
|
PenEventRing.instance.recordHardware(
|
||||||
|
barrel: _current.barrel,
|
||||||
|
eraser: _current.eraser,
|
||||||
|
inverted: _current.inverted,
|
||||||
|
tiltX: _current.tiltX,
|
||||||
|
tiltY: _current.tiltY,
|
||||||
|
);
|
||||||
|
BadNoteLog.instance.debug(
|
||||||
|
LogSubsystem.penNative,
|
||||||
|
'pen_hw',
|
||||||
|
fields: {
|
||||||
|
'ptrFlags': '0x${rawPtr.toRadixString(16)}',
|
||||||
|
'penFlags': '0x${rawPen.toRadixString(16)}',
|
||||||
|
'mask': '0x${rawMask.toRadixString(16)}',
|
||||||
|
'btnChg': btnChange,
|
||||||
|
'tiltX': _current.tiltX,
|
||||||
|
'tiltY': _current.tiltY,
|
||||||
|
'pressure': pressureValid ? pressure : null,
|
||||||
|
'pressureValid': pressureValid,
|
||||||
|
'barrel': _current.barrel,
|
||||||
|
'eraser': _current.eraser,
|
||||||
|
'inverted': _current.inverted,
|
||||||
|
},
|
||||||
|
);
|
||||||
DiagnosticLogger.instance.log(
|
DiagnosticLogger.instance.log(
|
||||||
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
|
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
|
||||||
'penFlags=0x${rawPen.toRadixString(16)} '
|
'penFlags=0x${rawPen.toRadixString(16)} '
|
||||||
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
||||||
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
|
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
|
||||||
|
'p=${pressureValid ? pressure.toStringAsFixed(3) : "n/a"} '
|
||||||
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
|
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _lastPenLogKey = '';
|
String _lastPenLogKey = '';
|
||||||
|
|
||||||
/// Stops listening and resets state.
|
|
||||||
void stop() {
|
void stop() {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_sub = null;
|
_sub = null;
|
||||||
|
|||||||
213
lib/editor/input/pen_slots.dart
Normal file
213
lib/editor/input/pen_slots.dart
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
|
||||||
|
/// OneNote-style independent pen slot: brush + color + thickness together.
|
||||||
|
///
|
||||||
|
/// Selecting a slot restores all three; color dots / thickness controls edit
|
||||||
|
/// only the active slot.
|
||||||
|
class PenSlot {
|
||||||
|
const PenSlot({
|
||||||
|
required this.id,
|
||||||
|
required this.brush,
|
||||||
|
required this.color,
|
||||||
|
required this.width,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
|
||||||
|
final BrushKind brush;
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
/// Stroke width as a fraction of page width.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
PenSlot copyWith({
|
||||||
|
String? id,
|
||||||
|
BrushKind? brush,
|
||||||
|
Color? color,
|
||||||
|
double? width,
|
||||||
|
}) {
|
||||||
|
return PenSlot(
|
||||||
|
id: id ?? this.id,
|
||||||
|
brush: brush ?? this.brush,
|
||||||
|
color: color ?? this.color,
|
||||||
|
width: width ?? this.width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'brush': brush.name,
|
||||||
|
'color': color.toARGB32(),
|
||||||
|
'width': width,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PenSlot.fromJson(Map<String, dynamic> json) {
|
||||||
|
final brushName = json['brush'] as String? ?? '';
|
||||||
|
return PenSlot(
|
||||||
|
id: json['id'] as String? ?? 'slot_0',
|
||||||
|
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
|
||||||
|
color: Color(json['color'] as int? ?? 0xFF000000),
|
||||||
|
width: (json['width'] as num?)?.toDouble() ?? 0.006,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is PenSlot &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
brush == other.brush &&
|
||||||
|
color.toARGB32() == other.color.toARGB32() &&
|
||||||
|
width == other.width;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
|
||||||
|
List<PenSlot> kDefaultPenSlots() => const [
|
||||||
|
PenSlot(
|
||||||
|
id: 'slot_0',
|
||||||
|
brush: BrushKind.fountainPen,
|
||||||
|
color: Colors.black,
|
||||||
|
width: 0.006,
|
||||||
|
),
|
||||||
|
PenSlot(
|
||||||
|
id: 'slot_1',
|
||||||
|
brush: BrushKind.ballpoint,
|
||||||
|
color: Colors.blue,
|
||||||
|
width: 0.0022,
|
||||||
|
),
|
||||||
|
PenSlot(
|
||||||
|
id: 'slot_2',
|
||||||
|
brush: BrushKind.pencil,
|
||||||
|
color: Colors.green,
|
||||||
|
width: 0.003,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
|
||||||
|
const double kThicknessSmall = 0.0022;
|
||||||
|
const double kThicknessMedium = 0.006;
|
||||||
|
const double kThicknessLarge = 0.012;
|
||||||
|
|
||||||
|
/// Allowed range for slot stroke width (page-width fraction).
|
||||||
|
const double kPenSlotWidthMin = 0.001;
|
||||||
|
const double kPenSlotWidthMax = 0.05;
|
||||||
|
|
||||||
|
/// Manages independent [PenSlot]s with SharedPreferences persistence.
|
||||||
|
///
|
||||||
|
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
|
||||||
|
class PenSlotsController extends ChangeNotifier {
|
||||||
|
PenSlotsController._(this._prefs, this._slots, this._activeId);
|
||||||
|
|
||||||
|
/// SharedPreferences key for the slots JSON blob.
|
||||||
|
static const prefsKey = 'pen_slots_v1';
|
||||||
|
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
List<PenSlot> _slots;
|
||||||
|
String _activeId;
|
||||||
|
|
||||||
|
List<PenSlot> get slots => List.unmodifiable(_slots);
|
||||||
|
|
||||||
|
String get activeId => _activeId;
|
||||||
|
|
||||||
|
PenSlot get active {
|
||||||
|
for (final s in _slots) {
|
||||||
|
if (s.id == _activeId) return s;
|
||||||
|
}
|
||||||
|
return _slots.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
|
||||||
|
/// JSON.
|
||||||
|
static Future<PenSlotsController> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(prefsKey);
|
||||||
|
var slots = kDefaultPenSlots();
|
||||||
|
var activeId = slots.first.id;
|
||||||
|
if (raw != null) {
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
final list = map['slots'] as List<dynamic>?;
|
||||||
|
if (list != null && list.isNotEmpty) {
|
||||||
|
slots = [
|
||||||
|
for (final e in list)
|
||||||
|
PenSlot.fromJson(e as Map<String, dynamic>),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
final storedActive = map['activeId'] as String?;
|
||||||
|
if (storedActive != null &&
|
||||||
|
slots.any((s) => s.id == storedActive)) {
|
||||||
|
activeId = storedActive;
|
||||||
|
} else {
|
||||||
|
activeId = slots.first.id;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
slots = kDefaultPenSlots();
|
||||||
|
activeId = slots.first.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PenSlotsController._(prefs, slots, activeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
await _prefs.setString(
|
||||||
|
prefsKey,
|
||||||
|
jsonEncode({
|
||||||
|
'activeId': _activeId,
|
||||||
|
'slots': [for (final s in _slots) s.toJson()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _indexOfActive() {
|
||||||
|
final i = _slots.indexWhere((s) => s.id == _activeId);
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceActive(PenSlot next) {
|
||||||
|
final i = _indexOfActive();
|
||||||
|
_slots = [..._slots]..[i] = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects [id] as the active slot (restores brush + color + width).
|
||||||
|
Future<void> select(String id) async {
|
||||||
|
if (!_slots.any((s) => s.id == id)) return;
|
||||||
|
if (_activeId == id) return;
|
||||||
|
_activeId = id;
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the active slot's color.
|
||||||
|
Future<void> setActiveColor(Color c) async {
|
||||||
|
_replaceActive(active.copyWith(color: c));
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the active slot's stroke width (clamped).
|
||||||
|
Future<void> setActiveWidth(double w) async {
|
||||||
|
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
|
||||||
|
_replaceActive(active.copyWith(width: clamped));
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the active slot's brush kind.
|
||||||
|
Future<void> setActiveBrush(BrushKind b) async {
|
||||||
|
if (b == BrushKind.highlighter) return;
|
||||||
|
_replaceActive(active.copyWith(brush: b));
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,33 +8,105 @@
|
|||||||
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
||||||
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
|
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
|
||||||
//
|
//
|
||||||
|
// Named [PressureCurveShape] presets mirror rnote-style curves (linear / soft /
|
||||||
|
// Pow2 / cubic / log / sqrt) via [PressureCurve.shaped]. Logarithmic uses
|
||||||
|
// ln(1+k·p)/ln(1+k); all others use p^gamma.
|
||||||
|
//
|
||||||
// Pure value type (widget-free, storage-free) so the full mapping is unit
|
// Pure value type (widget-free, storage-free) so the full mapping is unit
|
||||||
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
||||||
// draw path and is validated on-device).
|
// draw path and is validated on-device).
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// Default pressure-response exponent. <1 so light-to-medium pressure registers
|
||||||
|
/// more width — the responsive, rnote/OneNote-like feel — instead of the raw
|
||||||
|
/// linear mapping that made the pen feel like a pressure-sensitive finger.
|
||||||
|
const double kNaturalPressureGamma = 0.7;
|
||||||
|
|
||||||
|
/// Default minimum shaped pressure: even the lightest touch keeps ~12% of the
|
||||||
|
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
|
||||||
|
const double kNaturalPressureFloor = 0.12;
|
||||||
|
|
||||||
|
/// Steepness for [PressureCurveShape.logarithmic]: `ln(1+k·p)/ln(1+k)`.
|
||||||
|
const double kLogarithmicPressureK = 9.0;
|
||||||
|
|
||||||
|
/// Named rnote-style pressure-response shapes.
|
||||||
|
enum PressureCurveShape {
|
||||||
|
/// Identity: gamma 1.
|
||||||
|
linear,
|
||||||
|
|
||||||
|
/// Light-touch sensitive: gamma ≈ 0.6.
|
||||||
|
soft,
|
||||||
|
|
||||||
|
/// rnote Pow2 / fountain: gamma 2.
|
||||||
|
quadratic,
|
||||||
|
|
||||||
|
/// gamma 3.
|
||||||
|
cubic,
|
||||||
|
|
||||||
|
/// Log curve: ln(1+k·p)/ln(1+k).
|
||||||
|
logarithmic,
|
||||||
|
|
||||||
|
/// Pencil: gamma 0.5.
|
||||||
|
sqrt,
|
||||||
|
}
|
||||||
|
|
||||||
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
|
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
|
||||||
class PressureCurve {
|
class PressureCurve {
|
||||||
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
|
const PressureCurve({
|
||||||
: assert(floor >= 0.0 && floor < 1.0),
|
this.floor = 0.0,
|
||||||
|
this.gamma = 1.0,
|
||||||
|
this.shape,
|
||||||
|
}) : assert(floor >= 0.0 && floor < 1.0),
|
||||||
assert(gamma > 0.0);
|
assert(gamma > 0.0);
|
||||||
|
|
||||||
|
/// Named-shape factory. Sets [gamma] for power-law shapes; logarithmic
|
||||||
|
/// ignores gamma and uses [kLogarithmicPressureK] in [apply].
|
||||||
|
factory PressureCurve.shaped(
|
||||||
|
PressureCurveShape shape, {
|
||||||
|
double floor = 0.0,
|
||||||
|
}) {
|
||||||
|
switch (shape) {
|
||||||
|
case PressureCurveShape.linear:
|
||||||
|
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||||
|
case PressureCurveShape.soft:
|
||||||
|
return PressureCurve(floor: floor, gamma: 0.6, shape: shape);
|
||||||
|
case PressureCurveShape.quadratic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 2.0, shape: shape);
|
||||||
|
case PressureCurveShape.cubic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 3.0, shape: shape);
|
||||||
|
case PressureCurveShape.logarithmic:
|
||||||
|
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
|
||||||
|
case PressureCurveShape.sqrt:
|
||||||
|
return PressureCurve(floor: floor, gamma: 0.5, shape: shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
|
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
|
||||||
/// fixed-pressure feel (marker).
|
/// fixed-pressure feel (marker).
|
||||||
final double floor;
|
final double floor;
|
||||||
|
|
||||||
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
||||||
/// >1 = firmer.
|
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
|
||||||
final double gamma;
|
final double gamma;
|
||||||
|
|
||||||
|
/// Optional named shape. When [PressureCurveShape.logarithmic], [apply] uses
|
||||||
|
/// the log formula; otherwise (or when null) uses `p^gamma`.
|
||||||
|
final PressureCurveShape? shape;
|
||||||
|
|
||||||
/// Linear, full-range pen response (identity).
|
/// Linear, full-range pen response (identity).
|
||||||
static const PressureCurve linear = PressureCurve();
|
static const PressureCurve linear = PressureCurve();
|
||||||
|
|
||||||
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
||||||
double apply(double pressure) {
|
double apply(double pressure) {
|
||||||
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
||||||
final shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
final double shaped;
|
||||||
|
if (shape == PressureCurveShape.logarithmic) {
|
||||||
|
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
|
||||||
|
math.log(1.0 + kLogarithmicPressureK);
|
||||||
|
} else {
|
||||||
|
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||||
|
}
|
||||||
return floor + (1.0 - floor) * shaped;
|
return floor + (1.0 - floor) * shaped;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// lib/editor/notebook/ink_stroke_adapter.dart
|
||||||
|
//
|
||||||
|
// Bridge between the legacy note/ppt storage model (`InkStroke`, ABSOLUTE pixel
|
||||||
|
// coordinates, `PenTool`) and the pen-first canvas model (`PenStroke`,
|
||||||
|
// NORMALIZED [0,1] coordinates, `PenStrokeKind`). The pen-first canvas is the
|
||||||
|
// single performant inking engine, so notes and slides are rebuilt on top of it
|
||||||
|
// and persisted back as `InkStroke` via this adapter.
|
||||||
|
//
|
||||||
|
// Coordinates are normalized against a logical page rectangle: ink absolute
|
||||||
|
// (x,y) -> pen (x/pageW, y/pageH) and back. Stroke width is likewise expressed
|
||||||
|
// as a fraction of the page width on the pen side and as absolute pixels on the
|
||||||
|
// ink side. Only freehand pen/highlighter strokes round-trip; shape/text
|
||||||
|
// `PenTool`s have no pen-canvas representation and are dropped (the pen-first
|
||||||
|
// note is handwriting-first — see the rebuild roadmap).
|
||||||
|
|
||||||
|
import 'dart:ui' show Size;
|
||||||
|
|
||||||
|
import '../../models/ink_point.dart';
|
||||||
|
import '../../models/ink_stroke.dart';
|
||||||
|
import '../../models/pen_tool.dart';
|
||||||
|
import '../canvas/pen_stroke.dart';
|
||||||
|
import '../engine/brush.dart';
|
||||||
|
|
||||||
|
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
|
||||||
|
/// Strokes are normalized against this so they stay pinned under zoom/pan.
|
||||||
|
const Size kNoteLogicalPage = Size(1000, 1414);
|
||||||
|
|
||||||
|
/// True when [tool] is a freehand mark the pen canvas can render
|
||||||
|
/// (pen/marker/highlighter). Shapes and text are not representable.
|
||||||
|
bool isFreehandTool(PenTool tool) =>
|
||||||
|
tool == PenTool.pen ||
|
||||||
|
tool == PenTool.marker ||
|
||||||
|
tool == PenTool.highlighter;
|
||||||
|
|
||||||
|
/// Maps an ink [PenTool] to the pen-canvas stroke kind.
|
||||||
|
PenStrokeKind penKindFromTool(PenTool tool) =>
|
||||||
|
tool == PenTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen;
|
||||||
|
|
||||||
|
/// Maps a pen-canvas stroke kind back to a [PenTool].
|
||||||
|
PenTool toolFromPenKind(PenStrokeKind kind) =>
|
||||||
|
kind == PenStrokeKind.highlighter ? PenTool.highlighter : PenTool.pen;
|
||||||
|
|
||||||
|
/// Convert a stored [InkStroke] (absolute px on [page]) to a [PenStroke]
|
||||||
|
/// (normalized). Returns null for non-freehand strokes (shapes/text), which the
|
||||||
|
/// pen canvas cannot draw.
|
||||||
|
PenStroke? penStrokeFromInk(InkStroke s, Size page) {
|
||||||
|
if (!isFreehandTool(s.tool)) return null;
|
||||||
|
if (s.points.isEmpty) return null;
|
||||||
|
final w = page.width <= 0 ? 1.0 : page.width;
|
||||||
|
final h = page.height <= 0 ? 1.0 : page.height;
|
||||||
|
return PenStroke(
|
||||||
|
points: [
|
||||||
|
for (final p in s.points)
|
||||||
|
PenPoint(p.x / w, p.y / h, p.pressure, tilt: p.tilt),
|
||||||
|
],
|
||||||
|
color: s.color,
|
||||||
|
width: s.strokeWidth / w,
|
||||||
|
kind: penKindFromTool(s.tool),
|
||||||
|
// Brush isn't persisted yet (TODO(brush-persist)); derive from the tool so
|
||||||
|
// a loaded highlighter renders with the flat highlighter brush and pens
|
||||||
|
// fall back to the fountainPen default.
|
||||||
|
brush: s.tool == PenTool.highlighter
|
||||||
|
? BrushKind.highlighter
|
||||||
|
: BrushKind.fountainPen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a freshly drawn [PenStroke] (normalized) back to an [InkStroke]
|
||||||
|
/// (absolute px on [page]) for persistence. [id] and [createdAt] come from the
|
||||||
|
/// caller (uuid + clock) so this stays pure/deterministic.
|
||||||
|
InkStroke inkStrokeFromPen(
|
||||||
|
PenStroke s,
|
||||||
|
Size page, {
|
||||||
|
required String id,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) {
|
||||||
|
final w = page.width <= 0 ? 1.0 : page.width;
|
||||||
|
final h = page.height <= 0 ? 1.0 : page.height;
|
||||||
|
return InkStroke(
|
||||||
|
id: id,
|
||||||
|
points: [
|
||||||
|
for (final p in s.points)
|
||||||
|
InkPoint(
|
||||||
|
x: p.x * w,
|
||||||
|
y: p.y * h,
|
||||||
|
pressure: p.pressure ?? 0.5,
|
||||||
|
tilt: p.tilt ?? 0.0,
|
||||||
|
timestamp: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tool: toolFromPenKind(s.kind),
|
||||||
|
color: s.color,
|
||||||
|
strokeWidth: s.width * w,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a list of stored ink strokes to pen strokes, dropping the ones the
|
||||||
|
/// canvas cannot represent (shapes/text). Order is preserved.
|
||||||
|
List<PenStroke> penStrokesFromInk(Iterable<InkStroke> strokes, Size page) =>
|
||||||
|
[for (final s in strokes) penStrokeFromInk(s, page)]
|
||||||
|
.whereType<PenStroke>()
|
||||||
|
.toList();
|
||||||
61
lib/editor/pdf/page_tile_layer.dart
Normal file
61
lib/editor/pdf/page_tile_layer.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Double-buffer helper on top of [PageTileCache] to kill zoom white-flash:
|
||||||
|
// keep painting the last good tile while a higher-DPI raster is in flight.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'page_tile_cache.dart';
|
||||||
|
|
||||||
|
/// Holds the "last good" page image for the currently visible page so a zoom
|
||||||
|
/// settle never exposes an empty frame (plan W2 / R11).
|
||||||
|
class PageTileLayer extends ChangeNotifier {
|
||||||
|
PageTileLayer({PageTileCache? cache}) : _cache = cache ?? PageTileCache();
|
||||||
|
|
||||||
|
final PageTileCache _cache;
|
||||||
|
ui.Image? _lastGood;
|
||||||
|
TileKey? _lastKey;
|
||||||
|
|
||||||
|
PageTileCache get cache => _cache;
|
||||||
|
ui.Image? get lastGood => _lastGood;
|
||||||
|
TileKey? get lastKey => _lastKey;
|
||||||
|
|
||||||
|
/// Snap continuous zoom to a coarse DPI bucket (avoids a tile per frame).
|
||||||
|
static int dpiBucketFor(double zoom, {double baseDpi = 96, double step = 0.5}) {
|
||||||
|
final raw = zoom / step;
|
||||||
|
final snapped = raw.round().clamp(1, 16);
|
||||||
|
return (snapped * step * baseDpi).round();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Promote [image] as the last-good tile for [key].
|
||||||
|
void put(TileKey key, ui.Image image) {
|
||||||
|
_cache.put(key, image);
|
||||||
|
_lastGood = image;
|
||||||
|
_lastKey = key;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefer exact bucket; else fall back to last-good so zoom never blanks.
|
||||||
|
ui.Image? resolve(TileKey key) {
|
||||||
|
final hit = _cache.get(key);
|
||||||
|
if (hit != null) {
|
||||||
|
_lastGood = hit;
|
||||||
|
_lastKey = key;
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
return _lastGood;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_lastGood = null;
|
||||||
|
_lastKey = null;
|
||||||
|
_cache.dispose();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// lib/editor/pdf/slide_export.dart
|
||||||
|
//
|
||||||
|
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
|
||||||
|
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
|
||||||
|
// just maps each normalized point into the slide image's draw rectangle on the
|
||||||
|
// PDF page — no live-widget-size guessing, which is what made the old PPT
|
||||||
|
// exporter misalign ink (see the removed ppt_annotator_screen comment).
|
||||||
|
|
||||||
|
import 'dart:ui' show Offset, Rect, Size;
|
||||||
|
|
||||||
|
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
|
||||||
|
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
|
||||||
|
/// exported ink lands exactly where it was drawn.
|
||||||
|
Rect slideDrawRect(Size page, Size image) {
|
||||||
|
final iw = image.width <= 0 ? 1.0 : image.width;
|
||||||
|
final ih = image.height <= 0 ? 1.0 : image.height;
|
||||||
|
final scale = (page.width / iw) < (page.height / ih)
|
||||||
|
? page.width / iw
|
||||||
|
: page.height / ih;
|
||||||
|
final drawW = iw * scale;
|
||||||
|
final drawH = ih * scale;
|
||||||
|
final offX = (page.width - drawW) / 2;
|
||||||
|
final offY = (page.height - drawH) / 2;
|
||||||
|
return Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
|
||||||
|
/// inside the slide's [drawRect] on the PDF page.
|
||||||
|
Offset normToSlide(double nx, double ny, Rect drawRect) =>
|
||||||
|
Offset(drawRect.left + nx * drawRect.width,
|
||||||
|
drawRect.top + ny * drawRect.height);
|
||||||
|
|
||||||
|
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
|
||||||
|
/// page width, scaled into [drawRect].
|
||||||
|
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
|
||||||
|
normalizedWidth * drawRect.width;
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
// lib/editor/pdf/spike_app.dart
|
|
||||||
//
|
|
||||||
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
|
|
||||||
// on-screen frame-timing HUD (median build & raster ms over the last ~120
|
|
||||||
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
|
|
||||||
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/scheduler.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
|
|
||||||
import 'spike_editor_pane.dart';
|
|
||||||
|
|
||||||
class SpikeApp extends StatelessWidget {
|
|
||||||
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
|
|
||||||
|
|
||||||
final String pdfPath;
|
|
||||||
final String? denseStrokesAsset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'BadNote M1 Spike',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
|
|
||||||
home: SpikeHome(
|
|
||||||
pdfPath: pdfPath,
|
|
||||||
denseStrokesAsset: denseStrokesAsset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpikeHome extends StatefulWidget {
|
|
||||||
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
|
|
||||||
|
|
||||||
final String pdfPath;
|
|
||||||
final String? denseStrokesAsset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SpikeHome> createState() => _SpikeHomeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpikeHomeState extends State<SpikeHome> {
|
|
||||||
final GlobalKey<SpikeEditorPaneState> _paneKey =
|
|
||||||
GlobalKey<SpikeEditorPaneState>();
|
|
||||||
final PdfViewerController _controller = PdfViewerController();
|
|
||||||
bool _inkLoad = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
SpikeEditorPane(
|
|
||||||
key: _paneKey,
|
|
||||||
controller: _controller,
|
|
||||||
pdfPath: widget.pdfPath,
|
|
||||||
denseStrokesAsset: widget.denseStrokesAsset,
|
|
||||||
),
|
|
||||||
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
FloatingActionButton.extended(
|
|
||||||
heroTag: 'inkload',
|
|
||||||
onPressed: () async {
|
|
||||||
final next = !_inkLoad;
|
|
||||||
await _paneKey.currentState?.setInkLoad(next);
|
|
||||||
setState(() => _inkLoad = next);
|
|
||||||
},
|
|
||||||
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
|
|
||||||
icon: const Icon(Icons.brush),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On-screen median build/raster frame-time HUD, driven by
|
|
||||||
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
|
|
||||||
/// [_window] frames for both the build (`buildDuration`) and raster
|
|
||||||
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
|
|
||||||
/// MUST #4/#5.
|
|
||||||
class FrameTimingHud extends StatefulWidget {
|
|
||||||
const FrameTimingHud({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<FrameTimingHud> createState() => _FrameTimingHudState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FrameTimingHudState extends State<FrameTimingHud> {
|
|
||||||
static const int _window = 120;
|
|
||||||
final List<double> _build = <double>[];
|
|
||||||
final List<double> _raster = <double>[];
|
|
||||||
double _medBuild = 0;
|
|
||||||
double _medRaster = 0;
|
|
||||||
double _p95Build = 0;
|
|
||||||
double _p95Raster = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onTimings(List<FrameTiming> timings) {
|
|
||||||
for (final t in timings) {
|
|
||||||
_build.add(t.buildDuration.inMicroseconds / 1000.0);
|
|
||||||
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
|
||||||
}
|
|
||||||
while (_build.length > _window) {
|
|
||||||
_build.removeAt(0);
|
|
||||||
}
|
|
||||||
while (_raster.length > _window) {
|
|
||||||
_raster.removeAt(0);
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_medBuild = _percentile(_build, 50);
|
|
||||||
_medRaster = _percentile(_raster, 50);
|
|
||||||
_p95Build = _percentile(_build, 95);
|
|
||||||
_p95Raster = _percentile(_raster, 95);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static double _percentile(List<double> values, int p) {
|
|
||||||
if (values.isEmpty) return 0;
|
|
||||||
final sorted = List<double>.from(values)..sort();
|
|
||||||
final idx = ((p / 100.0) * (sorted.length - 1)).round();
|
|
||||||
return sorted[idx.clamp(0, sorted.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Color budget(double ms) => ms <= 16.6
|
|
||||||
? Colors.greenAccent
|
|
||||||
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
|
|
||||||
return IgnorePointer(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.65),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: DefaultTextStyle(
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('frames: ${_build.length}/$_window'),
|
|
||||||
Text.rich(TextSpan(children: [
|
|
||||||
const TextSpan(text: 'build med '),
|
|
||||||
TextSpan(
|
|
||||||
text: '${_medBuild.toStringAsFixed(1)}ms',
|
|
||||||
style: TextStyle(color: budget(_medBuild))),
|
|
||||||
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
|
|
||||||
])),
|
|
||||||
Text.rich(TextSpan(children: [
|
|
||||||
const TextSpan(text: 'raster med '),
|
|
||||||
TextSpan(
|
|
||||||
text: '${_medRaster.toStringAsFixed(1)}ms',
|
|
||||||
style: TextStyle(color: budget(_medRaster))),
|
|
||||||
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
|
|
||||||
])),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
// lib/editor/pdf/spike_editor_pane.dart
|
|
||||||
//
|
|
||||||
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
|
|
||||||
// PdfViewer.file and exercises the three things the M1 gate must prove:
|
|
||||||
//
|
|
||||||
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
|
|
||||||
// diagnostic crosshair at normalized (0.5, 0.5) using
|
|
||||||
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
|
|
||||||
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
|
|
||||||
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
|
|
||||||
//
|
|
||||||
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
|
|
||||||
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
|
|
||||||
// touch scrolls and pinch zooms — same overlay, no mode switch.
|
|
||||||
//
|
|
||||||
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
|
|
||||||
// per page (from dense_strokes.json) into the page overlay so the perf
|
|
||||||
// bench can measure BUILD time with a non-trivial ui.Picture per page.
|
|
||||||
//
|
|
||||||
// This file is NOT production code and is excluded from the real editor.
|
|
||||||
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
|
|
||||||
import 'pen_capture_region.dart';
|
|
||||||
|
|
||||||
/// Normalized page-space point the diagnostic marker is painted at. The M1
|
|
||||||
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
|
|
||||||
const Offset kMarkerNormalized = Offset(0.5, 0.5);
|
|
||||||
|
|
||||||
/// A single captured pen sample in normalized page space, tagged with its page.
|
|
||||||
class _PenSample {
|
|
||||||
const _PenSample(this.pageIndex, this.normalized);
|
|
||||||
final int pageIndex;
|
|
||||||
final Offset normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
|
|
||||||
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
|
|
||||||
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
|
|
||||||
class SpikeEditorPane extends StatefulWidget {
|
|
||||||
const SpikeEditorPane({
|
|
||||||
super.key,
|
|
||||||
required this.pdfPath,
|
|
||||||
this.denseStrokesAsset,
|
|
||||||
this.strokesPerPage = 300,
|
|
||||||
this.strokeCountKey = '2000',
|
|
||||||
this.onViewerReady,
|
|
||||||
this.controller,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String pdfPath;
|
|
||||||
final String? denseStrokesAsset;
|
|
||||||
final int strokesPerPage;
|
|
||||||
|
|
||||||
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
|
|
||||||
final String strokeCountKey;
|
|
||||||
|
|
||||||
/// Forwarded from pdfrx once the document is laid out and interactive.
|
|
||||||
final void Function(PdfDocument document, PdfViewerController controller)?
|
|
||||||
onViewerReady;
|
|
||||||
|
|
||||||
/// Optional externally-owned controller (tests drive zoom through this).
|
|
||||||
final PdfViewerController? controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpikeEditorPaneState extends State<SpikeEditorPane> {
|
|
||||||
late final PdfViewerController _controller =
|
|
||||||
widget.controller ?? PdfViewerController();
|
|
||||||
|
|
||||||
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
|
|
||||||
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
|
|
||||||
List<_PenSample>? _activeStroke;
|
|
||||||
|
|
||||||
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
|
|
||||||
/// list of normalized polylines (one stroke = list of points).
|
|
||||||
List<List<Offset>>? _syntheticStrokes;
|
|
||||||
bool _inkLoadEnabled = false;
|
|
||||||
bool _loadingSynthetic = false;
|
|
||||||
|
|
||||||
bool get inkLoadEnabled => _inkLoadEnabled;
|
|
||||||
|
|
||||||
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
|
|
||||||
/// enable. Public so the perf bench can drive it programmatically.
|
|
||||||
Future<void> setInkLoad(bool enabled) async {
|
|
||||||
if (enabled && _syntheticStrokes == null) {
|
|
||||||
await _loadSyntheticStrokes();
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _inkLoadEnabled = enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadSyntheticStrokes() async {
|
|
||||||
final asset = widget.denseStrokesAsset;
|
|
||||||
if (asset == null || _loadingSynthetic) return;
|
|
||||||
_loadingSynthetic = true;
|
|
||||||
try {
|
|
||||||
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
|
|
||||||
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
|
|
||||||
final raw = await File(asset).readAsString();
|
|
||||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
|
||||||
final strokesJson =
|
|
||||||
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
|
|
||||||
final result = <List<Offset>>[];
|
|
||||||
for (final s in strokesJson) {
|
|
||||||
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
|
|
||||||
final poly = <Offset>[];
|
|
||||||
for (final p in points) {
|
|
||||||
final pt = p as Map<String, dynamic>;
|
|
||||||
poly.add(Offset(
|
|
||||||
(pt['x'] as num).toDouble(),
|
|
||||||
(pt['y'] as num).toDouble(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (poly.length >= 2) result.add(poly);
|
|
||||||
}
|
|
||||||
_syntheticStrokes = result;
|
|
||||||
} finally {
|
|
||||||
_loadingSynthetic = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Pen capture (viewer-level) ---------------------------------------
|
|
||||||
|
|
||||||
void _onPenEvent(PointerEvent event) {
|
|
||||||
// Convert global → document → which page + normalized page coords.
|
|
||||||
final doc = _controller.globalToDocument(event.position);
|
|
||||||
if (doc == null) return;
|
|
||||||
final hit = _documentToPage(doc);
|
|
||||||
if (hit == null) return;
|
|
||||||
|
|
||||||
if (event is PointerDownEvent) {
|
|
||||||
_activeStroke = <_PenSample>[hit];
|
|
||||||
_penStrokes.add(_activeStroke!);
|
|
||||||
setState(() {});
|
|
||||||
} else if (event is PointerMoveEvent) {
|
|
||||||
_activeStroke?.add(hit);
|
|
||||||
setState(() {});
|
|
||||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
|
||||||
_activeStroke = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
|
|
||||||
/// controller's page layout rects (document coordinates). Returns null if the
|
|
||||||
/// point is outside every page box.
|
|
||||||
_PenSample? _documentToPage(Offset doc) {
|
|
||||||
if (!_controller.isReady) return null;
|
|
||||||
final rects = _controller.layout.pageLayouts;
|
|
||||||
for (var i = 0; i < rects.length; i++) {
|
|
||||||
final r = rects[i];
|
|
||||||
if (r.contains(doc)) {
|
|
||||||
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
|
||||||
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
|
||||||
return _PenSample(i, Offset(nx, ny));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
PdfViewer.file(
|
|
||||||
widget.pdfPath,
|
|
||||||
controller: _controller,
|
|
||||||
params: PdfViewerParams(
|
|
||||||
onViewerReady: widget.onViewerReady,
|
|
||||||
// (1) Per-page overlay: diagnostic center marker + optional synthetic
|
|
||||||
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
|
|
||||||
// normalized [0,1] → zoomed pixels (plan §2.1).
|
|
||||||
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
|
||||||
final pageIndex = page.pageNumber - 1;
|
|
||||||
return [
|
|
||||||
SizedBox.fromSize(
|
|
||||||
size: pageRectInViewer.size,
|
|
||||||
child: CustomPaint(
|
|
||||||
painter: _SpikeInkPainter(
|
|
||||||
synthetic:
|
|
||||||
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
|
|
||||||
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
|
|
||||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
|
||||||
return [
|
|
||||||
Positioned.fill(
|
|
||||||
child: PenCaptureRegion(
|
|
||||||
onPenEvent: _onPenEvent,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: CustomPaint(
|
|
||||||
size: size,
|
|
||||||
painter: _LivePenPainter(
|
|
||||||
strokes: _penStrokes,
|
|
||||||
controller: _controller,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deterministic per-page slice of the synthetic stroke pool so each page
|
|
||||||
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
|
|
||||||
List<List<Offset>> _strokesForPage(int pageIndex) {
|
|
||||||
final pool = _syntheticStrokes;
|
|
||||||
if (pool == null || pool.isEmpty) return const [];
|
|
||||||
final n = widget.strokesPerPage.clamp(0, pool.length);
|
|
||||||
final start = (pageIndex * n) % pool.length;
|
|
||||||
final out = <List<Offset>>[];
|
|
||||||
for (var i = 0; i < n; i++) {
|
|
||||||
out.add(pool[(start + i) % pool.length]);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
|
|
||||||
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
|
|
||||||
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
|
|
||||||
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
|
|
||||||
/// inspects.
|
|
||||||
class _SpikeInkPainter extends CustomPainter {
|
|
||||||
_SpikeInkPainter({this.synthetic});
|
|
||||||
|
|
||||||
final List<List<Offset>>? synthetic;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
canvas.save();
|
|
||||||
// Map normalized [0,1] → zoomed pixels (plan §2.1).
|
|
||||||
canvas.scale(size.width, size.height);
|
|
||||||
|
|
||||||
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
|
|
||||||
final syn = synthetic;
|
|
||||||
if (syn != null && syn.isNotEmpty) {
|
|
||||||
final inkPaint = Paint()
|
|
||||||
..color = const Color(0x5500AAFF)
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
// Stroke width is in normalized units post-scale; keep it page-relative
|
|
||||||
// and hairline-ish so 300 strokes are visible but cheap.
|
|
||||||
..strokeWidth = 0.002
|
|
||||||
..strokeCap = StrokeCap.round;
|
|
||||||
for (final poly in syn) {
|
|
||||||
if (poly.length < 2) continue;
|
|
||||||
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
|
|
||||||
for (var i = 1; i < poly.length; i++) {
|
|
||||||
path.lineTo(poly[i].dx, poly[i].dy);
|
|
||||||
}
|
|
||||||
canvas.drawPath(path, inkPaint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.restore();
|
|
||||||
|
|
||||||
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
|
|
||||||
// restore) so its line thickness is constant on screen and its CENTER is at
|
|
||||||
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
|
|
||||||
// this pixel.
|
|
||||||
final center = Offset(
|
|
||||||
size.width * kMarkerNormalized.dx,
|
|
||||||
size.height * kMarkerNormalized.dy,
|
|
||||||
);
|
|
||||||
final markerPaint = Paint()
|
|
||||||
..color = const Color(0xFFFF0066)
|
|
||||||
..strokeWidth = 2.0
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
const arm = 16.0;
|
|
||||||
canvas.drawLine(
|
|
||||||
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
|
|
||||||
canvas.drawLine(
|
|
||||||
center.translate(0, -arm), center.translate(0, arm), markerPaint);
|
|
||||||
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
|
|
||||||
oldDelegate.synthetic != synthetic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
|
|
||||||
/// in normalized page space, so for each sample we re-project page→document→
|
|
||||||
/// local each paint via the controller (keeps strokes glued to pages under
|
|
||||||
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
|
|
||||||
class _LivePenPainter extends CustomPainter {
|
|
||||||
_LivePenPainter({required this.strokes, required this.controller})
|
|
||||||
: super(repaint: controller);
|
|
||||||
|
|
||||||
final List<List<_PenSample>> strokes;
|
|
||||||
final PdfViewerController controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
if (!controller.isReady) return;
|
|
||||||
final rects = controller.layout.pageLayouts;
|
|
||||||
final paint = Paint()
|
|
||||||
..color = const Color(0xFF1565C0)
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 3.0
|
|
||||||
..strokeCap = StrokeCap.round
|
|
||||||
..strokeJoin = StrokeJoin.round;
|
|
||||||
|
|
||||||
for (final stroke in strokes) {
|
|
||||||
Path? path;
|
|
||||||
for (final s in stroke) {
|
|
||||||
if (s.pageIndex >= rects.length) continue;
|
|
||||||
final r = rects[s.pageIndex];
|
|
||||||
// normalized page → document
|
|
||||||
final docPt = Offset(
|
|
||||||
r.left + s.normalized.dx * r.width,
|
|
||||||
r.top + s.normalized.dy * r.height,
|
|
||||||
);
|
|
||||||
// document → local (viewer) coords
|
|
||||||
final local = controller.documentToLocal(docPt);
|
|
||||||
if (path == null) {
|
|
||||||
path = Path()..moveTo(local.dx, local.dy);
|
|
||||||
} else {
|
|
||||||
path.lineTo(local.dx, local.dy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (path != null) canvas.drawPath(path, paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// lib/editor/pdf/spike_launcher.dart
|
|
||||||
//
|
|
||||||
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
|
|
||||||
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
|
|
||||||
// real Surface Pen with the user's OWN large PDFs). Remove together with the
|
|
||||||
// rest of lib/editor/pdf/spike_* once M1 is signed off.
|
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../canvas/pen_editor_screen.dart';
|
|
||||||
|
|
||||||
/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor.
|
|
||||||
///
|
|
||||||
/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which
|
|
||||||
/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old
|
|
||||||
/// spike_* files are left in place but no longer wired to this entry.
|
|
||||||
Future<void> openM1Spike(BuildContext context) async {
|
|
||||||
final result = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['pdf'],
|
|
||||||
);
|
|
||||||
final path = result?.files.single.path;
|
|
||||||
if (path == null) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => PenEditorScreen(pdfPath: path),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// lib/editor/pdf/spike_main.dart
|
|
||||||
//
|
|
||||||
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
|
|
||||||
//
|
|
||||||
// Launch on the Windows tablet (or any desktop with a display):
|
|
||||||
// flutter run -t lib/editor/pdf/spike_main.dart
|
|
||||||
//
|
|
||||||
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
|
|
||||||
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
|
|
||||||
// observable on-device.
|
|
||||||
//
|
|
||||||
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
|
|
||||||
// below before pdfrx init). pdfrx itself is initialized via
|
|
||||||
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
|
||||||
|
|
||||||
import 'pen_capture_region.dart';
|
|
||||||
import 'spike_app.dart';
|
|
||||||
|
|
||||||
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
|
|
||||||
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
|
|
||||||
|
|
||||||
/// Filesystem path for the synthetic ink load (regenerate via
|
|
||||||
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
|
|
||||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
|
||||||
|
|
||||||
void main(List<String> args) {
|
|
||||||
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
|
|
||||||
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
|
|
||||||
PenCaptureBinding.ensureInitialized();
|
|
||||||
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
|
|
||||||
pdfrxFlutterInitialize();
|
|
||||||
|
|
||||||
// Allow overriding the PDF path as the first CLI arg (otherwise the default
|
|
||||||
// 300-page bench asset relative to the project root / cwd).
|
|
||||||
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
|
|
||||||
|
|
||||||
runApp(
|
|
||||||
SpikeApp(
|
|
||||||
pdfPath: pdfPath,
|
|
||||||
denseStrokesAsset: _kDenseStrokesAsset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
|
|
||||||
/// the relative asset path works on desktop; we also try a couple of fallbacks.
|
|
||||||
String _resolvePdfPath() {
|
|
||||||
final candidates = <String>[
|
|
||||||
_kDefaultPdfRelPath,
|
|
||||||
'${Directory.current.path}/$_kDefaultPdfRelPath',
|
|
||||||
];
|
|
||||||
for (final c in candidates) {
|
|
||||||
if (File(c).existsSync()) return c;
|
|
||||||
}
|
|
||||||
// Return the primary path anyway; pdfrx will surface a clear load error.
|
|
||||||
return _kDefaultPdfRelPath;
|
|
||||||
}
|
|
||||||
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// lib/editor/persistence/sidecar_flush_observer.dart
|
||||||
|
//
|
||||||
|
// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file-
|
||||||
|
// based-storage.md): app-lifecycle flush hardening.
|
||||||
|
//
|
||||||
|
// The per-file SidecarRepository debounces writes by 800 ms. That window is the
|
||||||
|
// data-loss gap on a Windows tablet: if the OS suspends or closes the app
|
||||||
|
// before the timer fires, the last strokes never reach disk. This observer
|
||||||
|
// listens for the app leaving the foreground and DRAINS every open repo's
|
||||||
|
// pending write before the process can be frozen, so "never lose the last
|
||||||
|
// strokes on app close" holds even when the editor's own dispose() doesn't run.
|
||||||
|
//
|
||||||
|
// Registered once in BadNoteApp; it delegates to
|
||||||
|
// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush().
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'sidecar_repository.dart';
|
||||||
|
|
||||||
|
/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when
|
||||||
|
/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`).
|
||||||
|
class SidecarFlushObserver with WidgetsBindingObserver {
|
||||||
|
/// Whether the observer is currently registered with the binding.
|
||||||
|
bool get isAttached => _attached;
|
||||||
|
bool _attached = false;
|
||||||
|
|
||||||
|
/// Register with [WidgetsBinding.instance] so lifecycle changes are observed.
|
||||||
|
void attach() {
|
||||||
|
if (_attached) return;
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_attached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop observing lifecycle changes.
|
||||||
|
void detach() {
|
||||||
|
if (!_attached) return;
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_attached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
switch (state) {
|
||||||
|
// Any transition out of the foreground is a potential suspend/kill point:
|
||||||
|
// drain pending sidecar writes now (the editors' own dispose() may never
|
||||||
|
// run when the OS freezes the process).
|
||||||
|
case AppLifecycleState.inactive:
|
||||||
|
case AppLifecycleState.hidden:
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
case AppLifecycleState.detached:
|
||||||
|
// Fire-and-forget at the framework boundary, but each write is atomic
|
||||||
|
// and awaited inside flushAll, so a half-written sidecar is impossible.
|
||||||
|
SidecarRepositoryRegistry.flushAll();
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
lib/editor/persistence/sidecar_repository.dart
Normal file
440
lib/editor/persistence/sidecar_repository.dart
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// lib/editor/persistence/sidecar_repository.dart
|
||||||
|
//
|
||||||
|
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||||
|
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
|
||||||
|
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
|
||||||
|
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
|
||||||
|
// source file, so annotations travel with the file ("跟着文件走").
|
||||||
|
//
|
||||||
|
// Design:
|
||||||
|
// * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
|
||||||
|
// mutate it through the schedule* methods, which (1) update the in-memory
|
||||||
|
// model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
|
||||||
|
// mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
|
||||||
|
// debounce timer that writes the WHOLE sidecar atomically (§F.1, via
|
||||||
|
// SidecarStore.writeAtomic — temp + rename + .bak).
|
||||||
|
// * The unit of debounce is the whole document sidecar (sidecars are small —
|
||||||
|
// sparse normalized strokes), one atomic write per debounce window.
|
||||||
|
// * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
|
||||||
|
// The sidecar IS the identity.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../models/bookmark.dart';
|
||||||
|
import '../../models/scratch_link.dart';
|
||||||
|
import '../../storage/badnote_sidecar.dart';
|
||||||
|
import '../../storage/sidecar_store.dart';
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
|
||||||
|
/// Suffix appended to a source-file path to form its sidecar path.
|
||||||
|
const String kSidecarSuffix = '.badnote.json';
|
||||||
|
|
||||||
|
/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
|
||||||
|
///
|
||||||
|
/// The 800 ms debounce timer only protects against losing work to a crash that
|
||||||
|
/// happens *between* edits; it does NOT help when the OS suspends or kills the
|
||||||
|
/// app mid-window (the main data-loss window on a Windows tablet). The app's
|
||||||
|
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
|
||||||
|
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
|
||||||
|
/// before the process can be frozen.
|
||||||
|
///
|
||||||
|
/// A repo registers itself in [open] and removes itself in [dispose], so the
|
||||||
|
/// set always reflects exactly the editors holding unsaved sidecar state.
|
||||||
|
class SidecarRepositoryRegistry {
|
||||||
|
SidecarRepositoryRegistry._();
|
||||||
|
|
||||||
|
static final Set<SidecarRepository> _open = <SidecarRepository>{};
|
||||||
|
|
||||||
|
/// The currently open repositories (for tests / inspection).
|
||||||
|
static Set<SidecarRepository> get open => Set.unmodifiable(_open);
|
||||||
|
|
||||||
|
/// Flush every open repository's pending debounced write and await them all.
|
||||||
|
/// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
|
||||||
|
static Future<void> flushAll() async {
|
||||||
|
// Snapshot first: a flush may complete and (in a future) trigger disposal,
|
||||||
|
// which mutates `_open` — iterating a copy avoids concurrent-modification.
|
||||||
|
final repos = List<SidecarRepository>.of(_open);
|
||||||
|
await Future.wait(repos.map((r) => r.flush()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open repository for [sourceFilePath], or null if none is open. Lets a
|
||||||
|
/// background task (e.g. OCR) write through the SAME in-memory sidecar the
|
||||||
|
/// editor holds, instead of racing it with a second open handle.
|
||||||
|
static SidecarRepository? forPath(String sourceFilePath) {
|
||||||
|
for (final r in _open) {
|
||||||
|
if (r.sourceFilePath == sourceFilePath) return r;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _register(SidecarRepository repo) => _open.add(repo);
|
||||||
|
|
||||||
|
static void _unregister(SidecarRepository repo) => _open.remove(repo);
|
||||||
|
|
||||||
|
/// Test-only: drop all registrations so one test can't leak repos into the
|
||||||
|
/// next. Does NOT flush or dispose them.
|
||||||
|
static void resetForTest() => _open.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
|
||||||
|
/// path, holds it in memory, and debounces atomic writes back to disk.
|
||||||
|
class SidecarRepository {
|
||||||
|
SidecarRepository._({
|
||||||
|
required this.sourceFilePath,
|
||||||
|
required BadnoteSidecar sidecar,
|
||||||
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
|
}) : _sidecar = sidecar,
|
||||||
|
_debounce = debounce;
|
||||||
|
|
||||||
|
/// Absolute path to the annotated source file (e.g. the vault PDF copy).
|
||||||
|
final String sourceFilePath;
|
||||||
|
|
||||||
|
/// The sidecar file: `<sourceFilePath>.badnote.json`.
|
||||||
|
File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');
|
||||||
|
|
||||||
|
final Duration _debounce;
|
||||||
|
|
||||||
|
BadnoteSidecar _sidecar;
|
||||||
|
Timer? _timer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// How many editors currently hold this repo. [open] reuses an existing
|
||||||
|
/// instance and bumps the count; [dispose] only tears down at zero so a
|
||||||
|
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
|
||||||
|
int _retainCount = 1;
|
||||||
|
|
||||||
|
/// Tail of the in-flight write chain. Writes are serialized through this so a
|
||||||
|
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
|
||||||
|
/// same `.tmp`/rename (which would throw on the loser). Each write always
|
||||||
|
/// persists the LATEST snapshot, so collapsing overlapping writes is safe.
|
||||||
|
Future<void> _writeChain = Future<void>.value();
|
||||||
|
|
||||||
|
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||||
|
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||||
|
///
|
||||||
|
/// Reuses an already-open repo for the same path (retain-counted) so a
|
||||||
|
/// scratchpad overlay / split view cannot race the PDF editor with a second
|
||||||
|
/// in-memory snapshot that would overwrite scratchpad ink on flush.
|
||||||
|
static Future<SidecarRepository> open(
|
||||||
|
String sourceFilePath, {
|
||||||
|
String? docType,
|
||||||
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
|
}) async {
|
||||||
|
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
|
||||||
|
if (existing != null && !existing._disposed) {
|
||||||
|
existing._retainCount++;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
final file = File('$sourceFilePath$kSidecarSuffix');
|
||||||
|
final loaded = await SidecarStore.read(file);
|
||||||
|
var sidecar = loaded ??
|
||||||
|
BadnoteSidecar(
|
||||||
|
sourceFile: _basename(sourceFilePath),
|
||||||
|
docType: docType,
|
||||||
|
pageCount: docType == 'notebook' ? 1 : null,
|
||||||
|
createdAt: DateTime.now().toUtc(),
|
||||||
|
);
|
||||||
|
// Standalone notebooks always carry an explicit pageCount (min 1). Older
|
||||||
|
// sidecars that omit it are normalized in-memory on open.
|
||||||
|
if (docType == 'notebook' &&
|
||||||
|
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
|
||||||
|
sidecar = BadnoteSidecar(
|
||||||
|
version: sidecar.version,
|
||||||
|
sourceFile: sidecar.sourceFile,
|
||||||
|
docType: sidecar.docType,
|
||||||
|
title: sidecar.title,
|
||||||
|
pageCount: 1,
|
||||||
|
rotation: sidecar.rotation,
|
||||||
|
createdAt: sidecar.createdAt,
|
||||||
|
updatedAt: sidecar.updatedAt,
|
||||||
|
strokes: sidecar.strokes,
|
||||||
|
highlights: sidecar.highlights,
|
||||||
|
texts: sidecar.texts,
|
||||||
|
bookmarks: sidecar.bookmarks,
|
||||||
|
scratchLinks: sidecar.scratchLinks,
|
||||||
|
legacyAnnotations: sidecar.legacyAnnotations,
|
||||||
|
ocrText: sidecar.ocrText,
|
||||||
|
pageText: sidecar.pageText,
|
||||||
|
legacyId: sidecar.legacyId,
|
||||||
|
background: sidecar.background,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final repo = SidecarRepository._(
|
||||||
|
sourceFilePath: sourceFilePath,
|
||||||
|
sidecar: sidecar,
|
||||||
|
debounce: debounce,
|
||||||
|
);
|
||||||
|
SidecarRepositoryRegistry._register(repo);
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Page index → committed strokes loaded from the sidecar.
|
||||||
|
Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
|
||||||
|
|
||||||
|
/// Page index → highlight rects loaded from the sidecar.
|
||||||
|
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
|
||||||
|
|
||||||
|
/// Page index → typed-text annotations loaded from the sidecar.
|
||||||
|
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
|
||||||
|
|
||||||
|
/// Scratch-link anchors loaded from the sidecar.
|
||||||
|
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
||||||
|
|
||||||
|
/// Bookmarks loaded from the sidecar.
|
||||||
|
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
|
||||||
|
|
||||||
|
/// The current in-memory sidecar (for tests / inspection).
|
||||||
|
BadnoteSidecar get sidecar => _sidecar;
|
||||||
|
|
||||||
|
/// The standalone-notebook title loaded from the sidecar, or null.
|
||||||
|
String? get loadedTitle => _sidecar.title;
|
||||||
|
|
||||||
|
/// The page-background template name loaded from the sidecar, or null
|
||||||
|
/// (missing → blank, decoded by the editor).
|
||||||
|
String? get loadedBackground => _sidecar.background;
|
||||||
|
|
||||||
|
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
|
||||||
|
|
||||||
|
/// Replace the standalone-notebook title and schedule a save. No-op if the
|
||||||
|
/// title is unchanged.
|
||||||
|
void scheduleTitleSave(String title) {
|
||||||
|
if (_sidecar.title == title) return;
|
||||||
|
_replace(title: title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the page-background template (a [NoteBackground] enum name) and
|
||||||
|
/// schedule a save. No-op if unchanged.
|
||||||
|
void scheduleBackgroundSave(String background) {
|
||||||
|
if (_sidecar.background == background) return;
|
||||||
|
_replace(background: background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
|
||||||
|
/// search index). No-op if unchanged.
|
||||||
|
void scheduleOcrTextSave(String? ocrText) {
|
||||||
|
final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
|
||||||
|
if (_sidecar.ocrText == next) return;
|
||||||
|
_replace(ocrText: next, clearOcrText: next == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OCR text loaded from the sidecar, or null.
|
||||||
|
String? get loadedOcrText => _sidecar.ocrText;
|
||||||
|
|
||||||
|
/// Replace the document-body search text (PDF embedded text layer, or
|
||||||
|
/// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
|
||||||
|
/// save. No-op if unchanged. An empty string is normalized to null.
|
||||||
|
void schedulePageTextSave(String? pageText) {
|
||||||
|
final next = (pageText != null && pageText.isEmpty) ? null : pageText;
|
||||||
|
if (_sidecar.pageText == next) return;
|
||||||
|
_replace(pageText: next, clearPageText: next == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document-body search text loaded from the sidecar, or null.
|
||||||
|
String? get loadedPageText => _sidecar.pageText;
|
||||||
|
|
||||||
|
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||||
|
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||||
|
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||||
|
if (strokes.isEmpty) {
|
||||||
|
next.remove(pageIndex);
|
||||||
|
} else {
|
||||||
|
next[pageIndex] = List<EditorStroke>.of(strokes);
|
||||||
|
}
|
||||||
|
_replace(strokes: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the standalone-notebook page count and schedule a save. No-op if
|
||||||
|
/// unchanged. [count] is clamped to at least 1.
|
||||||
|
void schedulePageCountSave(int count) {
|
||||||
|
final next = count < 1 ? 1 : count;
|
||||||
|
if (_sidecar.pageCount == next) return;
|
||||||
|
_replace(pageCount: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the highlight rects for [pageIndex] and schedule a save.
|
||||||
|
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
||||||
|
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
|
||||||
|
if (highlights.isEmpty) {
|
||||||
|
next.remove(pageIndex);
|
||||||
|
} else {
|
||||||
|
next[pageIndex] = List<SidecarHighlight>.of(highlights);
|
||||||
|
}
|
||||||
|
_replace(highlights: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
|
||||||
|
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
|
||||||
|
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
|
||||||
|
if (texts.isEmpty) {
|
||||||
|
next.remove(pageIndex);
|
||||||
|
} else {
|
||||||
|
next[pageIndex] = List<SidecarText>.of(texts);
|
||||||
|
}
|
||||||
|
_replace(texts: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
|
||||||
|
/// and schedule a save.
|
||||||
|
void scheduleScratchLinkUpsert(ScratchLink link) {
|
||||||
|
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||||
|
final idx = next.indexWhere((s) => s.link.id == link.id);
|
||||||
|
if (idx == -1) {
|
||||||
|
next.add(SidecarScratchLink(link: link));
|
||||||
|
} else {
|
||||||
|
next[idx] = SidecarScratchLink(
|
||||||
|
link: link,
|
||||||
|
scratchpad: next[idx].scratchpad,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_replace(scratchLinks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
|
||||||
|
void scheduleScratchLinkDelete(String linkId) {
|
||||||
|
final next = _sidecar.scratchLinks
|
||||||
|
.where((s) => s.link.id != linkId)
|
||||||
|
.toList(growable: false);
|
||||||
|
_replace(scratchLinks: List<SidecarScratchLink>.of(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
|
||||||
|
/// No-op if the anchor isn't present.
|
||||||
|
void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
|
||||||
|
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||||
|
final idx = next.indexWhere((s) => s.link.id == linkId);
|
||||||
|
if (idx == -1) return;
|
||||||
|
next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
|
||||||
|
_replace(scratchLinks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or update, by id) a bookmark and schedule a save.
|
||||||
|
void scheduleBookmarkUpsert(Bookmark bookmark) {
|
||||||
|
final next = List<Bookmark>.of(_sidecar.bookmarks);
|
||||||
|
final idx = next.indexWhere((b) => b.id == bookmark.id);
|
||||||
|
if (idx == -1) {
|
||||||
|
next.add(bookmark);
|
||||||
|
} else {
|
||||||
|
next[idx] = bookmark;
|
||||||
|
}
|
||||||
|
_replace(bookmarks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the bookmark by [bookmarkId] and schedule a save.
|
||||||
|
void scheduleBookmarkDelete(String bookmarkId) {
|
||||||
|
final next =
|
||||||
|
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
|
||||||
|
_replace(bookmarks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the whole bookmark list and schedule a save.
|
||||||
|
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
|
||||||
|
_replace(bookmarks: List<Bookmark>.of(bookmarks));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
|
||||||
|
SidecarScratchpad? scratchpadFor(String linkId) {
|
||||||
|
for (final s in _sidecar.scratchLinks) {
|
||||||
|
if (s.link.id == linkId) return s.scratchpad;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flush / dispose ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Write any pending change immediately and wait for it (and any in-flight
|
||||||
|
/// write) to land. If the debounce timer is still armed, fire one final write
|
||||||
|
/// of the latest snapshot; otherwise just drain whatever write is in flight.
|
||||||
|
Future<void> flush() async {
|
||||||
|
if (_timer != null) {
|
||||||
|
_timer!.cancel();
|
||||||
|
_timer = null;
|
||||||
|
await _write();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No pending edit, but a fire-and-forget timer write may still be running:
|
||||||
|
// await the chain so the bytes are on disk before we return.
|
||||||
|
await _writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
if (_retainCount > 1) {
|
||||||
|
_retainCount--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_disposed = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
SidecarRepositoryRegistry._unregister(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build a new sidecar (touching `updatedAt`) from the current one with the
|
||||||
|
/// given fields replaced, then arm the debounce timer. Snapshot is captured
|
||||||
|
/// synchronously here so a later edit can't corrupt an in-flight write.
|
||||||
|
void _replace({
|
||||||
|
String? title,
|
||||||
|
int? pageCount,
|
||||||
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
|
Map<int, List<SidecarText>>? texts,
|
||||||
|
List<Bookmark>? bookmarks,
|
||||||
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
|
String? ocrText,
|
||||||
|
bool clearOcrText = false,
|
||||||
|
String? pageText,
|
||||||
|
bool clearPageText = false,
|
||||||
|
String? background,
|
||||||
|
}) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_sidecar = BadnoteSidecar(
|
||||||
|
version: _sidecar.version,
|
||||||
|
sourceFile: _sidecar.sourceFile,
|
||||||
|
docType: _sidecar.docType,
|
||||||
|
title: title ?? _sidecar.title,
|
||||||
|
pageCount: pageCount ?? _sidecar.pageCount,
|
||||||
|
rotation: _sidecar.rotation,
|
||||||
|
createdAt: _sidecar.createdAt,
|
||||||
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
strokes: strokes ?? _sidecar.strokes,
|
||||||
|
highlights: highlights ?? _sidecar.highlights,
|
||||||
|
texts: texts ?? _sidecar.texts,
|
||||||
|
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
||||||
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||||
|
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||||
|
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
|
||||||
|
background: background ?? _sidecar.background,
|
||||||
|
);
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(_debounce, () {
|
||||||
|
_timer = null;
|
||||||
|
// Fire-and-forget; the next schedule simply re-arms the timer and the
|
||||||
|
// atomic write guarantees no torn file.
|
||||||
|
_write();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize writes through [_writeChain] so overlapping flushes never race
|
||||||
|
/// on the temp file. Each link writes the latest in-memory snapshot at the
|
||||||
|
/// moment it runs; an error in one write doesn't break the chain for the next.
|
||||||
|
Future<void> _write() {
|
||||||
|
final next = _writeChain.then((_) async {
|
||||||
|
final snapshot = _sidecar;
|
||||||
|
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
||||||
|
});
|
||||||
|
// Keep the chain alive past a failed write (e.g. transient FS error).
|
||||||
|
_writeChain = next.catchError((_) {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _basename(String path) {
|
||||||
|
final norm = path.replaceAll('\\', '/');
|
||||||
|
final i = norm.lastIndexOf('/');
|
||||||
|
return i == -1 ? norm : norm.substring(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,13 +39,7 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
isComplete: false, thinning: thinning);
|
isComplete: false, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) return;
|
if (path.getBounds().isEmpty) return;
|
||||||
|
|
||||||
canvas.drawPath(
|
canvas.drawPath(path, paintForEditorStroke(stroke));
|
||||||
path,
|
|
||||||
Paint()
|
|
||||||
..color = Color(stroke.color)
|
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -58,13 +58,10 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
final path = buildStrokeOutline(stroke, pageSize,
|
final path = buildStrokeOutline(stroke, pageSize,
|
||||||
isComplete: true, thinning: thinning);
|
isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
rec.drawPath(
|
// One drawPath per stroke ⇒ highlighter self-overlap never darkens;
|
||||||
path,
|
// cross-stroke overlap darkens via BlendMode.multiply (closes
|
||||||
Paint()
|
// TODO(brush-opacity); shared resolver with the live + PenCanvas paths).
|
||||||
..color = Color(stroke.color)
|
rec.drawPath(path, paintForEditorStroke(stroke));
|
||||||
..style = PaintingStyle.fill
|
|
||||||
..isAntiAlias = true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return recorder.endRecording();
|
return recorder.endRecording();
|
||||||
});
|
});
|
||||||
|
|||||||
10
lib/editor/stroke.dart
Normal file
10
lib/editor/stroke.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Canonical stroke model surface.
|
||||||
|
//
|
||||||
|
// Historical baggage had three parallel types (PenStroke / EditorStroke /
|
||||||
|
// InkStroke). New code MUST import from this barrel and prefer [EditorStroke]
|
||||||
|
// for engine/storage. UI adapters convert at the edge.
|
||||||
|
//
|
||||||
|
// Do not add a fourth model.
|
||||||
|
|
||||||
|
export '../engine/stroke_model.dart' show EditorStroke, EditorPoint, EditorTool;
|
||||||
|
export '../canvas/pen_stroke.dart' show PenStroke, PenPoint, PenStrokeKind;
|
||||||
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// lib/editor/ui/page_nav_shortcuts.dart
|
||||||
|
//
|
||||||
|
// Shared keyboard page navigation for PDF / slide / office editors.
|
||||||
|
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class PreviousPageIntent extends Intent {
|
||||||
|
const PreviousPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NextPageIntent extends Intent {
|
||||||
|
const NextPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FirstPageIntent extends Intent {
|
||||||
|
const FirstPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LastPageIntent extends Intent {
|
||||||
|
const LastPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
|
||||||
|
Widget pageNavShortcuts({
|
||||||
|
required Widget child,
|
||||||
|
required VoidCallback? onPrevious,
|
||||||
|
required VoidCallback? onNext,
|
||||||
|
VoidCallback? onFirst,
|
||||||
|
VoidCallback? onLast,
|
||||||
|
}) {
|
||||||
|
return Focus(
|
||||||
|
autofocus: true,
|
||||||
|
child: CallbackShortcuts(
|
||||||
|
bindings: <ShortcutActivator, VoidCallback>{
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
if (onFirst != null)
|
||||||
|
const SingleActivator(LogicalKeyboardKey.home): onFirst,
|
||||||
|
if (onLast != null)
|
||||||
|
const SingleActivator(LogicalKeyboardKey.end): onLast,
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
|
import '../input/pressure_curve.dart';
|
||||||
|
|
||||||
|
String _shapeNameForGamma(double gamma) {
|
||||||
|
if ((gamma - 0.6).abs() < 0.05) return 'soft';
|
||||||
|
if ((gamma - 1.0).abs() < 0.05) return 'linear';
|
||||||
|
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
|
||||||
|
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
|
||||||
|
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
|
||||||
|
return 'soft';
|
||||||
|
}
|
||||||
|
|
||||||
|
PressureCurve _curveForName(String name) => switch (name) {
|
||||||
|
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
|
||||||
|
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
|
||||||
|
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
|
||||||
|
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
|
||||||
|
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
|
||||||
|
_ => PressureCurve.shaped(PressureCurveShape.soft),
|
||||||
|
};
|
||||||
|
|
||||||
/// Shows a Material You modal bottom sheet for configuring pen input.
|
/// Shows a Material You modal bottom sheet for configuring pen input.
|
||||||
///
|
///
|
||||||
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
|
|||||||
formatValue: (v) => v.toStringAsFixed(2),
|
formatValue: (v) => v.toStringAsFixed(2),
|
||||||
onChanged: controller.setPressureGamma,
|
onChanged: controller.setPressureGamma,
|
||||||
),
|
),
|
||||||
|
_LabeledRow(
|
||||||
|
label: 'Curve Preset (rnote)',
|
||||||
|
child: DropdownMenu<String>(
|
||||||
|
initialSelection: _shapeNameForGamma(config.pressureGamma),
|
||||||
|
onSelected: (name) {
|
||||||
|
if (name == null) return;
|
||||||
|
final shaped = _curveForName(name);
|
||||||
|
controller.setPressureGamma(shaped.gamma);
|
||||||
|
},
|
||||||
|
dropdownMenuEntries: const [
|
||||||
|
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
|
||||||
|
DropdownMenuEntry(value: 'linear', label: 'Linear'),
|
||||||
|
DropdownMenuEntry(
|
||||||
|
value: 'quadratic', label: 'Quadratic / Pow2'),
|
||||||
|
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
|
||||||
|
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 8, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'笔刷自带曲线优先(钢笔=二次/Pow2,铅笔=平方根)。全局 gamma 作后备。',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────
|
// ── Input ─────────────────────────────────────────────────
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
@@ -161,6 +206,31 @@ class _PenSettingsSheet extends StatelessWidget {
|
|||||||
formatValue: (v) => v.toStringAsFixed(4),
|
formatValue: (v) => v.toStringAsFixed(4),
|
||||||
onChanged: controller.setHighlighterWidth,
|
onChanged: controller.setHighlighterWidth,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Eraser ────────────────────────────────────────────────
|
||||||
|
_SectionHeader(
|
||||||
|
title: 'Eraser',
|
||||||
|
icon: Icons.cleaning_services_outlined,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
_SliderTile(
|
||||||
|
label: 'Eraser Size',
|
||||||
|
value: config.eraserRadius,
|
||||||
|
min: 0.005,
|
||||||
|
max: 0.1,
|
||||||
|
divisions: 19,
|
||||||
|
formatValue: (v) => v.toStringAsFixed(3),
|
||||||
|
onChanged: controller.setEraserRadius,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Stroke Eraser'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Erase a whole stroke on contact (off: erase by segment)',
|
||||||
|
),
|
||||||
|
value: config.eraserWholeStroke,
|
||||||
|
onChanged: controller.setEraserWholeStroke,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -240,11 +310,13 @@ class _ActionDropdown extends StatelessWidget {
|
|||||||
final ValueChanged<PenButtonAction> onChanged;
|
final ValueChanged<PenButtonAction> onChanged;
|
||||||
|
|
||||||
static String _label(PenButtonAction action) => switch (action) {
|
static String _label(PenButtonAction action) => switch (action) {
|
||||||
PenButtonAction.none => 'None',
|
PenButtonAction.none => '无',
|
||||||
PenButtonAction.eraser => 'Eraser',
|
PenButtonAction.eraser => '橡皮',
|
||||||
PenButtonAction.undo => 'Undo',
|
PenButtonAction.undo => '撤销',
|
||||||
PenButtonAction.toggleTool => 'Toggle Tool',
|
PenButtonAction.toggleTool => '切换工具',
|
||||||
PenButtonAction.pan => 'Pan',
|
PenButtonAction.pan => '平移',
|
||||||
|
PenButtonAction.select => '选择(笔迹)',
|
||||||
|
PenButtonAction.selectText => '选择文本',
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -5,6 +5,24 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"importPdf": "Import PDF",
|
"importPdf": "Import PDF",
|
||||||
"importPpt": "Import PPT",
|
"importPpt": "Import PPT",
|
||||||
|
"importFile": "Import file",
|
||||||
|
"createNotebook": "Create notebook",
|
||||||
|
"newNotebookTitle": "New notebook",
|
||||||
|
"notebookTitleHint": "Notebook title",
|
||||||
|
"create": "Create",
|
||||||
|
"untitledNote": "Untitled",
|
||||||
|
"noNotesYetHint": "No ink notes yet — tap + to create one",
|
||||||
|
"noDocumentsYet": "No documents yet — tap Import file",
|
||||||
|
"processingImport": "Importing…",
|
||||||
|
"importFailed": "Couldn't import that file: {error}",
|
||||||
|
"@importFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"convertNeedsLibreOffice": "Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.",
|
||||||
|
"unsupportedFileType": "Unsupported file type: {ext}",
|
||||||
|
"@unsupportedFileType": {
|
||||||
|
"placeholders": { "ext": { "type": "String" } }
|
||||||
|
},
|
||||||
"penCanvasBeta": "Pen Canvas (beta)",
|
"penCanvasBeta": "Pen Canvas (beta)",
|
||||||
"newNote": "New Note",
|
"newNote": "New Note",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
@@ -15,12 +33,50 @@
|
|||||||
"openInSplitView": "Open in Split View",
|
"openInSplitView": "Open in Split View",
|
||||||
"splitViewSubtitle": "PDF reference + scratchpad",
|
"splitViewSubtitle": "PDF reference + scratchpad",
|
||||||
"removeDocument": "Remove document",
|
"removeDocument": "Remove document",
|
||||||
|
"ok": "OK",
|
||||||
|
"pickColor": "Pick a color",
|
||||||
|
"clearSettingsTitle": "Clear all local settings?",
|
||||||
|
"clear": "Clear",
|
||||||
|
"settingsReset": "Settings reset to defaults",
|
||||||
|
"themeSystem": "System",
|
||||||
|
"themeLight": "Light",
|
||||||
|
"themeDark": "Dark",
|
||||||
|
"seedColorDesc": "Seed color for Material 3 theme",
|
||||||
|
"searchHint": "Search notes and documents...",
|
||||||
|
"searchError": "Search error: {error}",
|
||||||
|
"@searchError": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"noResultsFor": "No results for \"{query}\"",
|
||||||
|
"@noResultsFor": {
|
||||||
|
"placeholders": { "query": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"typeToSearch": "Type to search your notes and documents",
|
||||||
|
"sectionNotes": "Notes",
|
||||||
|
"sectionDocuments": "Documents",
|
||||||
|
"pageLabel": "Page {page}",
|
||||||
|
"@pageLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
"processingPptx": "Processing PPTX...",
|
"processingPptx": "Processing PPTX...",
|
||||||
"processingPresentation": "Processing presentation...",
|
"processingPresentation": "Processing presentation...",
|
||||||
"couldNotOpenPresentation": "Could not open presentation.",
|
"couldNotOpenPresentation": "Could not open presentation.",
|
||||||
"toolPen": "Pen",
|
"toolPen": "Pen",
|
||||||
"toolHighlighter": "Highlighter",
|
"toolHighlighter": "Highlighter",
|
||||||
"toolEraser": "Eraser",
|
"toolEraser": "Eraser",
|
||||||
|
"brushPicker": "Brush",
|
||||||
|
"brushFountainPen": "Fountain pen",
|
||||||
|
"brushBallpoint": "Ballpoint",
|
||||||
|
"brushPencil": "Pencil",
|
||||||
|
"brushHighlighter": "Highlighter",
|
||||||
|
"toolSelect": "Select",
|
||||||
|
"toolShape": "Shape",
|
||||||
|
"shapePicker": "Shape",
|
||||||
|
"shapeLine": "Line",
|
||||||
|
"shapeRectangle": "Rectangle",
|
||||||
|
"shapeEllipse": "Ellipse",
|
||||||
|
"shapeArrow": "Arrow",
|
||||||
|
"actionDeleteSelection": "Delete selection",
|
||||||
"actionUndo": "Undo",
|
"actionUndo": "Undo",
|
||||||
"actionRedo": "Redo",
|
"actionRedo": "Redo",
|
||||||
"fingerDrawingOn": "Finger drawing ON",
|
"fingerDrawingOn": "Finger drawing ON",
|
||||||
@@ -31,6 +87,28 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"previousPage": "Previous page",
|
"previousPage": "Previous page",
|
||||||
"nextPage": "Next page",
|
"nextPage": "Next page",
|
||||||
|
"toolSelectText": "Select text",
|
||||||
|
"actionHighlightSelection": "Highlight selection",
|
||||||
|
"toolRemoveHighlight": "Remove highlight (tap a highlight)",
|
||||||
|
"toolPlaceScratchLink": "Place scratch link",
|
||||||
|
"toolText": "Text (tap or double-click to add)",
|
||||||
|
"textPlaceholder": "Type…",
|
||||||
|
"scratchLinkDeleteTitle": "Delete scratch link?",
|
||||||
|
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
|
||||||
|
"toolAddBookmark": "Add bookmark (here or at selection)",
|
||||||
|
"toolBookmarks": "Bookmarks",
|
||||||
|
"bookmarksTitle": "Bookmarks",
|
||||||
|
"bookmarksEmpty": "No bookmarks yet.",
|
||||||
|
"bookmarkDefaultLabel": "Page {page}",
|
||||||
|
"@bookmarkDefaultLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkPageLabel": "Page {page}",
|
||||||
|
"@bookmarkPageLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkDeleteTitle": "Delete bookmark?",
|
||||||
|
"bookmarkDeleteBody": "This removes the saved location.",
|
||||||
"failedToOpenPdf": "Failed to open PDF:\n{error}",
|
"failedToOpenPdf": "Failed to open PDF:\n{error}",
|
||||||
"@failedToOpenPdf": {
|
"@failedToOpenPdf": {
|
||||||
"placeholders": { "error": { "type": "String" } }
|
"placeholders": { "error": { "type": "String" } }
|
||||||
@@ -42,5 +120,135 @@
|
|||||||
"current": { "type": "int" },
|
"current": { "type": "int" },
|
||||||
"total": { "type": "int" }
|
"total": { "type": "int" }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"libraryTab": "Library",
|
||||||
|
"boardTab": "Stickies",
|
||||||
|
"shellTagline": "Ink · Annotate · Know",
|
||||||
|
"notesSection": "Notes",
|
||||||
|
"documentsSection": "Documents",
|
||||||
|
"emptyLibraryTitle": "Nothing here yet",
|
||||||
|
"emptyLibraryBody": "Create a note, or import PDF / PPT / Word",
|
||||||
|
"diagnosticsSection": "Diagnostics",
|
||||||
|
"diagnosticsExport": "Export diagnostic pack",
|
||||||
|
"diagnosticsExportHint": "Reproduce on Surface, export, and send the zip back",
|
||||||
|
"diagnosticsToggle": "Input diagnostics overlay",
|
||||||
|
"penSettingsUnified": "Pen & ink",
|
||||||
|
"board": "Board",
|
||||||
|
"boardTitle": "Sticky Board",
|
||||||
|
"boardOpen": "Sticky note board",
|
||||||
|
"boardAddCard": "Add card",
|
||||||
|
"boardNewCardText": "New note",
|
||||||
|
"boardDeleteCard": "Delete card",
|
||||||
|
"boardDeleteCardTitle": "Delete this card?",
|
||||||
|
"boardBacklinks": "Linked from",
|
||||||
|
"boardNoBacklinks": "Nothing links here yet",
|
||||||
|
"boardDanglingLink": "No card named \"{target}\"",
|
||||||
|
"@boardDanglingLink": {
|
||||||
|
"placeholders": { "target": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"close": "Close",
|
||||||
|
"vaultSetupTitle": "Choose your vault",
|
||||||
|
"vaultSetupHeadline": "Pick a folder for your notebooks",
|
||||||
|
"vaultSetupBody": "BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.",
|
||||||
|
"vaultChooseFolder": "Choose folder",
|
||||||
|
"vaultMissingTitle": "Your vault folder is missing",
|
||||||
|
"vaultMissingBody": "The folder you picked can't be found (it may have been moved, deleted, or on a drive that's unplugged). Relocate it or pick a new one.",
|
||||||
|
"vaultPickFailed": "Couldn't open the folder picker: {error}",
|
||||||
|
"@vaultPickFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"vaultNotWritable": "That folder isn't writable. Please choose another.",
|
||||||
|
"vaultSection": "Vault",
|
||||||
|
"vaultFolderLabel": "Vault folder",
|
||||||
|
"vaultNoneSelected": "No folder selected",
|
||||||
|
"vaultChangeFolder": "Change vault folder",
|
||||||
|
"vaultUpdated": "Vault folder updated",
|
||||||
|
"syncSection": "Sync (WebDAV)",
|
||||||
|
"syncServerUrl": "Server URL",
|
||||||
|
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
|
||||||
|
"syncUsername": "Username",
|
||||||
|
"syncPassword": "Password",
|
||||||
|
"syncRemoteFolder": "Remote folder",
|
||||||
|
"syncRemoteFolderHint": "BadNote",
|
||||||
|
"syncSave": "Save",
|
||||||
|
"syncSaved": "Sync settings saved",
|
||||||
|
"syncTestConnection": "Test connection",
|
||||||
|
"syncTestOk": "Connection OK",
|
||||||
|
"syncTestFailed": "Connection failed: {error}",
|
||||||
|
"@syncTestFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncNow": "Sync now",
|
||||||
|
"syncRunning": "Syncing…",
|
||||||
|
"syncNeverRun": "Never synced",
|
||||||
|
"syncLastRun": "Last synced: {when}",
|
||||||
|
"@syncLastRun": {
|
||||||
|
"placeholders": { "when": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncResultSummary": "{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts",
|
||||||
|
"@syncResultSummary": {
|
||||||
|
"placeholders": {
|
||||||
|
"uploaded": { "type": "int" },
|
||||||
|
"downloaded": { "type": "int" },
|
||||||
|
"conflicts": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncFailed": "Sync failed: {error}",
|
||||||
|
"@syncFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncAuto": "Sync automatically on launch",
|
||||||
|
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
|
||||||
|
"syncNotConfigured": "Enter a server URL to enable sync.",
|
||||||
|
"settingsDefaults": "Defaults",
|
||||||
|
"settingsAppearance": "Appearance",
|
||||||
|
"settingsAbout": "About",
|
||||||
|
"settingsDefaultTool": "Default tool",
|
||||||
|
"settingsDefaultColor": "Default color",
|
||||||
|
"settingsDefaultWidth": "Default stroke width",
|
||||||
|
"settingsPressureCurve": "Pressure curve",
|
||||||
|
"settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.",
|
||||||
|
"serverSection": "BadNote Server",
|
||||||
|
"serverUrl": "Server URL",
|
||||||
|
"serverUrlHint": "http://192.168.1.10:8080",
|
||||||
|
"serverUsername": "Username",
|
||||||
|
"serverPassword": "Password",
|
||||||
|
"serverSave": "Save & sign in",
|
||||||
|
"serverTest": "Test connection",
|
||||||
|
"serverTestOk": "Connected · API {version}",
|
||||||
|
"@serverTestOk": {
|
||||||
|
"placeholders": { "version": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverTestFail": "Connection failed: {error}",
|
||||||
|
"@serverTestFail": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverLoggedIn": "Signed in",
|
||||||
|
"serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.",
|
||||||
|
"boardEmptyTitle": "No sticky notes yet",
|
||||||
|
"boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.",
|
||||||
|
"relativeJustNow": "Just now",
|
||||||
|
"relativeMinutesAgo": "{n}m ago",
|
||||||
|
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeHoursAgo": "{n}h ago",
|
||||||
|
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeYesterday": "Yesterday",
|
||||||
|
"diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied",
|
||||||
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
|
"diagExportFail": "Export failed: {error}",
|
||||||
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"processingOcr": "Processing OCR…",
|
||||||
|
"notebooksSection": "Notebooks",
|
||||||
|
"addBlankPage": "Blank page",
|
||||||
|
"importIntoNotebook": "Import into notebook",
|
||||||
|
"notebookMembersEmpty": "No pages yet",
|
||||||
|
"memberCount": "{count} items",
|
||||||
|
"@memberCount": {
|
||||||
|
"placeholders": { "count": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"textFontSmall": "S",
|
||||||
|
"textFontMedium": "M",
|
||||||
|
"textFontLarge": "L",
|
||||||
|
"textBold": "Bold",
|
||||||
|
"textDragHint": "Drag to move"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,78 @@ abstract class AppLocalizations {
|
|||||||
/// **'Import PPT'**
|
/// **'Import PPT'**
|
||||||
String get importPpt;
|
String get importPpt;
|
||||||
|
|
||||||
|
/// No description provided for @importFile.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Import file'**
|
||||||
|
String get importFile;
|
||||||
|
|
||||||
|
/// No description provided for @createNotebook.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create notebook'**
|
||||||
|
String get createNotebook;
|
||||||
|
|
||||||
|
/// No description provided for @newNotebookTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New notebook'**
|
||||||
|
String get newNotebookTitle;
|
||||||
|
|
||||||
|
/// No description provided for @notebookTitleHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notebook title'**
|
||||||
|
String get notebookTitleHint;
|
||||||
|
|
||||||
|
/// No description provided for @create.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create'**
|
||||||
|
String get create;
|
||||||
|
|
||||||
|
/// No description provided for @untitledNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Untitled'**
|
||||||
|
String get untitledNote;
|
||||||
|
|
||||||
|
/// No description provided for @noNotesYetHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No ink notes yet — tap + to create one'**
|
||||||
|
String get noNotesYetHint;
|
||||||
|
|
||||||
|
/// No description provided for @noDocumentsYet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No documents yet — tap Import file'**
|
||||||
|
String get noDocumentsYet;
|
||||||
|
|
||||||
|
/// No description provided for @processingImport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Importing…'**
|
||||||
|
String get processingImport;
|
||||||
|
|
||||||
|
/// No description provided for @importFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t import that file: {error}'**
|
||||||
|
String importFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @convertNeedsLibreOffice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.'**
|
||||||
|
String get convertNeedsLibreOffice;
|
||||||
|
|
||||||
|
/// No description provided for @unsupportedFileType.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unsupported file type: {ext}'**
|
||||||
|
String unsupportedFileType(String ext);
|
||||||
|
|
||||||
/// No description provided for @penCanvasBeta.
|
/// No description provided for @penCanvasBeta.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -188,6 +260,102 @@ abstract class AppLocalizations {
|
|||||||
/// **'Remove document'**
|
/// **'Remove document'**
|
||||||
String get removeDocument;
|
String get removeDocument;
|
||||||
|
|
||||||
|
/// No description provided for @ok.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'OK'**
|
||||||
|
String get ok;
|
||||||
|
|
||||||
|
/// No description provided for @pickColor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick a color'**
|
||||||
|
String get pickColor;
|
||||||
|
|
||||||
|
/// No description provided for @clearSettingsTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear all local settings?'**
|
||||||
|
String get clearSettingsTitle;
|
||||||
|
|
||||||
|
/// No description provided for @clear.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear'**
|
||||||
|
String get clear;
|
||||||
|
|
||||||
|
/// No description provided for @settingsReset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Settings reset to defaults'**
|
||||||
|
String get settingsReset;
|
||||||
|
|
||||||
|
/// No description provided for @themeSystem.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'System'**
|
||||||
|
String get themeSystem;
|
||||||
|
|
||||||
|
/// No description provided for @themeLight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Light'**
|
||||||
|
String get themeLight;
|
||||||
|
|
||||||
|
/// No description provided for @themeDark.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dark'**
|
||||||
|
String get themeDark;
|
||||||
|
|
||||||
|
/// No description provided for @seedColorDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Seed color for Material 3 theme'**
|
||||||
|
String get seedColorDesc;
|
||||||
|
|
||||||
|
/// No description provided for @searchHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search notes and documents...'**
|
||||||
|
String get searchHint;
|
||||||
|
|
||||||
|
/// No description provided for @searchError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search error: {error}'**
|
||||||
|
String searchError(String error);
|
||||||
|
|
||||||
|
/// No description provided for @noResultsFor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No results for \"{query}\"'**
|
||||||
|
String noResultsFor(String query);
|
||||||
|
|
||||||
|
/// No description provided for @typeToSearch.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Type to search your notes and documents'**
|
||||||
|
String get typeToSearch;
|
||||||
|
|
||||||
|
/// No description provided for @sectionNotes.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notes'**
|
||||||
|
String get sectionNotes;
|
||||||
|
|
||||||
|
/// No description provided for @sectionDocuments.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Documents'**
|
||||||
|
String get sectionDocuments;
|
||||||
|
|
||||||
|
/// No description provided for @pageLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page}'**
|
||||||
|
String pageLabel(int page);
|
||||||
|
|
||||||
/// No description provided for @processingPptx.
|
/// No description provided for @processingPptx.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -224,6 +392,84 @@ abstract class AppLocalizations {
|
|||||||
/// **'Eraser'**
|
/// **'Eraser'**
|
||||||
String get toolEraser;
|
String get toolEraser;
|
||||||
|
|
||||||
|
/// No description provided for @brushPicker.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Brush'**
|
||||||
|
String get brushPicker;
|
||||||
|
|
||||||
|
/// No description provided for @brushFountainPen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fountain pen'**
|
||||||
|
String get brushFountainPen;
|
||||||
|
|
||||||
|
/// No description provided for @brushBallpoint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Ballpoint'**
|
||||||
|
String get brushBallpoint;
|
||||||
|
|
||||||
|
/// No description provided for @brushPencil.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pencil'**
|
||||||
|
String get brushPencil;
|
||||||
|
|
||||||
|
/// No description provided for @brushHighlighter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Highlighter'**
|
||||||
|
String get brushHighlighter;
|
||||||
|
|
||||||
|
/// No description provided for @toolSelect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select'**
|
||||||
|
String get toolSelect;
|
||||||
|
|
||||||
|
/// No description provided for @toolShape.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shape'**
|
||||||
|
String get toolShape;
|
||||||
|
|
||||||
|
/// No description provided for @shapePicker.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shape'**
|
||||||
|
String get shapePicker;
|
||||||
|
|
||||||
|
/// No description provided for @shapeLine.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Line'**
|
||||||
|
String get shapeLine;
|
||||||
|
|
||||||
|
/// No description provided for @shapeRectangle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Rectangle'**
|
||||||
|
String get shapeRectangle;
|
||||||
|
|
||||||
|
/// No description provided for @shapeEllipse.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Ellipse'**
|
||||||
|
String get shapeEllipse;
|
||||||
|
|
||||||
|
/// No description provided for @shapeArrow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Arrow'**
|
||||||
|
String get shapeArrow;
|
||||||
|
|
||||||
|
/// No description provided for @actionDeleteSelection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete selection'**
|
||||||
|
String get actionDeleteSelection;
|
||||||
|
|
||||||
/// No description provided for @actionUndo.
|
/// No description provided for @actionUndo.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -284,6 +530,102 @@ abstract class AppLocalizations {
|
|||||||
/// **'Next page'**
|
/// **'Next page'**
|
||||||
String get nextPage;
|
String get nextPage;
|
||||||
|
|
||||||
|
/// No description provided for @toolSelectText.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select text'**
|
||||||
|
String get toolSelectText;
|
||||||
|
|
||||||
|
/// No description provided for @actionHighlightSelection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Highlight selection'**
|
||||||
|
String get actionHighlightSelection;
|
||||||
|
|
||||||
|
/// No description provided for @toolRemoveHighlight.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove highlight (tap a highlight)'**
|
||||||
|
String get toolRemoveHighlight;
|
||||||
|
|
||||||
|
/// No description provided for @toolPlaceScratchLink.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Place scratch link'**
|
||||||
|
String get toolPlaceScratchLink;
|
||||||
|
|
||||||
|
/// No description provided for @toolText.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Text (tap or double-click to add)'**
|
||||||
|
String get toolText;
|
||||||
|
|
||||||
|
/// No description provided for @textPlaceholder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Type…'**
|
||||||
|
String get textPlaceholder;
|
||||||
|
|
||||||
|
/// No description provided for @scratchLinkDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete scratch link?'**
|
||||||
|
String get scratchLinkDeleteTitle;
|
||||||
|
|
||||||
|
/// No description provided for @scratchLinkDeleteBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This removes the anchor and its private scratchpad.'**
|
||||||
|
String get scratchLinkDeleteBody;
|
||||||
|
|
||||||
|
/// No description provided for @toolAddBookmark.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add bookmark (here or at selection)'**
|
||||||
|
String get toolAddBookmark;
|
||||||
|
|
||||||
|
/// No description provided for @toolBookmarks.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bookmarks'**
|
||||||
|
String get toolBookmarks;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarksTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bookmarks'**
|
||||||
|
String get bookmarksTitle;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarksEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No bookmarks yet.'**
|
||||||
|
String get bookmarksEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDefaultLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page}'**
|
||||||
|
String bookmarkDefaultLabel(int page);
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkPageLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page}'**
|
||||||
|
String bookmarkPageLabel(int page);
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete bookmark?'**
|
||||||
|
String get bookmarkDeleteTitle;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDeleteBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This removes the saved location.'**
|
||||||
|
String get bookmarkDeleteBody;
|
||||||
|
|
||||||
/// No description provided for @failedToOpenPdf.
|
/// No description provided for @failedToOpenPdf.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -301,6 +643,576 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{current} / {total}'**
|
/// **'{current} / {total}'**
|
||||||
String pageOfPages(int current, int total);
|
String pageOfPages(int current, int total);
|
||||||
|
|
||||||
|
/// No description provided for @libraryTab.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Library'**
|
||||||
|
String get libraryTab;
|
||||||
|
|
||||||
|
/// No description provided for @boardTab.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stickies'**
|
||||||
|
String get boardTab;
|
||||||
|
|
||||||
|
/// No description provided for @shellTagline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Ink · Annotate · Know'**
|
||||||
|
String get shellTagline;
|
||||||
|
|
||||||
|
/// No description provided for @notesSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notes'**
|
||||||
|
String get notesSection;
|
||||||
|
|
||||||
|
/// No description provided for @documentsSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Documents'**
|
||||||
|
String get documentsSection;
|
||||||
|
|
||||||
|
/// No description provided for @emptyLibraryTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nothing here yet'**
|
||||||
|
String get emptyLibraryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @emptyLibraryBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create a note, or import PDF / PPT / Word'**
|
||||||
|
String get emptyLibraryBody;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Diagnostics'**
|
||||||
|
String get diagnosticsSection;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsExport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export diagnostic pack'**
|
||||||
|
String get diagnosticsExport;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsExportHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reproduce on Surface, export, and send the zip back'**
|
||||||
|
String get diagnosticsExportHint;
|
||||||
|
|
||||||
|
/// No description provided for @diagnosticsToggle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Input diagnostics overlay'**
|
||||||
|
String get diagnosticsToggle;
|
||||||
|
|
||||||
|
/// No description provided for @penSettingsUnified.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pen & ink'**
|
||||||
|
String get penSettingsUnified;
|
||||||
|
|
||||||
|
/// No description provided for @board.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Board'**
|
||||||
|
String get board;
|
||||||
|
|
||||||
|
/// No description provided for @boardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sticky Board'**
|
||||||
|
String get boardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @boardOpen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sticky note board'**
|
||||||
|
String get boardOpen;
|
||||||
|
|
||||||
|
/// No description provided for @boardAddCard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add card'**
|
||||||
|
String get boardAddCard;
|
||||||
|
|
||||||
|
/// No description provided for @boardNewCardText.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New note'**
|
||||||
|
String get boardNewCardText;
|
||||||
|
|
||||||
|
/// No description provided for @boardDeleteCard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete card'**
|
||||||
|
String get boardDeleteCard;
|
||||||
|
|
||||||
|
/// No description provided for @boardDeleteCardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete this card?'**
|
||||||
|
String get boardDeleteCardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @boardBacklinks.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Linked from'**
|
||||||
|
String get boardBacklinks;
|
||||||
|
|
||||||
|
/// No description provided for @boardNoBacklinks.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nothing links here yet'**
|
||||||
|
String get boardNoBacklinks;
|
||||||
|
|
||||||
|
/// No description provided for @boardDanglingLink.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No card named \"{target}\"'**
|
||||||
|
String boardDanglingLink(String target);
|
||||||
|
|
||||||
|
/// No description provided for @close.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Close'**
|
||||||
|
String get close;
|
||||||
|
|
||||||
|
/// No description provided for @vaultSetupTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose your vault'**
|
||||||
|
String get vaultSetupTitle;
|
||||||
|
|
||||||
|
/// No description provided for @vaultSetupHeadline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pick a folder for your notebooks'**
|
||||||
|
String get vaultSetupHeadline;
|
||||||
|
|
||||||
|
/// No description provided for @vaultSetupBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.'**
|
||||||
|
String get vaultSetupBody;
|
||||||
|
|
||||||
|
/// No description provided for @vaultChooseFolder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose folder'**
|
||||||
|
String get vaultChooseFolder;
|
||||||
|
|
||||||
|
/// No description provided for @vaultMissingTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your vault folder is missing'**
|
||||||
|
String get vaultMissingTitle;
|
||||||
|
|
||||||
|
/// No description provided for @vaultMissingBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.'**
|
||||||
|
String get vaultMissingBody;
|
||||||
|
|
||||||
|
/// No description provided for @vaultPickFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t open the folder picker: {error}'**
|
||||||
|
String vaultPickFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @vaultNotWritable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'That folder isn\'t writable. Please choose another.'**
|
||||||
|
String get vaultNotWritable;
|
||||||
|
|
||||||
|
/// No description provided for @vaultSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Vault'**
|
||||||
|
String get vaultSection;
|
||||||
|
|
||||||
|
/// No description provided for @vaultFolderLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Vault folder'**
|
||||||
|
String get vaultFolderLabel;
|
||||||
|
|
||||||
|
/// No description provided for @vaultNoneSelected.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No folder selected'**
|
||||||
|
String get vaultNoneSelected;
|
||||||
|
|
||||||
|
/// No description provided for @vaultChangeFolder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Change vault folder'**
|
||||||
|
String get vaultChangeFolder;
|
||||||
|
|
||||||
|
/// No description provided for @vaultUpdated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Vault folder updated'**
|
||||||
|
String get vaultUpdated;
|
||||||
|
|
||||||
|
/// No description provided for @syncSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync (WebDAV)'**
|
||||||
|
String get syncSection;
|
||||||
|
|
||||||
|
/// No description provided for @syncServerUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Server URL'**
|
||||||
|
String get syncServerUrl;
|
||||||
|
|
||||||
|
/// No description provided for @syncServerUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'https://dav.example.com/remote.php/dav/files/me'**
|
||||||
|
String get syncServerUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @syncUsername.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Username'**
|
||||||
|
String get syncUsername;
|
||||||
|
|
||||||
|
/// No description provided for @syncPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Password'**
|
||||||
|
String get syncPassword;
|
||||||
|
|
||||||
|
/// No description provided for @syncRemoteFolder.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remote folder'**
|
||||||
|
String get syncRemoteFolder;
|
||||||
|
|
||||||
|
/// No description provided for @syncRemoteFolderHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BadNote'**
|
||||||
|
String get syncRemoteFolderHint;
|
||||||
|
|
||||||
|
/// No description provided for @syncSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get syncSave;
|
||||||
|
|
||||||
|
/// No description provided for @syncSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync settings saved'**
|
||||||
|
String get syncSaved;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestConnection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test connection'**
|
||||||
|
String get syncTestConnection;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestOk.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection OK'**
|
||||||
|
String get syncTestOk;
|
||||||
|
|
||||||
|
/// No description provided for @syncTestFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection failed: {error}'**
|
||||||
|
String syncTestFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @syncNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync now'**
|
||||||
|
String get syncNow;
|
||||||
|
|
||||||
|
/// No description provided for @syncRunning.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Syncing…'**
|
||||||
|
String get syncRunning;
|
||||||
|
|
||||||
|
/// No description provided for @syncNeverRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Never synced'**
|
||||||
|
String get syncNeverRun;
|
||||||
|
|
||||||
|
/// No description provided for @syncLastRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Last synced: {when}'**
|
||||||
|
String syncLastRun(String when);
|
||||||
|
|
||||||
|
/// No description provided for @syncResultSummary.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts'**
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts);
|
||||||
|
|
||||||
|
/// No description provided for @syncFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync failed: {error}'**
|
||||||
|
String syncFailed(String error);
|
||||||
|
|
||||||
|
/// No description provided for @syncAuto.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync automatically on launch'**
|
||||||
|
String get syncAuto;
|
||||||
|
|
||||||
|
/// No description provided for @syncCredentialsNote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Credentials are stored locally in plain text. Use a dedicated app password.'**
|
||||||
|
String get syncCredentialsNote;
|
||||||
|
|
||||||
|
/// No description provided for @syncNotConfigured.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter a server URL to enable sync.'**
|
||||||
|
String get syncNotConfigured;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaults.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Defaults'**
|
||||||
|
String get settingsDefaults;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAppearance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Appearance'**
|
||||||
|
String get settingsAppearance;
|
||||||
|
|
||||||
|
/// No description provided for @settingsAbout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'About'**
|
||||||
|
String get settingsAbout;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultTool.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default tool'**
|
||||||
|
String get settingsDefaultTool;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultColor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default color'**
|
||||||
|
String get settingsDefaultColor;
|
||||||
|
|
||||||
|
/// No description provided for @settingsDefaultWidth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Default stroke width'**
|
||||||
|
String get settingsDefaultWidth;
|
||||||
|
|
||||||
|
/// No description provided for @settingsPressureCurve.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pressure curve'**
|
||||||
|
String get settingsPressureCurve;
|
||||||
|
|
||||||
|
/// No description provided for @settingsClearConfirmBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This resets pen defaults and appearance. Notes and documents are not affected.'**
|
||||||
|
String get settingsClearConfirmBody;
|
||||||
|
|
||||||
|
/// No description provided for @serverSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'BadNote Server'**
|
||||||
|
String get serverSection;
|
||||||
|
|
||||||
|
/// No description provided for @serverUrl.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Server URL'**
|
||||||
|
String get serverUrl;
|
||||||
|
|
||||||
|
/// No description provided for @serverUrlHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'http://192.168.1.10:8080'**
|
||||||
|
String get serverUrlHint;
|
||||||
|
|
||||||
|
/// No description provided for @serverUsername.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Username'**
|
||||||
|
String get serverUsername;
|
||||||
|
|
||||||
|
/// No description provided for @serverPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Password'**
|
||||||
|
String get serverPassword;
|
||||||
|
|
||||||
|
/// No description provided for @serverSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save & sign in'**
|
||||||
|
String get serverSave;
|
||||||
|
|
||||||
|
/// No description provided for @serverTest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Test connection'**
|
||||||
|
String get serverTest;
|
||||||
|
|
||||||
|
/// No description provided for @serverTestOk.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connected · API {version}'**
|
||||||
|
String serverTestOk(String version);
|
||||||
|
|
||||||
|
/// No description provided for @serverTestFail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connection failed: {error}'**
|
||||||
|
String serverTestFail(String error);
|
||||||
|
|
||||||
|
/// No description provided for @serverLoggedIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed in'**
|
||||||
|
String get serverLoggedIn;
|
||||||
|
|
||||||
|
/// No description provided for @serverHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'**
|
||||||
|
String get serverHint;
|
||||||
|
|
||||||
|
/// No description provided for @boardEmptyTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No sticky notes yet'**
|
||||||
|
String get boardEmptyTitle;
|
||||||
|
|
||||||
|
/// No description provided for @boardEmptyBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'**
|
||||||
|
String get boardEmptyBody;
|
||||||
|
|
||||||
|
/// No description provided for @relativeJustNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Just now'**
|
||||||
|
String get relativeJustNow;
|
||||||
|
|
||||||
|
/// No description provided for @relativeMinutesAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{n}m ago'**
|
||||||
|
String relativeMinutesAgo(int n);
|
||||||
|
|
||||||
|
/// No description provided for @relativeHoursAgo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{n}h ago'**
|
||||||
|
String relativeHoursAgo(int n);
|
||||||
|
|
||||||
|
/// No description provided for @relativeYesterday.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Yesterday'**
|
||||||
|
String get relativeYesterday;
|
||||||
|
|
||||||
|
/// No description provided for @diagExported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Diagnostic pack exported ({bytes} bytes)\nPath copied'**
|
||||||
|
String diagExported(int bytes);
|
||||||
|
|
||||||
|
/// No description provided for @diagExportFail.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export failed: {error}'**
|
||||||
|
String diagExportFail(String error);
|
||||||
|
|
||||||
|
/// No description provided for @processingOcr.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Processing OCR…'**
|
||||||
|
String get processingOcr;
|
||||||
|
|
||||||
|
/// No description provided for @notebooksSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notebooks'**
|
||||||
|
String get notebooksSection;
|
||||||
|
|
||||||
|
/// No description provided for @addBlankPage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Blank page'**
|
||||||
|
String get addBlankPage;
|
||||||
|
|
||||||
|
/// No description provided for @importIntoNotebook.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Import into notebook'**
|
||||||
|
String get importIntoNotebook;
|
||||||
|
|
||||||
|
/// No description provided for @notebookMembersEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No pages yet'**
|
||||||
|
String get notebookMembersEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @memberCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} items'**
|
||||||
|
String memberCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @textFontSmall.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'S'**
|
||||||
|
String get textFontSmall;
|
||||||
|
|
||||||
|
/// No description provided for @textFontMedium.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'M'**
|
||||||
|
String get textFontMedium;
|
||||||
|
|
||||||
|
/// No description provided for @textFontLarge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'L'**
|
||||||
|
String get textFontLarge;
|
||||||
|
|
||||||
|
/// No description provided for @textBold.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bold'**
|
||||||
|
String get textBold;
|
||||||
|
|
||||||
|
/// No description provided for @textDragHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Drag to move'**
|
||||||
|
String get textDragHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -23,6 +23,47 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get importPpt => 'Import PPT';
|
String get importPpt => 'Import PPT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importFile => 'Import file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createNotebook => 'Create notebook';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newNotebookTitle => 'New notebook';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookTitleHint => 'Notebook title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get create => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get untitledNote => 'Untitled';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noNotesYetHint => 'No ink notes yet — tap + to create one';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDocumentsYet => 'No documents yet — tap Import file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingImport => 'Importing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importFailed(String error) {
|
||||||
|
return 'Couldn\'t import that file: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get convertNeedsLibreOffice =>
|
||||||
|
'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String unsupportedFileType(String ext) {
|
||||||
|
return 'Unsupported file type: $ext';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get penCanvasBeta => 'Pen Canvas (beta)';
|
String get penCanvasBeta => 'Pen Canvas (beta)';
|
||||||
|
|
||||||
@@ -53,6 +94,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get removeDocument => 'Remove document';
|
String get removeDocument => 'Remove document';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pickColor => 'Pick a color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSettingsTitle => 'Clear all local settings?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => 'Clear';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsReset => 'Settings reset to defaults';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeSystem => 'System';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeLight => 'Light';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeDark => 'Dark';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seedColorDesc => 'Seed color for Material 3 theme';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHint => 'Search notes and documents...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String searchError(String error) {
|
||||||
|
return 'Search error: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noResultsFor(String query) {
|
||||||
|
return 'No results for \"$query\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get typeToSearch => 'Type to search your notes and documents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionNotes => 'Notes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionDocuments => 'Documents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pageLabel(int page) {
|
||||||
|
return 'Page $page';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get processingPptx => 'Processing PPTX...';
|
String get processingPptx => 'Processing PPTX...';
|
||||||
|
|
||||||
@@ -71,6 +166,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get toolEraser => 'Eraser';
|
String get toolEraser => 'Eraser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPicker => 'Brush';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushFountainPen => 'Fountain pen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushBallpoint => 'Ballpoint';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPencil => 'Pencil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushHighlighter => 'Highlighter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolSelect => 'Select';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolShape => 'Shape';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapePicker => 'Shape';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeLine => 'Line';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeRectangle => 'Rectangle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeEllipse => 'Ellipse';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeArrow => 'Arrow';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionDeleteSelection => 'Delete selection';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionUndo => 'Undo';
|
String get actionUndo => 'Undo';
|
||||||
|
|
||||||
@@ -101,6 +235,59 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get nextPage => 'Next page';
|
String get nextPage => 'Next page';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolSelectText => 'Select text';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionHighlightSelection => 'Highlight selection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolRemoveHighlight => 'Remove highlight (tap a highlight)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolPlaceScratchLink => 'Place scratch link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolText => 'Text (tap or double-click to add)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textPlaceholder => 'Type…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteTitle => 'Delete scratch link?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteBody =>
|
||||||
|
'This removes the anchor and its private scratchpad.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolAddBookmark => 'Add bookmark (here or at selection)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolBookmarks => 'Bookmarks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksTitle => 'Bookmarks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty => 'No bookmarks yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkDefaultLabel(int page) {
|
||||||
|
return 'Page $page';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkPageLabel(int page) {
|
||||||
|
return 'Page $page';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteTitle => 'Delete bookmark?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteBody => 'This removes the saved location.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return 'Failed to open PDF:\n$error';
|
return 'Failed to open PDF:\n$error';
|
||||||
@@ -113,4 +300,324 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String pageOfPages(int current, int total) {
|
String pageOfPages(int current, int total) {
|
||||||
return '$current / $total';
|
return '$current / $total';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTab => 'Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardTab => 'Stickies';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shellTagline => 'Ink · Annotate · Know';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notesSection => 'Notes';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get documentsSection => 'Documents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyLibraryTitle => 'Nothing here yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyLibraryBody => 'Create a note, or import PDF / PPT / Word';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSection => 'Diagnostics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsExport => 'Export diagnostic pack';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsExportHint =>
|
||||||
|
'Reproduce on Surface, export, and send the zip back';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsToggle => 'Input diagnostics overlay';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get penSettingsUnified => 'Pen & ink';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get board => 'Board';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardTitle => 'Sticky Board';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardOpen => 'Sticky note board';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardAddCard => 'Add card';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardNewCardText => 'New note';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardDeleteCard => 'Delete card';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardDeleteCardTitle => 'Delete this card?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardBacklinks => 'Linked from';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardNoBacklinks => 'Nothing links here yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String boardDanglingLink(String target) {
|
||||||
|
return 'No card named \"$target\"';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupTitle => 'Choose your vault';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupHeadline => 'Pick a folder for your notebooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupBody =>
|
||||||
|
'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultChooseFolder => 'Choose folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultMissingTitle => 'Your vault folder is missing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultMissingBody =>
|
||||||
|
'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String vaultPickFailed(String error) {
|
||||||
|
return 'Couldn\'t open the folder picker: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultNotWritable =>
|
||||||
|
'That folder isn\'t writable. Please choose another.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSection => 'Vault';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultFolderLabel => 'Vault folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultNoneSelected => 'No folder selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultChangeFolder => 'Change vault folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultUpdated => 'Vault folder updated';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSection => 'Sync (WebDAV)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrl => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrlHint =>
|
||||||
|
'https://dav.example.com/remote.php/dav/files/me';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncUsername => 'Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolder => 'Remote folder';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolderHint => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSave => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSaved => 'Sync settings saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestConnection => 'Test connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestOk => 'Connection OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncTestFailed(String error) {
|
||||||
|
return 'Connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => 'Sync now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRunning => 'Syncing…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNeverRun => 'Never synced';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastRun(String when) {
|
||||||
|
return 'Last synced: $when';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
|
||||||
|
return '$uploaded uploaded · $downloaded downloaded · $conflicts conflicts';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncFailed(String error) {
|
||||||
|
return 'Sync failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncAuto => 'Sync automatically on launch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncCredentialsNote =>
|
||||||
|
'Credentials are stored locally in plain text. Use a dedicated app password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNotConfigured => 'Enter a server URL to enable sync.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaults => 'Defaults';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearance => 'Appearance';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAbout => 'About';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultTool => 'Default tool';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultColor => 'Default color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultWidth => 'Default stroke width';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPressureCurve => 'Pressure curve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsClearConfirmBody =>
|
||||||
|
'This resets pen defaults and appearance. Notes and documents are not affected.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSection => 'BadNote Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrl => 'Server URL';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrlHint => 'http://192.168.1.10:8080';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUsername => 'Username';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSave => 'Save & sign in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverTest => 'Test connection';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestOk(String version) {
|
||||||
|
return 'Connected · API $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestFail(String error) {
|
||||||
|
return 'Connection failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverLoggedIn => 'Signed in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverHint =>
|
||||||
|
'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyTitle => 'No sticky notes yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyBody =>
|
||||||
|
'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeJustNow => 'Just now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeMinutesAgo(int n) {
|
||||||
|
return '${n}m ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeHoursAgo(int n) {
|
||||||
|
return '${n}h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeYesterday => 'Yesterday';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExported(int bytes) {
|
||||||
|
return 'Diagnostic pack exported ($bytes bytes)\nPath copied';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExportFail(String error) {
|
||||||
|
return 'Export failed: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingOcr => 'Processing OCR…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebooksSection => 'Notebooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addBlankPage => 'Blank page';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importIntoNotebook => 'Import into notebook';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookMembersEmpty => 'No pages yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memberCount(int count) {
|
||||||
|
return '$count items';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontSmall => 'S';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontMedium => 'M';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontLarge => 'L';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textBold => 'Bold';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textDragHint => 'Drag to move';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,47 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get importPpt => '导入 PPT';
|
String get importPpt => '导入 PPT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importFile => '导入文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createNotebook => '新建笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newNotebookTitle => '新建笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookTitleHint => '笔记本标题';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get create => '创建';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get untitledNote => '未命名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noNotesYetHint => '还没有手写笔记——点按 + 新建';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDocumentsYet => '暂无文档——点按“导入文件”';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingImport => '正在导入…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String importFailed(String error) {
|
||||||
|
return '无法导入该文件:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get convertNeedsLibreOffice =>
|
||||||
|
'导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String unsupportedFileType(String ext) {
|
||||||
|
return '不支持的文件类型:$ext';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get penCanvasBeta => '手写画布(测试版)';
|
String get penCanvasBeta => '手写画布(测试版)';
|
||||||
|
|
||||||
@@ -53,6 +94,60 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get removeDocument => '移除文档';
|
String get removeDocument => '移除文档';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => '确定';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pickColor => '选择颜色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearSettingsTitle => '清除所有本地设置?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clear => '清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsReset => '设置已恢复默认';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeSystem => '跟随系统';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeLight => '浅色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get themeDark => '深色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get seedColorDesc => 'Material 3 主题种子色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchHint => '搜索笔记和文档…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String searchError(String error) {
|
||||||
|
return '搜索出错:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noResultsFor(String query) {
|
||||||
|
return '没有“$query”的结果';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get typeToSearch => '输入以搜索你的笔记和文档';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionNotes => '笔记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sectionDocuments => '文档';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pageLabel(int page) {
|
||||||
|
return '第 $page 页';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get processingPptx => '正在处理 PPTX…';
|
String get processingPptx => '正在处理 PPTX…';
|
||||||
|
|
||||||
@@ -71,6 +166,45 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get toolEraser => '橡皮擦';
|
String get toolEraser => '橡皮擦';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPicker => '笔刷';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushFountainPen => '钢笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushBallpoint => '圆珠笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushPencil => '铅笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get brushHighlighter => '荧光笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolSelect => '选择';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolShape => '形状';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapePicker => '形状';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeLine => '直线';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeRectangle => '矩形';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeEllipse => '椭圆';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shapeArrow => '箭头';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionDeleteSelection => '删除所选';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get actionUndo => '撤销';
|
String get actionUndo => '撤销';
|
||||||
|
|
||||||
@@ -101,6 +235,58 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get nextPage => '下一页';
|
String get nextPage => '下一页';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolSelectText => '选择文字';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionHighlightSelection => '高亮所选';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolRemoveHighlight => '移除高亮(点按高亮处)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolPlaceScratchLink => '放置便签链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolText => '文字(点按或双击添加)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textPlaceholder => '输入文字…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteTitle => '删除便签链接?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolAddBookmark => '添加书签(当前位置或所选段落)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolBookmarks => '书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksTitle => '书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty => '还没有书签。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkDefaultLabel(int page) {
|
||||||
|
return '第 $page 页';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkPageLabel(int page) {
|
||||||
|
return '第 $page 页';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteTitle => '删除书签?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteBody => '这会移除保存的位置。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return '打开 PDF 失败:\n$error';
|
return '打开 PDF 失败:\n$error';
|
||||||
@@ -113,4 +299,317 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String pageOfPages(int current, int total) {
|
String pageOfPages(int current, int total) {
|
||||||
return '$current / $total';
|
return '$current / $total';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryTab => '库';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardTab => '便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shellTagline => '手写 · 批注 · 知识';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notesSection => '笔记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get documentsSection => '文档';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyLibraryTitle => '还没有内容';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emptyLibraryBody => '新建笔记,或导入 PDF / PPT / Word';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsSection => '诊断';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsExport => '导出诊断包';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsExportHint => '在 Surface 上复现问题后导出,发回给开发者分析';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get diagnosticsToggle => '输入诊断叠加层';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get penSettingsUnified => '笔与墨迹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get board => '便利贴板';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardTitle => '便利贴板';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardOpen => '便利贴板';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardAddCard => '添加便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardNewCardText => '新便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardDeleteCard => '删除便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardDeleteCardTitle => '删除这张便利贴?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardBacklinks => '哪些链接到这里';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardNoBacklinks => '暂无其他便利贴链接到这里';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String boardDanglingLink(String target) {
|
||||||
|
return '没有名为“$target”的便利贴';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get close => '关闭';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupTitle => '选择笔记库';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupHeadline => '为你的笔记本选择一个文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSetupBody =>
|
||||||
|
'BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultChooseFolder => '选择文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultMissingTitle => '笔记库文件夹不见了';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultMissingBody => '找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String vaultPickFailed(String error) {
|
||||||
|
return '无法打开文件夹选择器:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultNotWritable => '该文件夹不可写,请另选一个。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultSection => '笔记库';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultFolderLabel => '笔记库文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultNoneSelected => '尚未选择文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultChangeFolder => '更改笔记库文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vaultUpdated => '笔记库文件夹已更新';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSection => '同步(WebDAV)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrl => '服务器地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncServerUrlHint =>
|
||||||
|
'https://dav.example.com/remote.php/dav/files/me';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncUsername => '用户名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncPassword => '密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolder => '远程文件夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRemoteFolderHint => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSave => '保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncSaved => '同步设置已保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestConnection => '测试连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncTestOk => '连接成功';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncTestFailed(String error) {
|
||||||
|
return '连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNow => '立即同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncRunning => '同步中…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNeverRun => '尚未同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncLastRun(String when) {
|
||||||
|
return '上次同步:$when';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
|
||||||
|
return '上传 $uploaded · 下载 $downloaded · 冲突 $conflicts';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String syncFailed(String error) {
|
||||||
|
return '同步失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncAuto => '启动时自动同步';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncCredentialsNote => '凭据以明文保存在本地,建议使用专用的应用密码。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get syncNotConfigured => '请输入服务器地址以启用同步。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaults => '默认笔迹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAppearance => '外观';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsAbout => '关于';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultTool => '默认工具';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultColor => '默认颜色';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsDefaultWidth => '默认线宽';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPressureCurve => '压感曲线';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSection => 'BadNote 服务器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrl => '服务器地址';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUrlHint => 'http://192.168.1.10:8080';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverUsername => '用户名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverPassword => '密码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverSave => '保存并登录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverTest => '测试连接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestOk(String version) {
|
||||||
|
return '连接成功 · API $version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String serverTestFail(String error) {
|
||||||
|
return '连接失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverLoggedIn => '已登录';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyTitle => '还没有便利贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeJustNow => '刚刚';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeMinutesAgo(int n) {
|
||||||
|
return '$n 分钟前';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String relativeHoursAgo(int n) {
|
||||||
|
return '$n 小时前';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relativeYesterday => '昨天';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExported(int bytes) {
|
||||||
|
return '诊断包已导出($bytes 字节)\n路径已复制';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String diagExportFail(String error) {
|
||||||
|
return '导出失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingOcr => '正在识别文字…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebooksSection => '笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addBlankPage => '空白页';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importIntoNotebook => '导入到笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookMembersEmpty => '还没有页面';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memberCount(int count) {
|
||||||
|
return '$count 项';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontSmall => '小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontMedium => '中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontLarge => '大';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textBold => '粗体';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textDragHint => '拖动移动';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,18 @@
|
|||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"importPdf": "导入 PDF",
|
"importPdf": "导入 PDF",
|
||||||
"importPpt": "导入 PPT",
|
"importPpt": "导入 PPT",
|
||||||
|
"importFile": "导入文件",
|
||||||
|
"createNotebook": "新建笔记本",
|
||||||
|
"newNotebookTitle": "新建笔记本",
|
||||||
|
"notebookTitleHint": "笔记本标题",
|
||||||
|
"create": "创建",
|
||||||
|
"untitledNote": "未命名",
|
||||||
|
"noNotesYetHint": "还没有手写笔记——点按 + 新建",
|
||||||
|
"noDocumentsYet": "暂无文档——点按“导入文件”",
|
||||||
|
"processingImport": "正在导入…",
|
||||||
|
"importFailed": "无法导入该文件:{error}",
|
||||||
|
"convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。",
|
||||||
|
"unsupportedFileType": "不支持的文件类型:{ext}",
|
||||||
"penCanvasBeta": "手写画布(测试版)",
|
"penCanvasBeta": "手写画布(测试版)",
|
||||||
"newNote": "新建笔记",
|
"newNote": "新建笔记",
|
||||||
"open": "打开",
|
"open": "打开",
|
||||||
@@ -15,12 +27,41 @@
|
|||||||
"openInSplitView": "分屏打开",
|
"openInSplitView": "分屏打开",
|
||||||
"splitViewSubtitle": "PDF 参考 + 草稿纸",
|
"splitViewSubtitle": "PDF 参考 + 草稿纸",
|
||||||
"removeDocument": "移除文档",
|
"removeDocument": "移除文档",
|
||||||
|
"ok": "确定",
|
||||||
|
"pickColor": "选择颜色",
|
||||||
|
"clearSettingsTitle": "清除所有本地设置?",
|
||||||
|
"clear": "清除",
|
||||||
|
"settingsReset": "设置已恢复默认",
|
||||||
|
"themeSystem": "跟随系统",
|
||||||
|
"themeLight": "浅色",
|
||||||
|
"themeDark": "深色",
|
||||||
|
"seedColorDesc": "Material 3 主题种子色",
|
||||||
|
"searchHint": "搜索笔记和文档…",
|
||||||
|
"searchError": "搜索出错:{error}",
|
||||||
|
"noResultsFor": "没有“{query}”的结果",
|
||||||
|
"typeToSearch": "输入以搜索你的笔记和文档",
|
||||||
|
"sectionNotes": "笔记",
|
||||||
|
"sectionDocuments": "文档",
|
||||||
|
"pageLabel": "第 {page} 页",
|
||||||
"processingPptx": "正在处理 PPTX…",
|
"processingPptx": "正在处理 PPTX…",
|
||||||
"processingPresentation": "正在处理演示文稿…",
|
"processingPresentation": "正在处理演示文稿…",
|
||||||
"couldNotOpenPresentation": "无法打开演示文稿。",
|
"couldNotOpenPresentation": "无法打开演示文稿。",
|
||||||
"toolPen": "钢笔",
|
"toolPen": "钢笔",
|
||||||
"toolHighlighter": "荧光笔",
|
"toolHighlighter": "荧光笔",
|
||||||
"toolEraser": "橡皮擦",
|
"toolEraser": "橡皮擦",
|
||||||
|
"brushPicker": "笔刷",
|
||||||
|
"brushFountainPen": "钢笔",
|
||||||
|
"brushBallpoint": "圆珠笔",
|
||||||
|
"brushPencil": "铅笔",
|
||||||
|
"brushHighlighter": "荧光笔",
|
||||||
|
"toolSelect": "选择",
|
||||||
|
"toolShape": "形状",
|
||||||
|
"shapePicker": "形状",
|
||||||
|
"shapeLine": "直线",
|
||||||
|
"shapeRectangle": "矩形",
|
||||||
|
"shapeEllipse": "椭圆",
|
||||||
|
"shapeArrow": "箭头",
|
||||||
|
"actionDeleteSelection": "删除所选",
|
||||||
"actionUndo": "撤销",
|
"actionUndo": "撤销",
|
||||||
"actionRedo": "重做",
|
"actionRedo": "重做",
|
||||||
"fingerDrawingOn": "手指书写:开",
|
"fingerDrawingOn": "手指书写:开",
|
||||||
@@ -31,7 +72,153 @@
|
|||||||
"back": "返回",
|
"back": "返回",
|
||||||
"previousPage": "上一页",
|
"previousPage": "上一页",
|
||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
|
"toolSelectText": "选择文字",
|
||||||
|
"actionHighlightSelection": "高亮所选",
|
||||||
|
"toolRemoveHighlight": "移除高亮(点按高亮处)",
|
||||||
|
"toolPlaceScratchLink": "放置便签链接",
|
||||||
|
"toolText": "文字(点按或双击添加)",
|
||||||
|
"textPlaceholder": "输入文字…",
|
||||||
|
"scratchLinkDeleteTitle": "删除便签链接?",
|
||||||
|
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
||||||
|
"toolAddBookmark": "添加书签(当前位置或所选段落)",
|
||||||
|
"toolBookmarks": "书签",
|
||||||
|
"bookmarksTitle": "书签",
|
||||||
|
"bookmarksEmpty": "还没有书签。",
|
||||||
|
"bookmarkDefaultLabel": "第 {page} 页",
|
||||||
|
"@bookmarkDefaultLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkPageLabel": "第 {page} 页",
|
||||||
|
"@bookmarkPageLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkDeleteTitle": "删除书签?",
|
||||||
|
"bookmarkDeleteBody": "这会移除保存的位置。",
|
||||||
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
||||||
"pdfNoPages": "PDF 没有任何页面。",
|
"pdfNoPages": "PDF 没有任何页面。",
|
||||||
"pageOfPages": "{current} / {total}"
|
"pageOfPages": "{current} / {total}",
|
||||||
|
"libraryTab": "库",
|
||||||
|
"boardTab": "便利贴",
|
||||||
|
"shellTagline": "手写 · 批注 · 知识",
|
||||||
|
"notesSection": "笔记",
|
||||||
|
"documentsSection": "文档",
|
||||||
|
"emptyLibraryTitle": "还没有内容",
|
||||||
|
"emptyLibraryBody": "新建笔记,或导入 PDF / PPT / Word",
|
||||||
|
"diagnosticsSection": "诊断",
|
||||||
|
"diagnosticsExport": "导出诊断包",
|
||||||
|
"diagnosticsExportHint": "在 Surface 上复现问题后导出,发回给开发者分析",
|
||||||
|
"diagnosticsToggle": "输入诊断叠加层",
|
||||||
|
"penSettingsUnified": "笔与墨迹",
|
||||||
|
"board": "便利贴板",
|
||||||
|
"boardTitle": "便利贴板",
|
||||||
|
"boardOpen": "便利贴板",
|
||||||
|
"boardAddCard": "添加便利贴",
|
||||||
|
"boardNewCardText": "新便利贴",
|
||||||
|
"boardDeleteCard": "删除便利贴",
|
||||||
|
"boardDeleteCardTitle": "删除这张便利贴?",
|
||||||
|
"boardBacklinks": "哪些链接到这里",
|
||||||
|
"boardNoBacklinks": "暂无其他便利贴链接到这里",
|
||||||
|
"boardDanglingLink": "没有名为“{target}”的便利贴",
|
||||||
|
"close": "关闭",
|
||||||
|
"vaultSetupTitle": "选择笔记库",
|
||||||
|
"vaultSetupHeadline": "为你的笔记本选择一个文件夹",
|
||||||
|
"vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。",
|
||||||
|
"vaultChooseFolder": "选择文件夹",
|
||||||
|
"vaultMissingTitle": "笔记库文件夹不见了",
|
||||||
|
"vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。",
|
||||||
|
"vaultPickFailed": "无法打开文件夹选择器:{error}",
|
||||||
|
"vaultNotWritable": "该文件夹不可写,请另选一个。",
|
||||||
|
"vaultSection": "笔记库",
|
||||||
|
"vaultFolderLabel": "笔记库文件夹",
|
||||||
|
"vaultNoneSelected": "尚未选择文件夹",
|
||||||
|
"vaultChangeFolder": "更改笔记库文件夹",
|
||||||
|
"vaultUpdated": "笔记库文件夹已更新",
|
||||||
|
"syncSection": "同步(WebDAV)",
|
||||||
|
"syncServerUrl": "服务器地址",
|
||||||
|
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
|
||||||
|
"syncUsername": "用户名",
|
||||||
|
"syncPassword": "密码",
|
||||||
|
"syncRemoteFolder": "远程文件夹",
|
||||||
|
"syncRemoteFolderHint": "BadNote",
|
||||||
|
"syncSave": "保存",
|
||||||
|
"syncSaved": "同步设置已保存",
|
||||||
|
"syncTestConnection": "测试连接",
|
||||||
|
"syncTestOk": "连接成功",
|
||||||
|
"syncTestFailed": "连接失败:{error}",
|
||||||
|
"@syncTestFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncNow": "立即同步",
|
||||||
|
"syncRunning": "同步中…",
|
||||||
|
"syncNeverRun": "尚未同步",
|
||||||
|
"syncLastRun": "上次同步:{when}",
|
||||||
|
"@syncLastRun": {
|
||||||
|
"placeholders": { "when": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}",
|
||||||
|
"@syncResultSummary": {
|
||||||
|
"placeholders": {
|
||||||
|
"uploaded": { "type": "int" },
|
||||||
|
"downloaded": { "type": "int" },
|
||||||
|
"conflicts": { "type": "int" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncFailed": "同步失败:{error}",
|
||||||
|
"@syncFailed": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"syncAuto": "启动时自动同步",
|
||||||
|
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
|
||||||
|
"syncNotConfigured": "请输入服务器地址以启用同步。",
|
||||||
|
"settingsDefaults": "默认笔迹",
|
||||||
|
"settingsAppearance": "外观",
|
||||||
|
"settingsAbout": "关于",
|
||||||
|
"settingsDefaultTool": "默认工具",
|
||||||
|
"settingsDefaultColor": "默认颜色",
|
||||||
|
"settingsDefaultWidth": "默认线宽",
|
||||||
|
"settingsPressureCurve": "压感曲线",
|
||||||
|
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
|
||||||
|
"serverSection": "BadNote 服务器",
|
||||||
|
"serverUrl": "服务器地址",
|
||||||
|
"serverUrlHint": "http://192.168.1.10:8080",
|
||||||
|
"serverUsername": "用户名",
|
||||||
|
"serverPassword": "密码",
|
||||||
|
"serverSave": "保存并登录",
|
||||||
|
"serverTest": "测试连接",
|
||||||
|
"serverTestOk": "连接成功 · API {version}",
|
||||||
|
"@serverTestOk": {
|
||||||
|
"placeholders": { "version": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverTestFail": "连接失败:{error}",
|
||||||
|
"@serverTestFail": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"serverLoggedIn": "已登录",
|
||||||
|
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。",
|
||||||
|
"boardEmptyTitle": "还没有便利贴",
|
||||||
|
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
|
||||||
|
"relativeJustNow": "刚刚",
|
||||||
|
"relativeMinutesAgo": "{n} 分钟前",
|
||||||
|
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeHoursAgo": "{n} 小时前",
|
||||||
|
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||||
|
"relativeYesterday": "昨天",
|
||||||
|
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
|
||||||
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
|
"diagExportFail": "导出失败:{error}",
|
||||||
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"processingOcr": "正在识别文字…",
|
||||||
|
"notebooksSection": "笔记本",
|
||||||
|
"addBlankPage": "空白页",
|
||||||
|
"importIntoNotebook": "导入到笔记本",
|
||||||
|
"notebookMembersEmpty": "还没有页面",
|
||||||
|
"memberCount": "{count} 项",
|
||||||
|
"@memberCount": {
|
||||||
|
"placeholders": { "count": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"textFontSmall": "小",
|
||||||
|
"textFontMedium": "中",
|
||||||
|
"textFontLarge": "大",
|
||||||
|
"textBold": "粗体",
|
||||||
|
"textDragHint": "拖动移动"
|
||||||
}
|
}
|
||||||
|
|||||||
173
lib/main.dart
173
lib/main.dart
@@ -2,21 +2,25 @@ import 'package:dynamic_color/dynamic_color.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'editor/pdf/pen_capture_region.dart';
|
import 'editor/pdf/pen_capture_region.dart';
|
||||||
|
import 'editor/persistence/sidecar_flush_observer.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
|
import 'diagnostics/badnote_log.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/app_shell.dart';
|
||||||
|
import 'screens/vault_setup_screen.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
|
import 'services/vault_service.dart';
|
||||||
|
import 'services/webdav_sync_service.dart';
|
||||||
|
import 'storage/sqlite_to_sidecar_migrator.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
// Kind-gated PDF pen capture MUST install before runApp — without it
|
||||||
// binding before runApp so the M1 spike's PenCaptureRegion can gate
|
// PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
|
||||||
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
|
|
||||||
// region mounted it behaves exactly like the default binding.
|
|
||||||
PenCaptureBinding.ensureInitialized();
|
PenCaptureBinding.ensureInitialized();
|
||||||
// pdfrx native engine init (required before any PdfViewer is built).
|
// pdfrx native engine init (required before any PdfViewer is built).
|
||||||
pdfrxFlutterInitialize();
|
pdfrxFlutterInitialize();
|
||||||
@@ -27,14 +31,40 @@ Future<void> main() async {
|
|||||||
// Initialize SharedPreferences
|
// Initialize SharedPreferences
|
||||||
await SharedPreferences.getInstance();
|
await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
// Always-on structured diagnostics (Surface remote debugging).
|
||||||
|
await BadNoteLog.instance.start();
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.shell, 'app_start');
|
||||||
|
|
||||||
runApp(const ProviderScope(child: BadNoteApp()));
|
runApp(const ProviderScope(child: BadNoteApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class BadNoteApp extends ConsumerWidget {
|
class BadNoteApp extends ConsumerStatefulWidget {
|
||||||
const BadNoteApp({super.key});
|
const BadNoteApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<BadNoteApp> createState() => _BadNoteAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BadNoteAppState extends ConsumerState<BadNoteApp> {
|
||||||
|
// Phase 6 / §F.3: flush any open sidecar repos when the app is suspended or
|
||||||
|
// closed so the last strokes are never lost to an OS kill. Lives for the whole
|
||||||
|
// app lifetime (attached here, detached on app teardown).
|
||||||
|
final SidecarFlushObserver _flushObserver = SidecarFlushObserver();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_flushObserver.attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_flushObserver.detach();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
|
|
||||||
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
||||||
@@ -54,8 +84,8 @@ class BadNoteApp extends ConsumerWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'BadNote',
|
title: 'BadNote',
|
||||||
themeMode: settings.themeMode,
|
themeMode: settings.themeMode,
|
||||||
theme: _theme(lightScheme),
|
theme: AppTheme.fromScheme(lightScheme),
|
||||||
darkTheme: _theme(darkScheme),
|
darkTheme: AppTheme.fromScheme(darkScheme),
|
||||||
// i18n: follows the OS language (en / zh) via the system locale.
|
// i18n: follows the OS language (en / zh) via the system locale.
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
AppLocalizations.delegate,
|
AppLocalizations.delegate,
|
||||||
@@ -64,17 +94,128 @@ class BadNoteApp extends ConsumerWidget {
|
|||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: const HomeScreen(),
|
home: const VaultGate(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ThemeData _theme(ColorScheme scheme) => ThemeData(
|
/// Startup gate: shows [HomeScreen] only once a valid vault root folder has been
|
||||||
colorScheme: scheme,
|
/// chosen. If none is set — or the saved folder no longer exists — it shows
|
||||||
useMaterial3: true,
|
/// [VaultSetupScreen] first (re-prompting on a missing folder rather than
|
||||||
textTheme: GoogleFonts.interTextTheme(
|
/// silently scattering data elsewhere). Phase 0: records the vault path only;
|
||||||
ThemeData(brightness: scheme.brightness).textTheme,
|
/// editors still use SQLite.
|
||||||
|
class VaultGate extends StatefulWidget {
|
||||||
|
const VaultGate({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VaultGate> createState() => _VaultGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VaultGateState extends State<VaultGate> {
|
||||||
|
VaultService? _vault;
|
||||||
|
bool _valid = false;
|
||||||
|
bool _hadStoredPath = false;
|
||||||
|
bool _loading = true;
|
||||||
|
bool _migrating = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_check();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _check() async {
|
||||||
|
final vault = await VaultService.getInstance();
|
||||||
|
final valid = await vault.vaultRootValid();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_vault = vault;
|
||||||
|
_valid = valid;
|
||||||
|
// A stored-but-invalid path means the chosen folder went missing.
|
||||||
|
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
if (valid) {
|
||||||
|
await _maybeMigrate(vault);
|
||||||
|
_maybeAutoSync(vault); // fire-and-forget; never blocks the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optionally kick off a WebDAV sync on launch when the user has enabled
|
||||||
|
/// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a
|
||||||
|
/// bad config or offline server must never delay or crash startup. Results
|
||||||
|
/// are surfaced in Settings (last-synced time) rather than interrupting here.
|
||||||
|
Future<void> _maybeAutoSync(VaultService vault) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final sync = WebDavSyncService(prefs);
|
||||||
|
final config = sync.config;
|
||||||
|
if (!config.autoSync || !config.isConfigured) return;
|
||||||
|
final root = vault.vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return;
|
||||||
|
final client = sync.buildClient();
|
||||||
|
if (client == null) return;
|
||||||
|
try {
|
||||||
|
await sync.syncNow(vaultRoot: root, client: client);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Auto-sync is best-effort; swallow everything so launch is unaffected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
|
||||||
|
/// Gated on [VaultService.vaultMigrationDone]; a fresh install (no legacy DB)
|
||||||
|
/// is a fast no-op. The live DB is first reopened at the vault cache location
|
||||||
|
/// so post-migration reads hit the new index, never the renamed legacy file.
|
||||||
|
Future<void> _maybeMigrate(VaultService vault) async {
|
||||||
|
// Move the live cache DB to the vault location now that the root is valid.
|
||||||
|
await DatabaseService.reopen();
|
||||||
|
if (vault.vaultMigrationDone) return;
|
||||||
|
if (mounted) setState(() => _migrating = true);
|
||||||
|
try {
|
||||||
|
await SqliteToSidecarMigrator(vault).run();
|
||||||
|
await vault.setVaultMigrationDone();
|
||||||
|
} catch (_) {
|
||||||
|
// A failed migration leaves the legacy DB intact (it is only renamed to
|
||||||
|
// `.premigration` after a successful pass) and the flag unset, so the
|
||||||
|
// next launch retries. Never block the user from reaching the app.
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _migrating = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_migrating) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text('Migrating your notebooks…'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (_valid) return const AppShell();
|
||||||
|
return VaultSetupScreen(
|
||||||
|
vaultService: _vault!,
|
||||||
|
missing: _hadStoredPath,
|
||||||
|
onVaultReady: () async {
|
||||||
|
if (_vault != null) await _maybeMigrate(_vault!);
|
||||||
|
if (mounted) setState(() => _valid = true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'bookmark.freezed.dart';
|
part 'bookmark.freezed.dart';
|
||||||
part 'bookmark.g.dart';
|
part 'bookmark.g.dart';
|
||||||
|
|
||||||
|
/// A saved location in a document.
|
||||||
|
///
|
||||||
|
/// "Paragraph precision" (user ask: 精确到段落加书签) is expressed by the optional
|
||||||
|
/// in-page anchor fields below, all normalized to the page in [0,1]:
|
||||||
|
///
|
||||||
|
/// * [anchorLeft]/[anchorTop]/[anchorRight]/[anchorBottom] — the bounding rect
|
||||||
|
/// of the bookmarked text fragment (the FIRST fragment of the current text
|
||||||
|
/// selection), in NORMALIZED page coords with a top-left origin (the same
|
||||||
|
/// convention `SidecarHighlight` and the editor's highlight rects use). This
|
||||||
|
/// is what jump-to scrolls to (via `goToRectInsidePage`), so the bookmark
|
||||||
|
/// lands on the exact paragraph, not just the page top.
|
||||||
|
/// * [charIndex] — the character index of the selection start in the page's
|
||||||
|
/// `fullText` (the true text-position anchor). Stored for fidelity / future
|
||||||
|
/// reflow-tolerant re-anchoring; not currently used for navigation.
|
||||||
|
///
|
||||||
|
/// When no text was selected the anchor falls back to the tapped point: only
|
||||||
|
/// [anchorTop]/[anchorLeft] are set (a zero-size rect) and [charIndex] is null.
|
||||||
|
/// All anchor fields are optional and absent from JSON when null, so OLD
|
||||||
|
/// bookmarks (page-only) still decode and re-encode unchanged (back-compat).
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Bookmark with _$Bookmark {
|
abstract class Bookmark with _$Bookmark {
|
||||||
const factory Bookmark({
|
const factory Bookmark({
|
||||||
required String id,
|
required String id,
|
||||||
required String documentId,
|
required String documentId,
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
required int pageNumber,
|
required int pageNumber,
|
||||||
@Default('') String label,
|
@Default('') String label,
|
||||||
@Default(0xFF2196F3) int color,
|
@Default(0xFF2196F3) int color,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
int? charIndex,
|
||||||
}) = _Bookmark;
|
}) = _Bookmark;
|
||||||
|
|
||||||
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
|
|||||||
mixin _$Bookmark {
|
mixin _$Bookmark {
|
||||||
String get id => throw _privateConstructorUsedError;
|
String get id => throw _privateConstructorUsedError;
|
||||||
String get documentId => throw _privateConstructorUsedError;
|
String get documentId => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
int get pageNumber => throw _privateConstructorUsedError;
|
int get pageNumber => throw _privateConstructorUsedError;
|
||||||
String get label => throw _privateConstructorUsedError;
|
String get label => throw _privateConstructorUsedError;
|
||||||
int get color => throw _privateConstructorUsedError;
|
int get color => throw _privateConstructorUsedError;
|
||||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
double? get anchorLeft => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorTop => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorRight => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorBottom => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
int? get charIndex => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this Bookmark to a JSON map.
|
/// Serializes this Bookmark to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
|
|||||||
String label,
|
String label,
|
||||||
int color,
|
int color,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
int? charIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +95,11 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
|||||||
Object? label = null,
|
Object? label = null,
|
||||||
Object? color = null,
|
Object? color = null,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
|
Object? anchorLeft = freezed,
|
||||||
|
Object? anchorTop = freezed,
|
||||||
|
Object? anchorRight = freezed,
|
||||||
|
Object? anchorBottom = freezed,
|
||||||
|
Object? charIndex = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
|||||||
? _value.createdAt
|
? _value.createdAt
|
||||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,
|
as DateTime,
|
||||||
|
anchorLeft: freezed == anchorLeft
|
||||||
|
? _value.anchorLeft
|
||||||
|
: anchorLeft // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorTop: freezed == anchorTop
|
||||||
|
? _value.anchorTop
|
||||||
|
: anchorTop // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorRight: freezed == anchorRight
|
||||||
|
? _value.anchorRight
|
||||||
|
: anchorRight // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorBottom: freezed == anchorBottom
|
||||||
|
? _value.anchorBottom
|
||||||
|
: anchorBottom // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
charIndex: freezed == charIndex
|
||||||
|
? _value.charIndex
|
||||||
|
: charIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
|
|||||||
String label,
|
String label,
|
||||||
int color,
|
int color,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
int? charIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
|||||||
Object? label = null,
|
Object? label = null,
|
||||||
Object? color = null,
|
Object? color = null,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
|
Object? anchorLeft = freezed,
|
||||||
|
Object? anchorTop = freezed,
|
||||||
|
Object? anchorRight = freezed,
|
||||||
|
Object? anchorBottom = freezed,
|
||||||
|
Object? charIndex = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$BookmarkImpl(
|
_$BookmarkImpl(
|
||||||
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
|||||||
? _value.createdAt
|
? _value.createdAt
|
||||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,
|
as DateTime,
|
||||||
|
anchorLeft: freezed == anchorLeft
|
||||||
|
? _value.anchorLeft
|
||||||
|
: anchorLeft // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorTop: freezed == anchorTop
|
||||||
|
? _value.anchorTop
|
||||||
|
: anchorTop // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorRight: freezed == anchorRight
|
||||||
|
? _value.anchorRight
|
||||||
|
: anchorRight // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorBottom: freezed == anchorBottom
|
||||||
|
? _value.anchorBottom
|
||||||
|
: anchorBottom // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
charIndex: freezed == charIndex
|
||||||
|
? _value.charIndex
|
||||||
|
: charIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,6 +264,11 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
this.label = '',
|
this.label = '',
|
||||||
this.color = 0xFF2196F3,
|
this.color = 0xFF2196F3,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.anchorLeft,
|
||||||
|
this.anchorTop,
|
||||||
|
this.anchorRight,
|
||||||
|
this.anchorBottom,
|
||||||
|
this.charIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
final String id;
|
final String id;
|
||||||
@override
|
@override
|
||||||
final String documentId;
|
final String documentId;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
@override
|
@override
|
||||||
final int pageNumber;
|
final int pageNumber;
|
||||||
@override
|
@override
|
||||||
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
@override
|
@override
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
@override
|
||||||
|
final double? anchorLeft;
|
||||||
|
@override
|
||||||
|
final double? anchorTop;
|
||||||
|
@override
|
||||||
|
final double? anchorRight;
|
||||||
|
@override
|
||||||
|
final double? anchorBottom;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
@override
|
||||||
|
final int? charIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)';
|
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt, anchorLeft: $anchorLeft, anchorTop: $anchorTop, anchorRight: $anchorRight, anchorBottom: $anchorBottom, charIndex: $charIndex)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
(identical(other.label, label) || other.label == label) &&
|
(identical(other.label, label) || other.label == label) &&
|
||||||
(identical(other.color, color) || other.color == color) &&
|
(identical(other.color, color) || other.color == color) &&
|
||||||
(identical(other.createdAt, createdAt) ||
|
(identical(other.createdAt, createdAt) ||
|
||||||
other.createdAt == createdAt));
|
other.createdAt == createdAt) &&
|
||||||
|
(identical(other.anchorLeft, anchorLeft) ||
|
||||||
|
other.anchorLeft == anchorLeft) &&
|
||||||
|
(identical(other.anchorTop, anchorTop) ||
|
||||||
|
other.anchorTop == anchorTop) &&
|
||||||
|
(identical(other.anchorRight, anchorRight) ||
|
||||||
|
other.anchorRight == anchorRight) &&
|
||||||
|
(identical(other.anchorBottom, anchorBottom) ||
|
||||||
|
other.anchorBottom == anchorBottom) &&
|
||||||
|
(identical(other.charIndex, charIndex) ||
|
||||||
|
other.charIndex == charIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
anchorLeft,
|
||||||
|
anchorTop,
|
||||||
|
anchorRight,
|
||||||
|
anchorBottom,
|
||||||
|
charIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Create a copy of Bookmark
|
/// Create a copy of Bookmark
|
||||||
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
final String label,
|
final String label,
|
||||||
final int color,
|
final int color,
|
||||||
required final DateTime createdAt,
|
required final DateTime createdAt,
|
||||||
|
final double? anchorLeft,
|
||||||
|
final double? anchorTop,
|
||||||
|
final double? anchorRight,
|
||||||
|
final double? anchorBottom,
|
||||||
|
final int? charIndex,
|
||||||
}) = _$BookmarkImpl;
|
}) = _$BookmarkImpl;
|
||||||
|
|
||||||
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
||||||
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
String get id;
|
String get id;
|
||||||
@override
|
@override
|
||||||
String get documentId;
|
String get documentId;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
@override
|
@override
|
||||||
int get pageNumber;
|
int get pageNumber;
|
||||||
@override
|
@override
|
||||||
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
@override
|
@override
|
||||||
DateTime get createdAt;
|
DateTime get createdAt;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
@override
|
||||||
|
double? get anchorLeft;
|
||||||
|
@override
|
||||||
|
double? get anchorTop;
|
||||||
|
@override
|
||||||
|
double? get anchorRight;
|
||||||
|
@override
|
||||||
|
double? get anchorBottom;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
@override
|
||||||
|
int? get charIndex;
|
||||||
|
|
||||||
/// Create a copy of Bookmark
|
/// Create a copy of Bookmark
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
|
|||||||
label: json['label'] as String? ?? '',
|
label: json['label'] as String? ?? '',
|
||||||
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
anchorLeft: (json['anchorLeft'] as num?)?.toDouble(),
|
||||||
|
anchorTop: (json['anchorTop'] as num?)?.toDouble(),
|
||||||
|
anchorRight: (json['anchorRight'] as num?)?.toDouble(),
|
||||||
|
anchorBottom: (json['anchorBottom'] as num?)?.toDouble(),
|
||||||
|
charIndex: (json['charIndex'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
||||||
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
|||||||
'label': instance.label,
|
'label': instance.label,
|
||||||
'color': instance.color,
|
'color': instance.color,
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'anchorLeft': instance.anchorLeft,
|
||||||
|
'anchorTop': instance.anchorTop,
|
||||||
|
'anchorRight': instance.anchorRight,
|
||||||
|
'anchorBottom': instance.anchorBottom,
|
||||||
|
'charIndex': instance.charIndex,
|
||||||
};
|
};
|
||||||
|
|||||||
103
lib/models/scratch_link.dart
Normal file
103
lib/models/scratch_link.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// lib/models/scratch_link.dart
|
||||||
|
//
|
||||||
|
// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized
|
||||||
|
// position (nx, ny in [0,1]) on a specific page of a document. Tapping the
|
||||||
|
// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by
|
||||||
|
// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ScratchLink {
|
||||||
|
const ScratchLink({
|
||||||
|
required this.id,
|
||||||
|
required this.documentId,
|
||||||
|
required this.pageIndex,
|
||||||
|
required this.nx,
|
||||||
|
required this.ny,
|
||||||
|
this.nw = 0.42,
|
||||||
|
this.nh = 0.36,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stable anchor id (uuid). Doubles as the scratchpad storage key so each
|
||||||
|
/// anchor gets its own private infinite scratchpad.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// The owning document (the editor's stable document-id for the PDF path).
|
||||||
|
final String documentId;
|
||||||
|
|
||||||
|
/// 0-based page the anchor sits on.
|
||||||
|
final int pageIndex;
|
||||||
|
|
||||||
|
/// Normalized horizontal position on the page, in [0, 1] (top-left of card).
|
||||||
|
final double nx;
|
||||||
|
|
||||||
|
/// Normalized vertical position on the page, in [0, 1] (top-left of card).
|
||||||
|
final double ny;
|
||||||
|
|
||||||
|
/// Expanded card width as a fraction of page width (clamped on write).
|
||||||
|
final double nw;
|
||||||
|
|
||||||
|
/// Expanded card height as a fraction of page height.
|
||||||
|
final double nh;
|
||||||
|
|
||||||
|
ScratchLink copyWith({
|
||||||
|
String? id,
|
||||||
|
String? documentId,
|
||||||
|
int? pageIndex,
|
||||||
|
double? nx,
|
||||||
|
double? ny,
|
||||||
|
double? nw,
|
||||||
|
double? nh,
|
||||||
|
}) =>
|
||||||
|
ScratchLink(
|
||||||
|
id: id ?? this.id,
|
||||||
|
documentId: documentId ?? this.documentId,
|
||||||
|
pageIndex: pageIndex ?? this.pageIndex,
|
||||||
|
nx: nx ?? this.nx,
|
||||||
|
ny: ny ?? this.ny,
|
||||||
|
nw: nw ?? this.nw,
|
||||||
|
nh: nh ?? this.nh,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'documentId': documentId,
|
||||||
|
'pageIndex': pageIndex,
|
||||||
|
'nx': nx,
|
||||||
|
'ny': ny,
|
||||||
|
'nw': nw,
|
||||||
|
'nh': nh,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
|
||||||
|
id: json['id'] as String,
|
||||||
|
documentId: json['documentId'] as String,
|
||||||
|
pageIndex: (json['pageIndex'] as num).toInt(),
|
||||||
|
nx: (json['nx'] as num).toDouble(),
|
||||||
|
ny: (json['ny'] as num).toDouble(),
|
||||||
|
nw: (json['nw'] as num?)?.toDouble() ?? 0.42,
|
||||||
|
nh: (json['nh'] as num?)?.toDouble() ?? 0.36,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is ScratchLink &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
documentId == other.documentId &&
|
||||||
|
pageIndex == other.pageIndex &&
|
||||||
|
nx == other.nx &&
|
||||||
|
ny == other.ny &&
|
||||||
|
nw == other.nw &&
|
||||||
|
nh == other.nh;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, '
|
||||||
|
'nx: $nx, ny: $ny, nw: $nw, nh: $nh)';
|
||||||
|
}
|
||||||
@@ -1,61 +1,68 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../models/document.dart';
|
import '../models/document.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/vault_service.dart';
|
||||||
import 'note_provider.dart';
|
|
||||||
|
|
||||||
const _uuid = Uuid();
|
final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
|
||||||
|
return VaultService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
final documentListProvider =
|
final documentListProvider =
|
||||||
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
||||||
DocumentListNotifier.new,
|
DocumentListNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The home-screen document list is now sourced from a VAULT SCAN (folders
|
||||||
|
/// under the vault root containing a source file + optional sidecar), NOT the
|
||||||
|
/// SQLite `documents` table. The sidecar that travels with the file is the
|
||||||
|
/// source of truth; there is no SQLite cache for this list (the scan is cheap —
|
||||||
|
/// one directory listing — and always correct).
|
||||||
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
|
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
|
||||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
Future<VaultService> get _vault =>
|
||||||
|
ref.read(vaultServiceProvider.future);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Document>> build() async {
|
Future<List<Document>> build() async {
|
||||||
final db = await _db;
|
return _scan();
|
||||||
return db.getAllDocuments();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads documents from the database and publishes the result to [state]
|
Future<List<Document>> _scan() async {
|
||||||
/// so the UI rebuilds. Used by pull-to-refresh.
|
final vault = await _vault;
|
||||||
|
final notebooks = await vault.scanNotebooks();
|
||||||
|
return notebooks.map(_toDocument).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapt a scanned [VaultNotebook] into the [Document] shape the home-screen
|
||||||
|
/// tiles already render. The notebook folder path doubles as a stable id.
|
||||||
|
Document _toDocument(VaultNotebook nb) {
|
||||||
|
return Document(
|
||||||
|
id: nb.folderPath,
|
||||||
|
filename: nb.filename,
|
||||||
|
docType: nb.docType,
|
||||||
|
filePath: nb.sourceFilePath,
|
||||||
|
pageCount: 0,
|
||||||
|
createdAt: nb.modified,
|
||||||
|
updatedAt: nb.modified,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
|
||||||
|
/// after an import.
|
||||||
Future<void> loadDocuments() async {
|
Future<void> loadDocuments() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(_scan);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a notebook by deleting its folder (source file + sidecar travel
|
||||||
|
/// together, so removing the folder removes the whole notebook). [id] is the
|
||||||
|
/// notebook folder path produced by [_toDocument].
|
||||||
Future<void> removeDocument(String id) async {
|
Future<void> removeDocument(String id) async {
|
||||||
final db = await _db;
|
final dir = Directory(id);
|
||||||
await db.deleteDocument(id);
|
if (await dir.exists()) {
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
}
|
||||||
final current = state.value ?? [];
|
final current = state.value ?? [];
|
||||||
state = AsyncData(current.where((d) => d.id != id).toList());
|
state = AsyncData(current.where((d) => d.id != id).toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,81 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../services/database_service.dart';
|
import '../services/vault_service.dart';
|
||||||
|
import 'document_provider.dart' show vaultServiceProvider;
|
||||||
const _uuid = Uuid();
|
|
||||||
|
|
||||||
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
|
|
||||||
return DatabaseService.getInstance();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
/// The home-screen note list is now sourced from a VAULT SCAN of standalone
|
||||||
|
/// (free-ink) notebook folders — each a folder holding a `notebook.badnote.json`
|
||||||
|
/// and NO importable source file — NOT the SQLite `notes` table. The sidecar
|
||||||
|
/// that lives in the folder is the source of truth ("跟着文件走").
|
||||||
|
///
|
||||||
|
/// Each scanned note is adapted into the existing [Note] model the home screen
|
||||||
|
/// already renders: `id` = the synthetic note path (`<folder>/notebook`, also a
|
||||||
|
/// stable id), `title`, `updatedAt` = the sidecar mtime. Strokes are NOT loaded
|
||||||
|
/// here — they are hydrated lazily by the editor from the sidecar, so the list
|
||||||
|
/// stays cheap (one directory listing). Home tiles that show a stroke count will
|
||||||
|
/// therefore read 0 until the note is opened; the count is no longer cached.
|
||||||
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
|
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
|
||||||
NoteListNotifier.new,
|
NoteListNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
class NoteListNotifier extends AsyncNotifier<List<Note>> {
|
class NoteListNotifier extends AsyncNotifier<List<Note>> {
|
||||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Note>> build() async {
|
Future<List<Note>> build() async {
|
||||||
final db = await _db;
|
return _scan();
|
||||||
return db.getAllNotes();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads notes from the database and publishes the result to [state] so
|
Future<List<Note>> _scan() async {
|
||||||
/// the UI rebuilds. Used by pull-to-refresh.
|
final vault = await _vault;
|
||||||
|
final notes = await vault.scanNotes();
|
||||||
|
return notes.map(_toNote).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapt a scanned [VaultNote] into the [Note] shape the home tiles render.
|
||||||
|
/// `id` is the synthetic note path so opening it re-keys the right sidecar.
|
||||||
|
Note _toNote(VaultNote n) => Note(
|
||||||
|
id: n.notePath,
|
||||||
|
title: n.title,
|
||||||
|
createdAt: n.modified,
|
||||||
|
updatedAt: n.modified,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
|
||||||
|
/// after a note is created or edited.
|
||||||
Future<void> loadNotes() async {
|
Future<void> loadNotes() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(_scan);
|
||||||
final db = await _db;
|
|
||||||
return db.getAllNotes();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an empty standalone notebook folder with [title] and return the
|
||||||
|
/// adapted [Note] (whose `id` is the synthetic note path). The home screen
|
||||||
|
/// opens the editor on it; persistence flows through the sidecar.
|
||||||
Future<Note> createNote({String title = 'Untitled'}) async {
|
Future<Note> createNote({String title = 'Untitled'}) async {
|
||||||
final db = await _db;
|
final vault = await _vault;
|
||||||
|
final notePath = await vault.createEmptyNotebook(title);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final note = Note(
|
final note = Note(
|
||||||
id: _uuid.v4(),
|
id: notePath,
|
||||||
title: title,
|
title: title,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
);
|
);
|
||||||
await db.insertNote(note);
|
|
||||||
state = AsyncData([note, ...state.value ?? []]);
|
state = AsyncData([note, ...state.value ?? []]);
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateNote(Note note) async {
|
/// Delete a note by removing its notebook folder (the sidecar travels with
|
||||||
final db = await _db;
|
/// it). [id] is the synthetic note path `<folder>/notebook`.
|
||||||
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 {
|
Future<void> deleteNote(String id) async {
|
||||||
final db = await _db;
|
final folder = Directory(File(id).parent.path);
|
||||||
await db.deleteNote(id);
|
if (await folder.exists()) {
|
||||||
|
await folder.delete(recursive: true);
|
||||||
|
}
|
||||||
final current = state.value ?? [];
|
final current = state.value ?? [];
|
||||||
state = AsyncData(current.where((n) => n.id != id).toList());
|
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);
|
|
||||||
});
|
|
||||||
|
|||||||
46
lib/providers/notebook_container_provider.dart
Normal file
46
lib/providers/notebook_container_provider.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// lib/providers/notebook_container_provider.dart
|
||||||
|
//
|
||||||
|
// Home-screen list of OneNote-style notebook containers: vault folders that
|
||||||
|
// hold a `notebook.json` manifest (see `storage/notebook_manifest.dart`). This
|
||||||
|
// mirrors `note_provider.dart` / `document_provider.dart`'s vault-scan pattern
|
||||||
|
// — the manifest on disk is the source of truth, there is no SQLite cache.
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import 'document_provider.dart' show vaultServiceProvider;
|
||||||
|
|
||||||
|
final notebookContainerListProvider = AsyncNotifierProvider<
|
||||||
|
NotebookContainerListNotifier, List<VaultContainer>>(
|
||||||
|
NotebookContainerListNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class NotebookContainerListNotifier
|
||||||
|
extends AsyncNotifier<List<VaultContainer>> {
|
||||||
|
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<VaultContainer>> build() => _scan();
|
||||||
|
|
||||||
|
Future<List<VaultContainer>> _scan() async {
|
||||||
|
final vault = await _vault;
|
||||||
|
return vault.scanContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
|
||||||
|
/// after a container is created elsewhere.
|
||||||
|
Future<void> loadContainers() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(_scan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new notebook container (folder + `notebook.json` + one blank ink
|
||||||
|
/// page) titled [title], prepend it to the list, and return it so the caller
|
||||||
|
/// can navigate straight into it.
|
||||||
|
Future<VaultContainer> createContainer(String title) async {
|
||||||
|
final vault = await _vault;
|
||||||
|
final container = await vault.createNotebookContainer(title);
|
||||||
|
state = AsyncData([container, ...state.value ?? []]);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../services/ocr_service.dart';
|
import '../services/ocr_service.dart';
|
||||||
|
import '../services/pdf_text_indexer.dart';
|
||||||
|
import '../services/pdfrx_page_text_source.dart';
|
||||||
|
|
||||||
enum OcrStatus { none, processing, done, failed }
|
enum OcrStatus { none, processing, done, failed }
|
||||||
|
|
||||||
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
|
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
|
||||||
|
|
||||||
|
/// The import-time PDF document-body indexer, wired to the pdfrx-backed embedded
|
||||||
|
/// text + page-render OCR sources (see [PdfrxPageTextSource]). The import flow
|
||||||
|
/// fires [PdfTextIndexer.indexPdf] (fire-and-forget) so a scanned PDF's text
|
||||||
|
/// becomes searchable in the background without blocking the editor opening.
|
||||||
|
final pdfTextIndexerProvider = Provider<PdfTextIndexer>(
|
||||||
|
(ref) => PdfTextIndexer(
|
||||||
|
loadEmbeddedText: PdfrxPageTextSource.loadEmbeddedText,
|
||||||
|
ocrPages: PdfrxPageTextSource.ocrPages,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Tracks local OCR processing status per note ID.
|
/// Tracks local OCR processing status per note ID.
|
||||||
///
|
///
|
||||||
/// This map only ever holds an entry per note that has had OCR triggered in
|
/// This map only ever holds an entry per note that has had OCR triggered in
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../models/document.dart';
|
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import 'note_provider.dart';
|
import '../services/vault_search_index.dart';
|
||||||
|
import 'document_provider.dart' show vaultServiceProvider;
|
||||||
|
|
||||||
|
/// The search index, rebuilt by SCANNING the vault sidecars (the source of
|
||||||
|
/// truth) — NOT the demoted SQLite cache (Phase 6, §B/§F). Bumping
|
||||||
|
/// [searchIndexEpochProvider] (e.g. after an import or note edit) invalidates
|
||||||
|
/// this provider so the next read re-scans the vault from disk.
|
||||||
|
final vaultSearchIndexProvider = FutureProvider<VaultSearchIndex>((ref) async {
|
||||||
|
ref.watch(searchIndexEpochProvider);
|
||||||
|
final vault = await ref.watch(vaultServiceProvider.future);
|
||||||
|
final index = VaultSearchIndex(vault);
|
||||||
|
await index.rebuild();
|
||||||
|
return index;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Bump to force the search index to rebuild from disk (e.g. after an import).
|
||||||
|
final searchIndexEpochProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
final searchQueryProvider = StateProvider<String>((ref) => '');
|
final searchQueryProvider = StateProvider<String>((ref) => '');
|
||||||
|
|
||||||
@@ -34,59 +49,45 @@ class DocumentSearchHit extends SearchResult {
|
|||||||
|
|
||||||
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
||||||
final query = ref.watch(searchQueryProvider);
|
final query = ref.watch(searchQueryProvider);
|
||||||
if (query.isEmpty) return [];
|
if (query.trim().isEmpty) return [];
|
||||||
|
|
||||||
// Obtain the DB through the provider graph so this participates in
|
final index = await ref.watch(vaultSearchIndexProvider.future);
|
||||||
// initialization and disposal like every other consumer.
|
|
||||||
final db = await ref.watch(databaseServiceProvider.future);
|
|
||||||
|
|
||||||
// Run the note and document searches concurrently.
|
final hits = await index.search(query);
|
||||||
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>[];
|
final results = <SearchResult>[];
|
||||||
|
for (final hit in hits) {
|
||||||
// Add note results.
|
final entry = hit.entry;
|
||||||
for (final note in noteHits) {
|
final snippet = hit.snippet.text;
|
||||||
results.add(NoteSearchHit(note: note, snippet: note.title));
|
if (entry.isNote) {
|
||||||
}
|
// Construct a lightweight Note whose id is the synthetic note path so
|
||||||
|
// PenNoteScreen re-keys the right sidecar on open. Strokes are hydrated
|
||||||
// Resolve document metadata without an N+1 loop: collect the distinct
|
// lazily by the editor; the search list only needs id/title.
|
||||||
// document ids referenced by the hits, look each up exactly once, then
|
final now = DateTime.now();
|
||||||
// build the result list from the cached lookups.
|
results.add(
|
||||||
final docIds = <String>{
|
NoteSearchHit(
|
||||||
for (final hit in docHits)
|
note: Note(
|
||||||
if (hit['document_id'] is String) hit['document_id'] as String,
|
id: entry.openPath,
|
||||||
};
|
title: entry.title,
|
||||||
final docEntries = await Future.wait(
|
createdAt: now,
|
||||||
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
|
updatedAt: now,
|
||||||
);
|
),
|
||||||
final docsById = <String, Document>{
|
snippet: snippet,
|
||||||
for (final entry in docEntries)
|
),
|
||||||
if (entry.value != null) entry.key: entry.value!,
|
);
|
||||||
};
|
} else {
|
||||||
|
results.add(
|
||||||
for (final hit in docHits) {
|
DocumentSearchHit(
|
||||||
final documentId = hit['document_id'];
|
documentId: entry.id,
|
||||||
if (documentId is! String) continue;
|
filename: entry.title,
|
||||||
final doc = docsById[documentId];
|
filePath: entry.openPath,
|
||||||
if (doc == null) continue;
|
// The scan-based index matches whole-notebook text, not per-page, so
|
||||||
|
// the document opens at its first page.
|
||||||
final pageNumber = hit['page_number'];
|
pageNumber: 0,
|
||||||
final content = hit['content'];
|
snippet: snippet,
|
||||||
results.add(
|
),
|
||||||
DocumentSearchHit(
|
);
|
||||||
documentId: documentId,
|
}
|
||||||
filename: doc.filename,
|
|
||||||
filePath: doc.filePath,
|
|
||||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
|
||||||
snippet: content is String ? content : '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
174
lib/screens/app_shell.dart
Normal file
174
lib/screens/app_shell.dart
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../diagnostics/badnote_log.dart';
|
||||||
|
import '../diagnostics/diagnostic_chrome.dart';
|
||||||
|
import '../diagnostics/diagnostic_export.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import 'board_screen.dart';
|
||||||
|
import 'home_screen.dart';
|
||||||
|
import 'search_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
/// Unified product shell — single chrome for library, board, search, settings.
|
||||||
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
|
const AppShell({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AppShell> createState() => _AppShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppShellState extends ConsumerState<AppShell> {
|
||||||
|
int _index = 0;
|
||||||
|
final _diagKey = GlobalKey<DiagnosticChromeState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.shell, 'shell_open');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final wide = MediaQuery.sizeOf(context).width >= 900;
|
||||||
|
|
||||||
|
final destinations = [
|
||||||
|
_Dest(Icons.menu_book_outlined, Icons.menu_book, l.libraryTab),
|
||||||
|
_Dest(Icons.sticky_note_2_outlined, Icons.sticky_note_2, l.boardTab),
|
||||||
|
_Dest(Icons.search, Icons.search, l.search),
|
||||||
|
_Dest(Icons.tune, Icons.tune, l.settings),
|
||||||
|
];
|
||||||
|
|
||||||
|
final pages = const [
|
||||||
|
HomeScreen(embeddedInShell: true),
|
||||||
|
BoardScreen(),
|
||||||
|
SearchScreen(embeddedInShell: true),
|
||||||
|
SettingsScreen(embeddedInShell: true),
|
||||||
|
];
|
||||||
|
|
||||||
|
final body = DiagnosticChrome(
|
||||||
|
key: _diagKey,
|
||||||
|
child: pages[_index],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wide) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
NavigationRail(
|
||||||
|
selectedIndex: _index,
|
||||||
|
onDestinationSelected: _select,
|
||||||
|
extended: MediaQuery.sizeOf(context).width >= 1200,
|
||||||
|
labelType: MediaQuery.sizeOf(context).width >= 1200
|
||||||
|
? NavigationRailLabelType.none
|
||||||
|
: NavigationRailLabelType.all,
|
||||||
|
leading: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12, bottom: 24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'BadNote',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppTokens.copper,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l.shellTagline,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: AppTokens.inkMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: DiagnosticToggleButton(
|
||||||
|
onToggle: () => _diagKey.currentState?.toggle(),
|
||||||
|
onExport: () => _diagKey.currentState?.exportPack(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
destinations: [
|
||||||
|
for (final d in destinations)
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(d.icon),
|
||||||
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
|
label: Text(d.label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
VerticalDivider(width: 1, color: AppTokens.rule.withValues(alpha: 0.8)),
|
||||||
|
Expanded(child: body),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: body,
|
||||||
|
bottomNavigationBar: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
NavigationBar(
|
||||||
|
selectedIndex: _index,
|
||||||
|
onDestinationSelected: _select,
|
||||||
|
destinations: [
|
||||||
|
for (final d in destinations)
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(d.icon),
|
||||||
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
|
label: d.label,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.small(
|
||||||
|
heroTag: 'diag_fab',
|
||||||
|
tooltip: AppLocalizations.of(context).diagnosticsSection,
|
||||||
|
onPressed: () => _diagKey.currentState?.toggle(),
|
||||||
|
child: const Icon(Icons.bug_report_outlined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _select(int i) {
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.shell, 'tab', fields: {'index': i});
|
||||||
|
setState(() => _index = i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Dest {
|
||||||
|
const _Dest(this.icon, this.selectedIcon, this.label);
|
||||||
|
final IconData icon;
|
||||||
|
final IconData selectedIcon;
|
||||||
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper used by settings when not embedded — still export packs.
|
||||||
|
Future<void> exportDiagnosticPack(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
final result = await DiagnosticExport.instance.exportPack();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).diagExported(result.bytes)),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(AppLocalizations.of(context).diagExportFail('$e'))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
403
lib/screens/board_screen.dart
Normal file
403
lib/screens/board_screen.dart
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../diagnostics/badnote_log.dart';
|
||||||
|
import '../editor/board/board.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
|
||||||
|
const _kDefaultBoardId = 'main';
|
||||||
|
|
||||||
|
/// Infinite sticky-note board — first-class shell destination (F7).
|
||||||
|
class BoardScreen extends ConsumerStatefulWidget {
|
||||||
|
const BoardScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BoardScreen> createState() => _BoardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardScreenState extends ConsumerState<BoardScreen> {
|
||||||
|
Board _board = Board.empty;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _selectedId;
|
||||||
|
final _transform = TransformationController();
|
||||||
|
Timer? _saveDebounce;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_saveDebounce?.cancel();
|
||||||
|
_transform.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final db = await DatabaseService.getInstance();
|
||||||
|
final board = await db.loadBoard(_kDefaultBoardId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_board = board;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
BadNoteLog.instance.info(
|
||||||
|
LogSubsystem.board,
|
||||||
|
'board_loaded',
|
||||||
|
fields: {'cards': board.length},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleSave() {
|
||||||
|
_saveDebounce?.cancel();
|
||||||
|
_saveDebounce = Timer(const Duration(milliseconds: 400), () async {
|
||||||
|
final db = await DatabaseService.getInstance();
|
||||||
|
await db.saveBoardCards(_kDefaultBoardId, _board.cards);
|
||||||
|
BadNoteLog.instance.debug(
|
||||||
|
LogSubsystem.board,
|
||||||
|
'board_saved',
|
||||||
|
fields: {'cards': _board.length},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addCard() {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final id = const Uuid().v4();
|
||||||
|
// Place near viewport center in scene coords.
|
||||||
|
final matrix = _transform.value;
|
||||||
|
final inv = Matrix4.inverted(matrix);
|
||||||
|
final center = MatrixUtils.transformPoint(
|
||||||
|
inv,
|
||||||
|
Offset(
|
||||||
|
MediaQuery.sizeOf(context).width / 2,
|
||||||
|
MediaQuery.sizeOf(context).height / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_board = _board.add(
|
||||||
|
BoardCard(
|
||||||
|
id: id,
|
||||||
|
position: center - const Offset(120, 80),
|
||||||
|
size: const Size(240, 160),
|
||||||
|
text: l.boardNewCardText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_selectedId = id;
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteSelected() {
|
||||||
|
final id = _selectedId;
|
||||||
|
if (id == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l.boardDeleteCardTitle),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l.boardDeleteCard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).then((ok) {
|
||||||
|
if (ok != true) return;
|
||||||
|
setState(() {
|
||||||
|
_board = _board.removeById(id);
|
||||||
|
_selectedId = null;
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
if (_loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
|
||||||
|
final backlinks =
|
||||||
|
selected != null ? _board.backlinksOf(selected.id) : <String>{};
|
||||||
|
final isEmpty = _board.length == 0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l.boardTitle),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.boardAddCard,
|
||||||
|
onPressed: _addCard,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
if (_selectedId != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.boardDeleteCard,
|
||||||
|
onPressed: _deleteSelected,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.sticky_note_2_outlined,
|
||||||
|
size: 72,
|
||||||
|
color: AppTokens.copper.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
l.boardEmptyTitle,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l.boardEmptyBody,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppTokens.inkMuted,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _addCard,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(l.boardAddCard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InteractiveViewer(
|
||||||
|
transformationController: _transform,
|
||||||
|
constrained: false,
|
||||||
|
boundaryMargin: const EdgeInsets.all(2000),
|
||||||
|
minScale: 0.25,
|
||||||
|
maxScale: 3,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 4000,
|
||||||
|
height: 3000,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _BoardGridPainter(
|
||||||
|
color: AppTokens.rule.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
for (final card in _board.cards)
|
||||||
|
Positioned(
|
||||||
|
left: card.position.dx,
|
||||||
|
top: card.position.dy,
|
||||||
|
width: card.size.width,
|
||||||
|
height: card.size.height,
|
||||||
|
child: _StickyCard(
|
||||||
|
card: card,
|
||||||
|
selected: card.id == _selectedId,
|
||||||
|
onTap: () => setState(() => _selectedId = card.id),
|
||||||
|
onDrag: (delta) {
|
||||||
|
setState(() {
|
||||||
|
_board = _board.moveCard(
|
||||||
|
card.id,
|
||||||
|
card.position + delta,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
},
|
||||||
|
onTextChanged: (text) {
|
||||||
|
setState(() {
|
||||||
|
_board = _board.setText(card.id, text);
|
||||||
|
});
|
||||||
|
_scheduleSave();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (selected != null)
|
||||||
|
SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: Material(
|
||||||
|
elevation: 1,
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppTokens.chromePad),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.boardBacklinks,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (backlinks.isEmpty)
|
||||||
|
Text(
|
||||||
|
l.boardNoBacklinks,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...backlinks.map((id) {
|
||||||
|
final c = _board.cardById(id);
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(c?.text.split('\n').first ?? id),
|
||||||
|
onTap: () => setState(() => _selectedId = id),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Divider(),
|
||||||
|
Text(
|
||||||
|
'[[links]]',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l.boardEmptyBody,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: AppTokens.inkMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _addCard,
|
||||||
|
icon: const Icon(Icons.sticky_note_2),
|
||||||
|
label: Text(l.boardAddCard),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickyCard extends StatefulWidget {
|
||||||
|
const _StickyCard({
|
||||||
|
required this.card,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDrag,
|
||||||
|
required this.onTextChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BoardCard card;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ValueChanged<Offset> onDrag;
|
||||||
|
final ValueChanged<String> onTextChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_StickyCard> createState() => _StickyCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StickyCardState extends State<_StickyCard> {
|
||||||
|
late final TextEditingController _controller =
|
||||||
|
TextEditingController(text: widget.card.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _StickyCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.card.text != widget.card.text &&
|
||||||
|
_controller.text != widget.card.text) {
|
||||||
|
_controller.text = widget.card.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onPanUpdate: (d) => widget.onDrag(d.delta),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTokens.sticky,
|
||||||
|
borderRadius: BorderRadius.circular(AppTokens.radiusSm),
|
||||||
|
border: Border.all(
|
||||||
|
color: widget.selected ? AppTokens.copper : AppTokens.rule,
|
||||||
|
width: widget.selected ? 2 : 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
maxLines: null,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppTokens.ink,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
onChanged: widget.onTextChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BoardGridPainter extends CustomPainter {
|
||||||
|
_BoardGridPainter({required this.color});
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color
|
||||||
|
..strokeWidth = 1;
|
||||||
|
const step = 48.0;
|
||||||
|
for (double x = 0; x < size.width; x += step) {
|
||||||
|
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||||
|
}
|
||||||
|
for (double y = 0; y < size.height; y += step) {
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _BoardGridPainter oldDelegate) =>
|
||||||
|
oldDelegate.color != color;
|
||||||
|
}
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/document.dart';
|
import '../models/document.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../providers/document_provider.dart';
|
import '../providers/document_provider.dart';
|
||||||
import '../providers/note_provider.dart';
|
import '../providers/note_provider.dart';
|
||||||
|
import '../providers/notebook_container_provider.dart';
|
||||||
import '../providers/ocr_provider.dart';
|
import '../providers/ocr_provider.dart';
|
||||||
|
import '../providers/search_provider.dart';
|
||||||
import '../editor/canvas/pen_editor_screen.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
import '../editor/pdf/spike_launcher.dart';
|
import '../editor/canvas/office_document_screen.dart';
|
||||||
import '../services/pdf_service.dart';
|
|
||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
import 'note_editor_screen.dart';
|
import '../services/vault_service.dart';
|
||||||
import 'ppt_annotator_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
|
import 'notebook_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'split_view_screen.dart';
|
|
||||||
|
|
||||||
// [M1] Relative date helper — no new package dependencies.
|
// Relative date helper — localized.
|
||||||
String _formatDate(DateTime d) {
|
String _formatDate(BuildContext context, DateTime d) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(d);
|
final diff = now.difference(d);
|
||||||
if (diff.inSeconds < 60) return 'Just now';
|
if (diff.inSeconds < 60) return l.relativeJustNow;
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
|
||||||
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
|
||||||
return 'Yesterday';
|
return l.relativeYesterday;
|
||||||
}
|
}
|
||||||
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key, this.embeddedInShell = false});
|
||||||
|
|
||||||
|
/// When true, chrome (settings/search) is owned by [AppShell].
|
||||||
|
final bool embeddedInShell;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -39,48 +49,40 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(l.appTitle),
|
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
|
||||||
centerTitle: true,
|
centerTitle: !embeddedInShell,
|
||||||
actions: [
|
actions: [
|
||||||
|
if (!embeddedInShell) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
tooltip: l.settings,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: l.search,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.file_open),
|
||||||
tooltip: l.settings,
|
tooltip: l.importFile,
|
||||||
onPressed: () {
|
onPressed: () => _importFile(context, ref),
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
tooltip: l.importPdf,
|
|
||||||
onPressed: () => _importPdf(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
tooltip: l.importPpt,
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.search),
|
|
||||||
tooltip: l.search,
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// New pen-first canvas editor (beta).
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.draw_outlined),
|
|
||||||
tooltip: l.penCanvasBeta,
|
|
||||||
onPressed: () => openM1Spike(context),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => _createAndOpenNote(context, ref),
|
onPressed: () => _createAndOpenNote(context, ref),
|
||||||
child: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(l.createNotebook),
|
||||||
),
|
),
|
||||||
body: notesAsync.when(
|
body: notesAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
@@ -88,8 +90,10 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
data: (notes) {
|
data: (notes) {
|
||||||
final documentsAsync = ref.watch(documentListProvider);
|
final documentsAsync = ref.watch(documentListProvider);
|
||||||
final documents = documentsAsync.valueOrNull ?? [];
|
final documents = documentsAsync.valueOrNull ?? [];
|
||||||
|
final containersAsync = ref.watch(notebookContainerListProvider);
|
||||||
|
final containers = containersAsync.valueOrNull ?? [];
|
||||||
|
|
||||||
if (notes.isEmpty && documents.isEmpty) {
|
if (notes.isEmpty && documents.isEmpty && containers.isEmpty) {
|
||||||
return _buildEmptyState(context, ref);
|
return _buildEmptyState(context, ref);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -97,19 +101,40 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
await Future.wait([
|
await Future.wait([
|
||||||
ref.read(noteListProvider.notifier).loadNotes(),
|
ref.read(noteListProvider.notifier).loadNotes(),
|
||||||
ref.read(documentListProvider.notifier).loadDocuments(),
|
ref.read(documentListProvider.notifier).loadDocuments(),
|
||||||
|
ref
|
||||||
|
.read(notebookContainerListProvider.notifier)
|
||||||
|
.loadContainers(),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Notes section header always shown when documents exist
|
if (containers.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
l.notebooksSection,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
_ContainerTile(container: containers[index]),
|
||||||
|
childCount: containers.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Notes',
|
l.notesSection,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -130,7 +155,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No ink notes yet — tap + to create one',
|
l.noNotesYetHint,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -141,19 +166,19 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Documents section header always shown when notes exist
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Recent Documents',
|
l.documentsSection,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (documents.isNotEmpty)
|
if (documents.isNotEmpty)
|
||||||
|
// continue existing document list below — marker for patch
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) =>
|
(context, index) =>
|
||||||
@@ -162,7 +187,6 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
// [M2] Per-section empty hint when notes exist but documents don't
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -171,7 +195,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No documents yet — import a PDF or PPT',
|
l.noDocumentsYet,
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -191,54 +215,179 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Create notebook" (OneNote style): prompt a title (defaulting to
|
||||||
|
/// Untitled), create the notebook container FOLDER + `notebook.json` (with
|
||||||
|
/// one blank ink page) via `VaultService.createNotebookContainer`, then open
|
||||||
|
/// [NotebookScreen] on it.
|
||||||
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
||||||
final note = await ref.read(noteListProvider.notifier).createNote();
|
final title = await _promptNotebookTitle(context);
|
||||||
if (context.mounted) {
|
if (title == null) return; // cancelled
|
||||||
Navigator.of(
|
final l = context.mounted ? AppLocalizations.of(context) : null;
|
||||||
context,
|
final resolved = title.trim().isEmpty
|
||||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
? (l?.untitledNote ?? 'Untitled')
|
||||||
}
|
: title.trim();
|
||||||
}
|
final container = await ref
|
||||||
|
.read(notebookContainerListProvider.notifier)
|
||||||
Future<void> _importPdf(BuildContext context) async {
|
.createContainer(resolved);
|
||||||
final pdfService = PdfService();
|
|
||||||
final filePath = await pdfService.pickPdfFile();
|
|
||||||
if (filePath != null && context.mounted) {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => PenEditorScreen(pdfPath: 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) {
|
if (context.mounted) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PptAnnotatorScreen(
|
builder: (_) => NotebookScreen(
|
||||||
filePath: filePath,
|
folderPath: container.folderPath,
|
||||||
slideImagePaths: slideImages,
|
title: container.title,
|
||||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask for a notebook title. Returns the entered string (possibly empty →
|
||||||
|
/// caller defaults it), or null if the user cancelled.
|
||||||
|
Future<String?> _promptNotebookTitle(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final controller = TextEditingController(text: l.untitledNote);
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l.newNotebookTitle),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(hintText: l.notebookTitleHint),
|
||||||
|
onSubmitted: (v) => Navigator.of(ctx).pop(v),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: Text(l.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||||
|
child: Text(l.create),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single top-level "Import file" action (sibling of "Create notebook"):
|
||||||
|
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
|
||||||
|
/// open the IN-VAULT copy in the right editor (routed by extension).
|
||||||
|
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: VaultService.importableExtensions.toList(),
|
||||||
|
);
|
||||||
|
final picked = result?.files;
|
||||||
|
if (picked == null || picked.isEmpty) return;
|
||||||
|
final pickedPath = picked.first.path;
|
||||||
|
if (pickedPath == null) return;
|
||||||
|
|
||||||
|
final messenger = context.mounted ? ScaffoldMessenger.of(context) : null;
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text(l.processingImport)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
final vaultPath = await vault.createNotebook(pickedPath);
|
||||||
|
// Refresh the documents list so the new notebook shows on return.
|
||||||
|
await ref.read(documentListProvider.notifier).loadDocuments();
|
||||||
|
// For a PDF, index its document body (embedded text layer, or background
|
||||||
|
// OCR of a rasterized/scanned PDF) into the sidecar so search covers it.
|
||||||
|
// Fire-and-forget: import returns and opens the editor immediately.
|
||||||
|
_indexPdfInBackground(ref, vaultPath);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await _openVaultFile(context, ref, vaultPath);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick off background document-body indexing for an in-vault PDF (no-op for
|
||||||
|
/// other types). Runs detached from the import await chain so the editor opens
|
||||||
|
/// immediately; on completion it bumps the search-index epoch so the newly
|
||||||
|
/// indexed text is searchable. Idempotency and graceful OCR degradation live in
|
||||||
|
/// [PdfTextIndexer]; failures here are swallowed (search just misses the body).
|
||||||
|
void _indexPdfInBackground(WidgetRef ref, String vaultPath) {
|
||||||
|
final ext = p.extension(vaultPath).replaceFirst('.', '').toLowerCase();
|
||||||
|
if (ext != 'pdf') return;
|
||||||
|
final indexer = ref.read(pdfTextIndexerProvider);
|
||||||
|
unawaited(() async {
|
||||||
|
final indexed = await indexer.indexPdf(vaultPath);
|
||||||
|
if (indexed != null && indexed.isNotEmpty) {
|
||||||
|
// Force the next search to re-scan the vault (picks up the new pageText).
|
||||||
|
final epoch = ref.read(searchIndexEpochProvider.notifier);
|
||||||
|
epoch.state = epoch.state + 1;
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route an in-vault [filePath] to the correct editor by extension:
|
||||||
|
/// pdf → [PenEditorScreen]; pptx/docx → native [OfficeDocumentScreen];
|
||||||
|
/// legacy .ppt may still use image fallback.
|
||||||
|
Future<void> _openVaultFile(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenEditorScreen(pdfPath: filePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case 'pptx':
|
||||||
|
case 'docx':
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OfficeDocumentScreen(filePath: filePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case 'ppt':
|
||||||
|
// Legacy binary PPT — try native-ish image path for now.
|
||||||
|
await _openPresentation(context, filePath);
|
||||||
|
default:
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.unsupportedFileType(ext))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPresentation(
|
||||||
|
BuildContext context,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final pptxService = PptxService();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.processingPresentation)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final slideImages = await pptxService.convertToImages(filePath);
|
||||||
|
final extractedText = await pptxService.extractText(filePath);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (slideImages.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenSlideScreen(
|
||||||
|
filePath: filePath,
|
||||||
|
slideImagePaths: slideImages,
|
||||||
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -251,14 +400,14 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'No notes yet',
|
AppLocalizations.of(context).emptyLibraryTitle,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Create your first note',
|
AppLocalizations.of(context).emptyLibraryBody,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -267,19 +416,13 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _createAndOpenNote(context, ref),
|
onPressed: () => _createAndOpenNote(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: Text(AppLocalizations.of(context).newNote),
|
label: Text(AppLocalizations.of(context).createNotebook),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _importPdf(context),
|
onPressed: () => _importFile(context, ref),
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
icon: const Icon(Icons.file_open),
|
||||||
label: Text(AppLocalizations.of(context).importPdf),
|
label: Text(AppLocalizations.of(context).importFile),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
label: Text(AppLocalizations.of(context).importPpt),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -287,6 +430,45 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple tile for a OneNote-style notebook container on the home screen.
|
||||||
|
/// Tapping opens [NotebookScreen] on the container's folder.
|
||||||
|
class _ContainerTile extends StatelessWidget {
|
||||||
|
const _ContainerTile({required this.container});
|
||||||
|
|
||||||
|
final VaultContainer container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final dateStr = _formatDate(context, container.modified);
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.menu_book,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
container.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${l.memberCount(container.memberCount)} · $dateStr',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => NotebookScreen(
|
||||||
|
folderPath: container.folderPath,
|
||||||
|
title: container.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _NoteTile extends ConsumerStatefulWidget {
|
class _NoteTile extends ConsumerStatefulWidget {
|
||||||
final Note note;
|
final Note note;
|
||||||
const _NoteTile({required this.note});
|
const _NoteTile({required this.note});
|
||||||
@@ -302,7 +484,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final note = widget.note;
|
final note = widget.note;
|
||||||
// [M1] Use relative date helper
|
// [M1] Use relative date helper
|
||||||
final dateStr = _formatDate(note.updatedAt);
|
final dateStr = _formatDate(context, note.updatedAt);
|
||||||
|
|
||||||
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
||||||
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
||||||
@@ -334,8 +516,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Stroke count is no longer cached in the vault scan (strokes
|
||||||
|
// load lazily in the editor), so the tile shows only the date.
|
||||||
Text(
|
Text(
|
||||||
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
|
dateStr,
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
if (note.tags.isNotEmpty)
|
if (note.tags.isNotEmpty)
|
||||||
@@ -375,7 +559,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onLongPress: () => _confirmDelete(context),
|
onLongPress: () => _confirmDelete(context),
|
||||||
@@ -425,7 +609,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result == 'open') {
|
if (result == 'open') {
|
||||||
Navigator.of(this.context).push(
|
Navigator.of(this.context).push(
|
||||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
|
||||||
);
|
);
|
||||||
} else if (result == 'delete') {
|
} else if (result == 'delete') {
|
||||||
_confirmDelete(this.context);
|
_confirmDelete(this.context);
|
||||||
@@ -473,7 +657,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final document = widget.document;
|
final document = widget.document;
|
||||||
// [M1] Use relative date helper
|
// [M1] Use relative date helper
|
||||||
final dateStr = _formatDate(document.updatedAt);
|
final dateStr = _formatDate(context, document.updatedAt);
|
||||||
final isPdf = document.docType == 'pdf';
|
final isPdf = document.docType == 'pdf';
|
||||||
|
|
||||||
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
@@ -499,32 +683,18 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
// [H2] Trailing row: split-view (PDF only) + remove
|
// [H2] Trailing remove button. Split view is now reached only by
|
||||||
trailing: Row(
|
// tapping a scratch-link anchor inside the PDF editor, so the
|
||||||
mainAxisSize: MainAxisSize.min,
|
// standalone split-view entry was removed.
|
||||||
children: [
|
trailing: IconButton(
|
||||||
if (isPdf)
|
icon: Icon(
|
||||||
IconButton(
|
Icons.delete_outline,
|
||||||
icon: Icon(
|
color: _hovering
|
||||||
Icons.vertical_split,
|
? Theme.of(context).colorScheme.error
|
||||||
color: _hovering
|
: subtleColor.withValues(alpha: 0.4),
|
||||||
? Theme.of(context).colorScheme.primary
|
),
|
||||||
: subtleColor.withValues(alpha: 0.4),
|
tooltip: 'Remove document',
|
||||||
),
|
onPressed: () => _confirmDelete(context),
|
||||||
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
|
// [L2] Routing bug fix: route by docType
|
||||||
onTap: () => _openDocument(context),
|
onTap: () => _openDocument(context),
|
||||||
@@ -534,22 +704,34 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen
|
// Route by docType: pdf → PenEditorScreen; pptx/docx → native OfficeDocumentScreen.
|
||||||
Future<void> _openDocument(BuildContext context) async {
|
Future<void> _openDocument(BuildContext context) async {
|
||||||
final document = widget.document;
|
final document = widget.document;
|
||||||
final isPdf = document.docType == 'pdf';
|
final l = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (isPdf) {
|
if (document.docType == 'pdf') {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
|
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
return;
|
||||||
// PPT/PPTX: convert to images then push PptAnnotatorScreen
|
}
|
||||||
|
|
||||||
|
if (document.docType == 'docx' || document.docType == 'pptx') {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OfficeDocumentScreen(filePath: document.filePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Legacy .ppt: convert to images then push PenSlideScreen
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
const SnackBar(content: Text('Processing presentation...')),
|
SnackBar(content: Text(l.processingPresentation)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pptxService = PptxService();
|
final pptxService = PptxService();
|
||||||
@@ -558,13 +740,13 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (slideImages.isEmpty) {
|
if (slideImages.isEmpty) {
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
const SnackBar(content: Text('Could not open presentation.')),
|
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Navigator.of(this.context).push(
|
Navigator.of(this.context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PptAnnotatorScreen(
|
builder: (_) => PenSlideScreen(
|
||||||
filePath: document.filePath,
|
filePath: document.filePath,
|
||||||
slideImagePaths: slideImages,
|
slideImagePaths: slideImages,
|
||||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
@@ -574,19 +756,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
void _showContextMenu(BuildContext context, Offset position) async {
|
||||||
final isPdf = widget.document.docType == 'pdf';
|
|
||||||
final result = await showMenu<String>(
|
final result = await showMenu<String>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromLTRB(
|
position: RelativeRect.fromLTRB(
|
||||||
@@ -606,17 +776,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isPdf)
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'split',
|
|
||||||
child: Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.vertical_split),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Open in Split View'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'remove',
|
value: 'remove',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -638,15 +797,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result == 'open') {
|
if (result == 'open') {
|
||||||
_openDocument(this.context);
|
_openDocument(this.context);
|
||||||
} else if (result == 'split') {
|
|
||||||
_openSplitView(this.context);
|
|
||||||
} else if (result == 'remove') {
|
} else if (result == 'remove') {
|
||||||
_confirmDelete(this.context);
|
_confirmDelete(this.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDocumentMenu(BuildContext context) {
|
void _showDocumentMenu(BuildContext context) {
|
||||||
final isPdf = widget.document.docType == 'pdf';
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
@@ -654,16 +810,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
248
lib/screens/notebook_screen.dart
Normal file
248
lib/screens/notebook_screen.dart
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
// lib/screens/notebook_screen.dart
|
||||||
|
//
|
||||||
|
// OneNote-style notebook container screen: lists the members (pages/imported
|
||||||
|
// documents) of one vault folder holding a `notebook.json` manifest, and
|
||||||
|
// routes taps to the matching editor by `NotebookMemberKind`. The open-by-type
|
||||||
|
// routing intentionally MIRRORS `home_screen.dart`'s `_DocumentTile._openDocument`
|
||||||
|
// so a member opens in exactly the editor its extension would on the home
|
||||||
|
// screen (pdf → PenEditorScreen; pptx/docx → OfficeDocumentScreen; legacy ppt →
|
||||||
|
// image-converted PenSlideScreen; note → PenNoteScreen).
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../editor/canvas/office_document_screen.dart';
|
||||||
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/note.dart';
|
||||||
|
import '../providers/document_provider.dart' show vaultServiceProvider;
|
||||||
|
import '../services/pptx_service.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import '../storage/notebook_manifest.dart';
|
||||||
|
|
||||||
|
/// Shows one notebook container's table of contents and lets the user add a
|
||||||
|
/// blank ink page or import a file into it.
|
||||||
|
class NotebookScreen extends ConsumerStatefulWidget {
|
||||||
|
const NotebookScreen({
|
||||||
|
super.key,
|
||||||
|
required this.folderPath,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Absolute path to the notebook container folder (holds `notebook.json`).
|
||||||
|
final String folderPath;
|
||||||
|
|
||||||
|
/// Title shown before the manifest loads (and as a fallback if it has none).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NotebookScreen> createState() => _NotebookScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotebookScreenState extends ConsumerState<NotebookScreen> {
|
||||||
|
NotebookManifest? _manifest;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reload() async {
|
||||||
|
final manifest = await NotebookManifest.read(widget.folderPath);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_manifest = manifest;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final members = _manifest?.members ?? const [];
|
||||||
|
final title = (_manifest?.title.trim().isNotEmpty ?? false)
|
||||||
|
? _manifest!.title.trim()
|
||||||
|
: widget.title;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(title),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.note_add_outlined),
|
||||||
|
tooltip: l.addBlankPage,
|
||||||
|
onPressed: _addBlankPage,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_open),
|
||||||
|
tooltip: l.importIntoNotebook,
|
||||||
|
onPressed: _importFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: members.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
l.notebookMembersEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _reload,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final member = members[index];
|
||||||
|
return _MemberTile(
|
||||||
|
member: member,
|
||||||
|
onTap: () => _openMember(member),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addBlankPage() async {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
await vault.addBlankPageToContainer(widget.folderPath);
|
||||||
|
await _reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importFile() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: VaultService.importableExtensions.toList(),
|
||||||
|
);
|
||||||
|
final pickedPath = result?.files.first.path;
|
||||||
|
if (pickedPath == null || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.processingImport)));
|
||||||
|
try {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
await vault.importFileIntoContainer(widget.folderPath, pickedPath);
|
||||||
|
await _reload();
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route [member] to the correct editor by kind — same switch as
|
||||||
|
/// `home_screen.dart`'s `_DocumentTile._openDocument`.
|
||||||
|
Future<void> _openMember(NotebookMember member) async {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
final absolutePath = vault.memberAbsolutePath(widget.folderPath, member);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (member.kind) {
|
||||||
|
case NotebookMemberKind.note:
|
||||||
|
final now = DateTime.now();
|
||||||
|
final note = Note(
|
||||||
|
id: absolutePath,
|
||||||
|
title: member.title,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.pdf:
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenEditorScreen(pdfPath: absolutePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.pptx:
|
||||||
|
case NotebookMemberKind.docx:
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OfficeDocumentScreen(filePath: absolutePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.ppt:
|
||||||
|
// Legacy binary PPT — same image-fallback path as the home screen.
|
||||||
|
await _openLegacyPpt(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openLegacyPpt(String filePath) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final pptxService = PptxService();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.processingPresentation)),
|
||||||
|
);
|
||||||
|
final slideImages = await pptxService.convertToImages(filePath);
|
||||||
|
final extractedText = await pptxService.extractText(filePath);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (slideImages.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenSlideScreen(
|
||||||
|
filePath: filePath,
|
||||||
|
slideImagePaths: slideImages,
|
||||||
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemberTile extends StatelessWidget {
|
||||||
|
const _MemberTile({required this.member, required this.onTap});
|
||||||
|
|
||||||
|
final NotebookMember member;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(_iconFor(member.kind), color: _colorFor(member.kind)),
|
||||||
|
title: Text(
|
||||||
|
member.title.isEmpty ? 'Untitled' : member.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(_kindLabel(member.kind)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _kindLabel(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => '空白页 · 手写',
|
||||||
|
NotebookMemberKind.pdf => 'PDF · 批注',
|
||||||
|
NotebookMemberKind.pptx => 'PPTX · 幻灯片',
|
||||||
|
NotebookMemberKind.ppt => 'PPT · 幻灯片',
|
||||||
|
NotebookMemberKind.docx => 'DOCX · 文档',
|
||||||
|
};
|
||||||
|
|
||||||
|
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => Icons.edit_note,
|
||||||
|
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
||||||
|
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Icons.slideshow,
|
||||||
|
NotebookMemberKind.docx => Icons.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _colorFor(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => Colors.blue,
|
||||||
|
NotebookMemberKind.pdf => Colors.red,
|
||||||
|
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Colors.orange,
|
||||||
|
NotebookMemberKind.docx => Colors.indigo,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,13 +3,16 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../providers/search_provider.dart';
|
import '../providers/search_provider.dart';
|
||||||
import 'note_editor_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import 'pdf_annotator_screen.dart';
|
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key, this.embeddedInShell = false});
|
||||||
|
|
||||||
|
final bool embeddedInShell;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||||
@@ -42,16 +45,17 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final results = ref.watch(searchResultsProvider);
|
final results = ref.watch(searchResultsProvider);
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search notes and documents...',
|
hintText: l.searchHint,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintStyle: TextStyle(color: Colors.grey),
|
hintStyle: const TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
onChanged: _onQueryChanged,
|
onChanged: _onQueryChanged,
|
||||||
onSubmitted: (value) {
|
onSubmitted: (value) {
|
||||||
@@ -72,14 +76,14 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
),
|
),
|
||||||
body: results.when(
|
body: results.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Search error: $e')),
|
error: (e, _) => Center(child: Text(l.searchError('$e'))),
|
||||||
data: (hits) {
|
data: (hits) {
|
||||||
final query = ref.watch(searchQueryProvider);
|
final query = ref.watch(searchQueryProvider);
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Type to search your notes and documents',
|
l.typeToSearch,
|
||||||
style: TextStyle(color: Colors.grey),
|
style: const TextStyle(color: Colors.grey),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,7 +99,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No results for "$query"',
|
l.noResultsFor(query),
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -114,7 +118,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Notes',
|
l.sectionNotes,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
@@ -129,7 +133,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Documents',
|
l.sectionDocuments,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
@@ -177,7 +181,7 @@ class _NoteSearchResultTile extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +213,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Page ${pageNumber + 1}',
|
AppLocalizations.of(context).pageLabel(pageNumber + 1),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
if (snippet.isNotEmpty)
|
if (snippet.isNotEmpty)
|
||||||
@@ -220,7 +224,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
|
|||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
|
PenEditorScreen(pdfPath: filePath, initialPage: pageNumber),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import '../models/pressure_curve.dart';
|
import '../models/pressure_curve.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../screens/app_shell.dart' show exportDiagnosticPack;
|
||||||
|
import '../services/badnote_server_client.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import '../services/webdav_sync_service.dart';
|
||||||
import '../utils/stroke_stabilizer.dart';
|
import '../utils/stroke_stabilizer.dart';
|
||||||
|
|
||||||
/// Material 3 settings screen for BadNote.
|
/// Material 3 settings screen for BadNote.
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key, this.embeddedInShell = false});
|
||||||
|
|
||||||
|
final bool embeddedInShell;
|
||||||
|
|
||||||
void _showColorPicker(
|
void _showColorPicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@@ -20,8 +29,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Pick a color'),
|
title: Text(l.pickColor),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: ColorPicker(
|
child: ColorPicker(
|
||||||
pickerColor: pickerColor,
|
pickerColor: pickerColor,
|
||||||
@@ -31,14 +41,14 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
child: Text(l.cancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
onPicked(pickerColor);
|
onPicked(pickerColor);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: const Text('OK'),
|
child: Text(l.ok),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -50,25 +60,23 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Clear all local settings?'),
|
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
|
||||||
content: const Text(
|
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
|
||||||
'This will reset pen defaults and appearance settings. '
|
|
||||||
'Notes and documents are not affected.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
child: const Text('Cancel'),
|
child: Text(AppLocalizations.of(ctx).cancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(settingsProvider).clearAllData();
|
ref.read(settingsProvider).clearAllData();
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Settings reset to defaults')),
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context).settingsReset)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Clear'),
|
child: Text(AppLocalizations.of(ctx).clear),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -81,18 +89,34 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: embeddedInShell
|
||||||
|
? AppBar(title: Text(AppLocalizations.of(context).settings))
|
||||||
|
: AppBar(title: Text(AppLocalizations.of(context).settings)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_SectionHeader(title: 'Defaults', icon: Icons.tune),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).diagnosticsSection,
|
||||||
|
icon: Icons.bug_report_outlined,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text(AppLocalizations.of(context).diagnosticsExport),
|
||||||
|
subtitle: Text(AppLocalizations.of(context).diagnosticsExportHint),
|
||||||
|
trailing: const Icon(Icons.ios_share),
|
||||||
|
onTap: () => exportDiagnosticPack(context),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsDefaults,
|
||||||
|
icon: Icons.tune,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Default Tool',
|
AppLocalizations.of(context).settingsDefaultTool,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<PenTool>(
|
DropdownButtonFormField<PenTool>(
|
||||||
@@ -112,9 +136,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Default Color',
|
AppLocalizations.of(context).settingsDefaultColor,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
@@ -147,9 +171,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Default Stroke Width',
|
AppLocalizations.of(context).settingsDefaultWidth,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
value: settings.defaultStrokeWidth,
|
value: settings.defaultStrokeWidth,
|
||||||
@@ -160,9 +184,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
onChanged: settings.setDefaultStrokeWidth,
|
onChanged: settings.setDefaultStrokeWidth,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Pressure Curve',
|
AppLocalizations.of(context).settingsPressureCurve,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
DropdownButtonFormField<PressureCurveType>(
|
DropdownButtonFormField<PressureCurveType>(
|
||||||
@@ -209,33 +233,36 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'Appearance', icon: Icons.palette),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsAppearance,
|
||||||
|
icon: Icons.palette,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Theme Mode',
|
AppLocalizations.of(context).settingsAppearance,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SegmentedButton<ThemeMode>(
|
SegmentedButton<ThemeMode>(
|
||||||
segments: const [
|
segments: [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
label: Text('System'),
|
label: Text(AppLocalizations.of(context).themeSystem),
|
||||||
icon: Icon(Icons.brightness_auto),
|
icon: const Icon(Icons.brightness_auto),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.light,
|
value: ThemeMode.light,
|
||||||
label: Text('Light'),
|
label: Text(AppLocalizations.of(context).themeLight),
|
||||||
icon: Icon(Icons.light_mode),
|
icon: const Icon(Icons.light_mode),
|
||||||
),
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: ThemeMode.dark,
|
value: ThemeMode.dark,
|
||||||
label: Text('Dark'),
|
label: Text(AppLocalizations.of(context).themeDark),
|
||||||
icon: Icon(Icons.dark_mode),
|
icon: const Icon(Icons.dark_mode),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {settings.themeMode},
|
selected: {settings.themeMode},
|
||||||
@@ -244,9 +271,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Color Scheme Seed',
|
AppLocalizations.of(context).seedColorDesc,
|
||||||
style: TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
@@ -272,14 +299,44 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('Seed color for Material 3 theme'),
|
Text(AppLocalizations.of(context).seedColorDesc),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(title: 'About', icon: Icons.info),
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).vaultSection,
|
||||||
|
icon: Icons.folder_special,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: _VaultSettings(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).syncSection,
|
||||||
|
icon: Icons.cloud_sync,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: _SyncSettings(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).serverSection,
|
||||||
|
icon: Icons.dns_outlined,
|
||||||
|
),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: _ServerSettings(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
_SectionHeader(
|
||||||
|
title: AppLocalizations.of(context).settingsAbout,
|
||||||
|
icon: Icons.info,
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -317,6 +374,520 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows the current vault folder path and lets the user re-pick it. Re-uses
|
||||||
|
/// the same `getDirectoryPath` flow as the first-run [VaultSetupScreen],
|
||||||
|
/// persisting the choice through [VaultService.setVaultRoot].
|
||||||
|
class _VaultSettings extends StatefulWidget {
|
||||||
|
const _VaultSettings();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_VaultSettings> createState() => _VaultSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VaultSettingsState extends State<_VaultSettings> {
|
||||||
|
VaultService? _vault;
|
||||||
|
String? _path;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final vault = await VaultService.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_vault = vault;
|
||||||
|
_path = vault.vaultRoot;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeFolder() async {
|
||||||
|
final vault = _vault;
|
||||||
|
if (vault == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final path = await FilePicker.platform.getDirectoryPath(
|
||||||
|
dialogTitle: l.vaultSetupTitle,
|
||||||
|
lockParentWindow: true,
|
||||||
|
);
|
||||||
|
if (path != null) {
|
||||||
|
await vault.setVaultRoot(path);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _path = path);
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.vaultUpdated)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(l.vaultPickFailed(e.toString()))),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.vaultFolderLabel,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
(_path == null || _path!.isEmpty) ? l.vaultNoneSelected : _path!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _busy ? null : _changeFolder,
|
||||||
|
icon: const Icon(Icons.drive_folder_upload),
|
||||||
|
label: Text(l.vaultChangeFolder),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebDAV sync configuration + actions. Persists config through
|
||||||
|
/// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection /
|
||||||
|
/// Sync now buttons, the last-synced time and last result, and an
|
||||||
|
/// "auto-sync on launch" toggle (default OFF). All network ops are
|
||||||
|
/// time-bounded inside the service and surface friendly errors here.
|
||||||
|
class _SyncSettings extends StatefulWidget {
|
||||||
|
const _SyncSettings();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SyncSettings> createState() => _SyncSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SyncSettingsState extends State<_SyncSettings> {
|
||||||
|
WebDavSyncService? _sync;
|
||||||
|
final _urlCtrl = TextEditingController();
|
||||||
|
final _userCtrl = TextEditingController();
|
||||||
|
final _passCtrl = TextEditingController();
|
||||||
|
final _folderCtrl = TextEditingController();
|
||||||
|
bool _autoSync = false;
|
||||||
|
bool _busy = false;
|
||||||
|
bool _testing = false;
|
||||||
|
bool _obscure = true;
|
||||||
|
DateTime? _lastSync;
|
||||||
|
SyncResult? _lastResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlCtrl.dispose();
|
||||||
|
_userCtrl.dispose();
|
||||||
|
_passCtrl.dispose();
|
||||||
|
_folderCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final sync = WebDavSyncService(prefs);
|
||||||
|
if (!mounted) return;
|
||||||
|
final c = sync.config;
|
||||||
|
setState(() {
|
||||||
|
_sync = sync;
|
||||||
|
_urlCtrl.text = c.baseUrl;
|
||||||
|
_userCtrl.text = c.username;
|
||||||
|
_passCtrl.text = c.password;
|
||||||
|
_folderCtrl.text = c.remoteRoot;
|
||||||
|
_autoSync = c.autoSync;
|
||||||
|
_lastSync = sync.lastSyncTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
WebDavConfig _currentConfig() => WebDavConfig(
|
||||||
|
baseUrl: _urlCtrl.text,
|
||||||
|
username: _userCtrl.text,
|
||||||
|
password: _passCtrl.text,
|
||||||
|
remoteRoot: _folderCtrl.text,
|
||||||
|
autoSync: _autoSync,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncSaved)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _testConnection() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
setState(() => _testing = true);
|
||||||
|
final client = sync.buildClient();
|
||||||
|
try {
|
||||||
|
if (client == null) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.testConnection();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk)));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger
|
||||||
|
.showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString()))));
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
if (mounted) setState(() => _testing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNow() async {
|
||||||
|
final sync = _sync;
|
||||||
|
if (sync == null) return;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
await sync.saveConfig(_currentConfig());
|
||||||
|
final vault = await VaultService.getInstance();
|
||||||
|
final root = vault.vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final client = sync.buildClient();
|
||||||
|
try {
|
||||||
|
if (client == null) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await sync.syncNow(vaultRoot: root, client: client);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_lastResult = result;
|
||||||
|
_lastSync = sync.lastSyncTime;
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(result.ok
|
||||||
|
? l.syncResultSummary(
|
||||||
|
result.uploaded, result.downloaded, result.conflicts)
|
||||||
|
: l.syncFailed(result.error ?? '')),
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final configured = _urlCtrl.text.trim().isNotEmpty;
|
||||||
|
final lastResult = _lastResult;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _urlCtrl,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncServerUrl,
|
||||||
|
hintText: l.syncServerUrlHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncUsername,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _passCtrl,
|
||||||
|
obscureText: _obscure,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncPassword,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
|
||||||
|
onPressed: () => setState(() => _obscure = !_obscure),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _folderCtrl,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.syncRemoteFolder,
|
||||||
|
hintText: l.syncRemoteFolderHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l.syncCredentialsNote,
|
||||||
|
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l.syncAuto),
|
||||||
|
value: _autoSync,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() => _autoSync = v);
|
||||||
|
_save();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: (_busy || _testing) ? null : _save,
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: Text(l.syncSave),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: (!configured || _busy || _testing)
|
||||||
|
? null
|
||||||
|
: _testConnection,
|
||||||
|
icon: _testing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.wifi_tethering),
|
||||||
|
label: Text(l.syncTestConnection),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: (!configured || _busy || _testing) ? null : _syncNow,
|
||||||
|
icon: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
label: Text(_busy ? l.syncRunning : l.syncNow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_lastSync == null
|
||||||
|
? l.syncNeverRun
|
||||||
|
: l.syncLastRun(_lastSync!.toLocal().toString()),
|
||||||
|
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
if (lastResult != null && lastResult.ok) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l.syncResultSummary(lastResult.uploaded, lastResult.downloaded,
|
||||||
|
lastResult.conflicts),
|
||||||
|
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerSettings extends StatefulWidget {
|
||||||
|
const _ServerSettings();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ServerSettings> createState() => _ServerSettingsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerSettingsState extends State<_ServerSettings> {
|
||||||
|
final _urlCtrl = TextEditingController();
|
||||||
|
final _userCtrl = TextEditingController();
|
||||||
|
final _passCtrl = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
bool _loggedIn = false;
|
||||||
|
String? _status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlCtrl.dispose();
|
||||||
|
_userCtrl.dispose();
|
||||||
|
_passCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cfg = await BadNoteServerConfig.load(prefs);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_urlCtrl.text = cfg.baseUrl;
|
||||||
|
_userCtrl.text = cfg.username;
|
||||||
|
_passCtrl.text = cfg.password;
|
||||||
|
_loggedIn = cfg.isLoggedIn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAndLogin() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
var cfg = BadNoteServerConfig(
|
||||||
|
baseUrl: _urlCtrl.text.trim(),
|
||||||
|
username: _userCtrl.text.trim(),
|
||||||
|
password: _passCtrl.text,
|
||||||
|
);
|
||||||
|
final client = BadNoteServerClient(cfg);
|
||||||
|
cfg = await client.registerOrLogin(
|
||||||
|
username: cfg.username,
|
||||||
|
password: cfg.password,
|
||||||
|
);
|
||||||
|
await cfg.save(prefs);
|
||||||
|
client.close();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loggedIn = true;
|
||||||
|
_status = l.serverLoggedIn;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _status = l.serverTestFail('$e'));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _test() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim());
|
||||||
|
final client = BadNoteServerClient(cfg);
|
||||||
|
final health = await client.health();
|
||||||
|
client.close();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_status = l.serverTestOk('${health['version'] ?? health['api']}');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _status = l.serverTestFail('$e'));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.serverHint,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _urlCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverUrl,
|
||||||
|
hintText: l.serverUrlHint,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverUsername,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _passCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.serverPassword,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _busy ? null : _saveAndLogin,
|
||||||
|
child: Text(l.serverSave),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _busy ? null : _test,
|
||||||
|
child: Text(l.serverTest),
|
||||||
|
),
|
||||||
|
if (_loggedIn)
|
||||||
|
Chip(
|
||||||
|
avatar: const Icon(Icons.check_circle, size: 16),
|
||||||
|
label: Text(l.serverLoggedIn),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_status != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_status!, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
|
// lib/screens/split_view_screen.dart
|
||||||
|
//
|
||||||
|
// Anchor-keyed split view: opened by tapping a PDF-anchored scratch link.
|
||||||
|
// LEFT pane = the source PDF (pdfrx PdfViewer, read-only reference + page
|
||||||
|
// nav), opened at the anchor's page.
|
||||||
|
// RIGHT pane = an INFINITE freehand scratchpad that BELONGS TO THE ANCHOR,
|
||||||
|
// keyed by [scratchLinkId] (not the documentId). Each anchor has
|
||||||
|
// its own private scratch space, persisted via the existing
|
||||||
|
// scratchpad storage (InkStroke JSON, format unchanged).
|
||||||
|
//
|
||||||
|
// The right pane reuses the pen-first PenCanvas world-coord engine and the
|
||||||
|
// Material You brush palette shared with pen_note_screen (BrushPickerButton /
|
||||||
|
// ToolButton / color dots), replacing the old AnnotationToolbar.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../editor/canvas/pen_canvas.dart';
|
||||||
|
import '../editor/canvas/pen_palette_widgets.dart';
|
||||||
|
import '../editor/canvas/pen_stroke.dart';
|
||||||
|
import '../editor/engine/brush.dart';
|
||||||
|
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
|
||||||
|
import '../editor/notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../editor/persistence/sidecar_repository.dart';
|
||||||
import '../models/ink_stroke.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 '../services/undo_manager.dart';
|
||||||
import '../utils/stroke_stabilizer.dart';
|
import '../storage/badnote_sidecar.dart';
|
||||||
import '../widgets/annotation_toolbar.dart';
|
|
||||||
import '../widgets/ink_canvas.dart';
|
|
||||||
|
|
||||||
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
|
/// Split-view derivation surface for a single scratch-link anchor: left pane =
|
||||||
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
/// the reference PDF (at [initialPage]), right pane = the anchor's private
|
||||||
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
/// infinite scratchpad. Scratchpad strokes persist inside the source file's
|
||||||
|
/// SIDECAR (`<filePath>.badnote.json`), embedded in the anchor's
|
||||||
|
/// `scratchLinks[id].scratchpad` (keyed by [scratchLinkId]). Strokes keep the
|
||||||
|
/// absolute world-pixel [InkStroke] format unchanged.
|
||||||
class SplitViewScreen extends StatefulWidget {
|
class SplitViewScreen extends StatefulWidget {
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String documentId;
|
|
||||||
|
/// The owning anchor id. Selects which `scratchLinks[].scratchpad` in the
|
||||||
|
/// sidecar this is the private scratch space for.
|
||||||
|
final String scratchLinkId;
|
||||||
|
|
||||||
|
/// 0-based page the anchor sits on; the left PDF opens here.
|
||||||
|
final int initialPage;
|
||||||
|
|
||||||
const SplitViewScreen({
|
const SplitViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.documentId,
|
required this.scratchLinkId,
|
||||||
|
this.initialPage = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,34 +59,46 @@ class SplitViewScreen extends StatefulWidget {
|
|||||||
class _SplitViewState extends State<SplitViewScreen> {
|
class _SplitViewState extends State<SplitViewScreen> {
|
||||||
// -- PDF (left pane) --
|
// -- PDF (left pane) --
|
||||||
final PdfViewerController _pdfController = PdfViewerController();
|
final PdfViewerController _pdfController = PdfViewerController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0; // 0-based
|
||||||
int _pageCount = 0;
|
int _pageCount = 0;
|
||||||
String _fileName = '';
|
|
||||||
|
|
||||||
// -- Split divider --
|
// -- Split divider --
|
||||||
double _leftPaneFraction = 0.5;
|
double _leftPaneFraction = 0.5;
|
||||||
bool _isDraggingDivider = false;
|
bool _isDraggingDivider = false;
|
||||||
|
|
||||||
// -- Scratchpad (right pane) --
|
// -- Scratchpad (right pane) --
|
||||||
|
// Infinite WORLD: strokes stored in absolute world pixels ([InkStroke],
|
||||||
|
// unchanged persistence format), rendered through PenCanvas by normalizing
|
||||||
|
// against the CURRENT world size. World auto-expands without moving ink.
|
||||||
final UndoManager _undoManager = UndoManager();
|
final UndoManager _undoManager = UndoManager();
|
||||||
List<InkStroke> _strokes = [];
|
List<InkStroke> _strokes = [];
|
||||||
double _canvasWidth = 4000;
|
double _canvasWidth = 4000;
|
||||||
double _canvasHeight = 4000;
|
double _canvasHeight = 4000;
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
// -- Tool state --
|
final TransformationController _scratchTransform = TransformationController();
|
||||||
PenTool _currentTool = PenTool.pen;
|
bool _scratchCentered = false;
|
||||||
Color _currentColor = Colors.black;
|
|
||||||
double _currentStrokeWidth = 2.0;
|
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
|
||||||
bool _filled = false;
|
|
||||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
// -- Tool state (new Material You brush palette) --
|
||||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
CanvasTool _tool = CanvasTool.pen;
|
||||||
|
BrushKind _penBrush = BrushKind.fountainPen;
|
||||||
|
Color _color = Colors.black;
|
||||||
|
|
||||||
|
static const double _penWidthFraction = 0.006;
|
||||||
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
|
static const List<Color> _palette = kInkPalette;
|
||||||
|
|
||||||
// -- Auto-save debounce --
|
// -- Auto-save debounce --
|
||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
// -- Page link markers (optional feature) --
|
/// Sidecar persistence for the source file (the scratchpad is embedded in the
|
||||||
final List<_PageLink> _pageLinks = [];
|
/// anchor's `scratchLinks[id].scratchpad`). Null until [_loadScratchpad]
|
||||||
|
/// resolves.
|
||||||
|
SidecarRepository? _repo;
|
||||||
|
|
||||||
static const double _edgeThreshold = 200.0;
|
static const double _edgeThreshold = 200.0;
|
||||||
static const double _expandAmount = 1000.0;
|
static const double _expandAmount = 1000.0;
|
||||||
@@ -69,30 +106,52 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_currentPage = widget.initialPage;
|
||||||
_loadScratchpad();
|
_loadScratchpad();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
_saveImmediate();
|
// Schedule a final flush; retain-counted repo may still be held by the
|
||||||
_pdfController.dispose();
|
// PDF editor, so dispose only drops our retain.
|
||||||
|
if (_dirty) {
|
||||||
|
unawaited(_saveImmediate());
|
||||||
|
} else {
|
||||||
|
unawaited(_repo?.flush() ?? Future<void>.value());
|
||||||
|
}
|
||||||
|
_repo?.dispose();
|
||||||
|
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
||||||
|
_scratchTransform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Persistence --
|
// -- Persistence (sidecar's scratchLinks[id].scratchpad, keyed by anchor id) --
|
||||||
|
|
||||||
Future<void> _loadScratchpad() async {
|
Future<void> _loadScratchpad() async {
|
||||||
final db = await DatabaseService.getInstance();
|
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
|
||||||
final strokes = await db.loadScratchpad(widget.documentId);
|
if (!mounted) {
|
||||||
if (mounted) {
|
repo.dispose();
|
||||||
setState(() {
|
return;
|
||||||
_strokes = strokes;
|
|
||||||
for (final s in strokes) {
|
|
||||||
_undoManager.addStroke(s);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
_repo = repo;
|
||||||
|
final pad = repo.scratchpadFor(widget.scratchLinkId);
|
||||||
|
setState(() {
|
||||||
|
if (pad != null) {
|
||||||
|
// Restore the world size so the infinite canvas reopens at its grown
|
||||||
|
// extent (previously always reset to 4000×4000).
|
||||||
|
_canvasWidth = pad.canvasWidth;
|
||||||
|
_canvasHeight = pad.canvasHeight;
|
||||||
|
}
|
||||||
|
final strokes = pad?.strokes ?? const <InkStroke>[];
|
||||||
|
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
|
||||||
|
// manager (shapes/text have no pen-canvas representation).
|
||||||
|
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
|
_strokes = freehand;
|
||||||
|
for (final s in freehand) {
|
||||||
|
_undoManager.addStroke(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleSave() {
|
void _scheduleSave() {
|
||||||
@@ -103,15 +162,26 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
|
|
||||||
Future<void> _saveImmediate() async {
|
Future<void> _saveImmediate() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
|
final repo = _repo;
|
||||||
|
if (repo == null) return;
|
||||||
|
// Keep dirty until schedule succeeds so a race during load can't swallow ink.
|
||||||
|
repo.scheduleScratchpadSave(
|
||||||
|
widget.scratchLinkId,
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: _canvasWidth,
|
||||||
|
canvasHeight: _canvasHeight,
|
||||||
|
strokes: List<InkStroke>.of(_strokes),
|
||||||
|
),
|
||||||
|
);
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
final db = await DatabaseService.getInstance();
|
await repo.flush();
|
||||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
|
||||||
await db.saveScratchpad(widget.documentId, json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Scratchpad stroke callbacks --
|
// -- Scratchpad stroke callbacks --
|
||||||
|
|
||||||
void _onStrokeComplete(InkStroke stroke) {
|
void _onStrokeComplete(PenStroke pen) {
|
||||||
|
final stroke = inkStrokeFromPen(pen, _worldSize,
|
||||||
|
id: _uuid.v4(), createdAt: DateTime.now());
|
||||||
setState(() {
|
setState(() {
|
||||||
_strokes.add(stroke);
|
_strokes.add(stroke);
|
||||||
_undoManager.addStroke(stroke);
|
_undoManager.addStroke(stroke);
|
||||||
@@ -120,13 +190,17 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
_scheduleSave();
|
_scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
void _onErase(int index, List<PenStroke> replacements) {
|
||||||
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
|
final original = _strokes[index];
|
||||||
if (original != null) {
|
final inkReplacements = [
|
||||||
_undoManager.removeStroke(original, replacements: replacements);
|
for (final r in replacements)
|
||||||
_strokes = List.from(_undoManager.currentStrokes);
|
inkStrokeFromPen(r, _worldSize,
|
||||||
}
|
id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
];
|
||||||
|
_undoManager.removeStroke(original, replacements: inkReplacements);
|
||||||
|
_strokes = List.from(_undoManager.currentStrokes);
|
||||||
});
|
});
|
||||||
_scheduleSave();
|
_scheduleSave();
|
||||||
}
|
}
|
||||||
@@ -190,71 +264,40 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
setState(() => _isDraggingDivider = false);
|
setState(() => _isDraggingDivider = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- PDF page navigation --
|
// -- PDF page navigation (left pane) --
|
||||||
|
|
||||||
void _prevPage() {
|
void _prevPage() {
|
||||||
if (_currentPage > 0) {
|
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
|
||||||
_pdfController.previousPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
if (_currentPage < _pageCount - 1) {
|
if (_currentPage < _pageCount - 1) {
|
||||||
_pdfController.nextPage();
|
_pdfController.goToPage(pageNumber: _currentPage + 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Page link creation (long-press on left pane) --
|
CanvasTool get _activeTool => _tool;
|
||||||
|
|
||||||
void _onPdfLongPress(int pageNumber) {
|
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||||
// Place a page link marker at the current scratchpad viewport center.
|
? _highlighterWidthFraction
|
||||||
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
: _penWidthFraction;
|
||||||
// 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 --
|
// -- Build --
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
|
||||||
_fileName.isEmpty ? 'Split View' : _fileName,
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
_saveImmediate();
|
await _saveImmediate();
|
||||||
Navigator.of(context).pop();
|
if (context.mounted) Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
// Left pane page navigation
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.navigate_before),
|
icon: const Icon(Icons.navigate_before),
|
||||||
tooltip: 'Previous page (PDF)',
|
tooltip: 'Previous page (PDF)',
|
||||||
@@ -275,7 +318,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Canvas info
|
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message:
|
message:
|
||||||
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
||||||
@@ -293,7 +335,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Label clarifying that the toolbar controls the scratchpad pane.
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -301,47 +342,22 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'Scratchpad tools',
|
'Scratchpad tools',
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Toolbar (applies to scratchpad only)
|
_buildBrushPalette(cs),
|
||||||
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(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final totalWidth = constraints.maxWidth;
|
final totalWidth = constraints.maxWidth;
|
||||||
final leftWidth = totalWidth * _leftPaneFraction;
|
final leftWidth = totalWidth * _leftPaneFraction;
|
||||||
final rightWidth =
|
final rightWidth = totalWidth - leftWidth - 12;
|
||||||
totalWidth - leftWidth - 12; // 12px divider hit area
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Left pane: PDF reference (read-only)
|
|
||||||
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
||||||
// Draggable divider: 12px hit area, 4px visual strip.
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onHorizontalDragStart: _onDividerDragStart,
|
onHorizontalDragStart: _onDividerDragStart,
|
||||||
onHorizontalDragUpdate: (d) =>
|
onHorizontalDragUpdate: (d) =>
|
||||||
@@ -362,7 +378,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Right pane: Infinite scratchpad
|
|
||||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -374,96 +389,188 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPdfPane() {
|
/// Material You brush palette (shared chrome with the pen-first editors):
|
||||||
return Stack(
|
/// brush picker + highlighter + eraser, undo/redo, color dots.
|
||||||
children: [
|
Widget _buildBrushPalette(ColorScheme cs) {
|
||||||
GestureDetector(
|
return Material(
|
||||||
onLongPress: () {
|
color: cs.surfaceContainerHigh,
|
||||||
// Long-press on PDF to create page link marker
|
elevation: 3,
|
||||||
_onPdfLongPress(_currentPage + 1);
|
borderRadius: BorderRadius.circular(28),
|
||||||
},
|
child: Padding(
|
||||||
child: SfPdfViewer.file(
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
File(widget.filePath),
|
child: SingleChildScrollView(
|
||||||
controller: _pdfController,
|
scrollDirection: Axis.horizontal,
|
||||||
canShowScrollHead: true,
|
child: Row(
|
||||||
canShowScrollStatus: true,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPageChanged: (PdfPageChangedDetails details) {
|
children: [
|
||||||
setState(() {
|
BrushPickerButton(
|
||||||
_currentPage = details.newPageNumber - 1;
|
selected: _penBrush,
|
||||||
});
|
active: _tool == CanvasTool.pen,
|
||||||
},
|
tooltip: 'Brush',
|
||||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
labelFor: brushLabelEn,
|
||||||
setState(() {
|
// The scratchpad keeps a single shared color (no per-brush color
|
||||||
_pageCount = details.document.pages.count;
|
// memory in this surface); show it for every brush.
|
||||||
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
colorFor: (_) => _color,
|
||||||
});
|
onSelected: (b) => setState(() {
|
||||||
},
|
_penBrush = b;
|
||||||
),
|
_tool = CanvasTool.pen;
|
||||||
),
|
}),
|
||||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
),
|
||||||
if (_pageLinks.isNotEmpty)
|
ToolButton(
|
||||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
icon: Icons.brush_outlined,
|
||||||
],
|
selected: _tool == CanvasTool.highlighter,
|
||||||
);
|
tooltip: 'Highlighter',
|
||||||
}
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||||
|
),
|
||||||
Widget _buildPageLinkChips() {
|
ToolButton(
|
||||||
return Wrap(
|
icon: Icons.cleaning_services_outlined,
|
||||||
spacing: 4,
|
selected: _tool == CanvasTool.eraser,
|
||||||
runSpacing: 4,
|
tooltip: 'Eraser',
|
||||||
children: _pageLinks.map((link) {
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
return GestureDetector(
|
),
|
||||||
onTap: () => _onPageLinkTap(link),
|
PaletteDivider(cs: cs),
|
||||||
onLongPress: () => _deletePageLink(link),
|
ToolButton(
|
||||||
child: Chip(
|
icon: Icons.undo,
|
||||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
selected: false,
|
||||||
label: Text(
|
tooltip: 'Undo',
|
||||||
'p${link.pageNumber}',
|
onPressed: _undoManager.canUndo ? _undo : null,
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
),
|
||||||
),
|
ToolButton(
|
||||||
backgroundColor: Colors.blue.shade600,
|
icon: Icons.redo,
|
||||||
padding: EdgeInsets.zero,
|
selected: false,
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tooltip: 'Redo',
|
||||||
visualDensity: VisualDensity.compact,
|
onPressed: _undoManager.canRedo ? _redo : null,
|
||||||
),
|
),
|
||||||
);
|
PaletteDivider(cs: cs),
|
||||||
}).toList(),
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
);
|
],
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
class _PageLink {
|
final selected =
|
||||||
final int pageNumber;
|
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||||
final Offset position;
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_color = c;
|
||||||
|
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||||
|
}),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? cs.onSurface : cs.outlineVariant,
|
||||||
|
width: selected ? 3 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const _PageLink({required this.pageNumber, required this.position});
|
Widget _buildPdfPane() {
|
||||||
|
// Read-only reference PDF on the same engine (pdfrx) as the rest of the
|
||||||
|
// app, opened at the anchor's page.
|
||||||
|
return PdfViewer.file(
|
||||||
|
widget.filePath,
|
||||||
|
controller: _pdfController,
|
||||||
|
params: PdfViewerParams(
|
||||||
|
onViewerReady: (document, controller) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pageCount = document.pages.length;
|
||||||
|
});
|
||||||
|
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
||||||
|
if (target > 0) {
|
||||||
|
controller.goToPage(pageNumber: target + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageChanged: (pageNumber) {
|
||||||
|
if (pageNumber == null || !mounted) return;
|
||||||
|
final idx = pageNumber - 1;
|
||||||
|
if (idx != _currentPage) setState(() => _currentPage = idx);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScratchpadPane() {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (!_scratchCentered) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_frameScratchpad(
|
||||||
|
Size(constraints.maxWidth, constraints.maxHeight));
|
||||||
|
setState(() => _scratchCentered = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: PenCanvas(
|
||||||
|
pageSize: _worldSize,
|
||||||
|
strokes: penStrokesFromInk(_strokes, _worldSize),
|
||||||
|
transformationController: _scratchTransform,
|
||||||
|
tool: _activeTool,
|
||||||
|
brush: _penBrush,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: _strokeWidth,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
|
minScale: 0.1,
|
||||||
|
maxScale: 8.0,
|
||||||
|
onStrokeComplete: _onStrokeComplete,
|
||||||
|
onEraseStroke: _onErase,
|
||||||
|
pageWidget: const ColoredBox(color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position the scratchpad so existing ink is on-screen (fit its bbox into
|
||||||
|
/// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1.
|
||||||
|
void _frameScratchpad(Size pane) {
|
||||||
|
if (pane.isEmpty) return;
|
||||||
|
if (_strokes.isEmpty) {
|
||||||
|
_scratchTransform.value = Matrix4.identity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double minX = double.infinity, minY = double.infinity;
|
||||||
|
double maxX = -double.infinity, maxY = -double.infinity;
|
||||||
|
for (final s in _strokes) {
|
||||||
|
for (final p in s.points) {
|
||||||
|
if (p.x < minX) minX = p.x;
|
||||||
|
if (p.y < minY) minY = p.y;
|
||||||
|
if (p.x > maxX) maxX = p.x;
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minX > maxX) {
|
||||||
|
_scratchTransform.value = Matrix4.identity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pad = 80.0;
|
||||||
|
final boxW = (maxX - minX) + pad * 2;
|
||||||
|
final boxH = (maxY - minY) + pad * 2;
|
||||||
|
final scale = (pane.width / boxW < pane.height / boxH
|
||||||
|
? pane.width / boxW
|
||||||
|
: pane.height / boxH)
|
||||||
|
.clamp(0.15, 1.5);
|
||||||
|
final cx = (minX + maxX) / 2;
|
||||||
|
final cy = (minY + maxY) / 2;
|
||||||
|
final tx = pane.width / 2 - scale * cx;
|
||||||
|
final ty = pane.height / 2 - scale * cy;
|
||||||
|
_scratchTransform.value = Matrix4.identity()
|
||||||
|
..setEntry(0, 0, scale)
|
||||||
|
..setEntry(1, 1, scale)
|
||||||
|
..setEntry(2, 2, scale)
|
||||||
|
..setTranslationRaw(tx, ty, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
lib/screens/vault_setup_screen.dart
Normal file
158
lib/screens/vault_setup_screen.dart
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
|
||||||
|
/// First-run (and re-prompt) gate that asks the user to pick a vault root
|
||||||
|
/// folder — the single folder under which all notebooks will live, like an
|
||||||
|
/// Obsidian vault.
|
||||||
|
///
|
||||||
|
/// On a successful, writable selection the chosen path is persisted via
|
||||||
|
/// [VaultService] and [onVaultReady] is invoked so the host can proceed to the
|
||||||
|
/// home screen. When [missing] is true the screen shows a "your vault folder is
|
||||||
|
/// missing" message instead of the first-run copy (the saved folder no longer
|
||||||
|
/// exists).
|
||||||
|
class VaultSetupScreen extends StatefulWidget {
|
||||||
|
const VaultSetupScreen({
|
||||||
|
super.key,
|
||||||
|
required this.vaultService,
|
||||||
|
required this.onVaultReady,
|
||||||
|
this.missing = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VaultService vaultService;
|
||||||
|
|
||||||
|
/// Called after a valid, writable vault root has been persisted.
|
||||||
|
final VoidCallback onVaultReady;
|
||||||
|
|
||||||
|
/// Whether a previously-chosen folder went missing (changes the copy).
|
||||||
|
final bool missing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VaultSetupScreen> createState() => _VaultSetupScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VaultSetupScreenState extends State<VaultSetupScreen> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _pickFolder() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
setState(() {
|
||||||
|
_busy = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// getDirectoryPath is Desktop/Windows supported by file_picker.
|
||||||
|
// lockParentWindow makes the native Windows dialog modal.
|
||||||
|
final path = await FilePicker.platform.getDirectoryPath(
|
||||||
|
dialogTitle: l.vaultSetupTitle,
|
||||||
|
lockParentWindow: true,
|
||||||
|
);
|
||||||
|
if (path == null) {
|
||||||
|
// User cancelled the native dialog.
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await _isWritable(path)) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = l.vaultNotWritable;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await widget.vaultService.setVaultRoot(path);
|
||||||
|
if (mounted) widget.onVaultReady();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_busy = false;
|
||||||
|
_error = l.vaultPickFailed(e.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe writability by creating and deleting a temp file in [path]. The
|
||||||
|
/// native folder dialog can hand back a read-only location on Windows, so we
|
||||||
|
/// validate before committing it as the vault.
|
||||||
|
Future<bool> _isWritable(String path) async {
|
||||||
|
final probe = File(p.join(path, '.badnote-write-probe'));
|
||||||
|
try {
|
||||||
|
await probe.writeAsString('ok', flush: true);
|
||||||
|
await probe.delete();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
widget.missing
|
||||||
|
? Icons.folder_off_outlined
|
||||||
|
: Icons.folder_special_outlined,
|
||||||
|
size: 56,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
widget.missing ? l.vaultMissingTitle : l.vaultSetupHeadline,
|
||||||
|
style: textTheme.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.missing ? l.vaultMissingBody : l.vaultSetupBody,
|
||||||
|
style: textTheme.bodyMedium
|
||||||
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: textTheme.bodyMedium
|
||||||
|
?.copyWith(color: colorScheme.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _busy ? null : _pickFolder,
|
||||||
|
icon: _busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.folder_open),
|
||||||
|
label: Text(l.vaultChooseFolder),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
lib/services/badnote_server_client.dart
Normal file
139
lib/services/badnote_server_client.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
|
||||||
|
// WebDAV remains the primary NAS sync path; this client covers health, auth,
|
||||||
|
// vault manifest assist, and OCR job submit/poll.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BadNoteServerConfig {
|
||||||
|
const BadNoteServerConfig({
|
||||||
|
this.baseUrl = '',
|
||||||
|
this.username = '',
|
||||||
|
this.password = '',
|
||||||
|
this.token = '',
|
||||||
|
this.userId = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String baseUrl;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
final String token;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||||
|
bool get isLoggedIn => token.isNotEmpty;
|
||||||
|
|
||||||
|
Uri? uri(String path) {
|
||||||
|
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||||
|
if (base.isEmpty) return null;
|
||||||
|
final p = path.startsWith('/') ? path : '/$path';
|
||||||
|
return Uri.parse('$base$p');
|
||||||
|
}
|
||||||
|
|
||||||
|
BadNoteServerConfig copyWith({
|
||||||
|
String? baseUrl,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String? token,
|
||||||
|
String? userId,
|
||||||
|
}) =>
|
||||||
|
BadNoteServerConfig(
|
||||||
|
baseUrl: baseUrl ?? this.baseUrl,
|
||||||
|
username: username ?? this.username,
|
||||||
|
password: password ?? this.password,
|
||||||
|
token: token ?? this.token,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _kBase = 'badnote.server.baseUrl';
|
||||||
|
static const _kUser = 'badnote.server.username';
|
||||||
|
static const _kPass = 'badnote.server.password';
|
||||||
|
static const _kToken = 'badnote.server.token';
|
||||||
|
static const _kUid = 'badnote.server.userId';
|
||||||
|
|
||||||
|
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
|
||||||
|
return BadNoteServerConfig(
|
||||||
|
baseUrl: prefs.getString(_kBase) ?? '',
|
||||||
|
username: prefs.getString(_kUser) ?? '',
|
||||||
|
password: prefs.getString(_kPass) ?? '',
|
||||||
|
token: prefs.getString(_kToken) ?? '',
|
||||||
|
userId: prefs.getString(_kUid) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> save(SharedPreferences prefs) async {
|
||||||
|
await prefs.setString(_kBase, baseUrl);
|
||||||
|
await prefs.setString(_kUser, username);
|
||||||
|
await prefs.setString(_kPass, password);
|
||||||
|
await prefs.setString(_kToken, token);
|
||||||
|
await prefs.setString(_kUid, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadNoteServerClient {
|
||||||
|
BadNoteServerClient(this.config, {http.Client? httpClient})
|
||||||
|
: _http = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
BadNoteServerConfig config;
|
||||||
|
final http.Client _http;
|
||||||
|
|
||||||
|
Map<String, String> get _authHeaders => {
|
||||||
|
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> health() async {
|
||||||
|
final uri = config.uri('/api/v1/health');
|
||||||
|
if (uri == null) throw StateError('server URL not set');
|
||||||
|
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('health ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BadNoteServerConfig> registerOrLogin({
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final registerUri = config.uri('/api/v1/auth/register');
|
||||||
|
if (registerUri == null) throw StateError('server URL not set');
|
||||||
|
var res = await _http.post(
|
||||||
|
registerUri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'username': username, 'password': password}),
|
||||||
|
);
|
||||||
|
if (res.statusCode == 409) {
|
||||||
|
final loginUri = config.uri('/api/v1/auth/login')!;
|
||||||
|
res = await _http.post(
|
||||||
|
loginUri,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'username': username, 'password': password}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.statusCode != 200 && res.statusCode != 201) {
|
||||||
|
throw StateError('auth ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
config = config.copyWith(
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
token: body['token'] as String? ?? '',
|
||||||
|
userId: body['user_id'] as String? ?? '',
|
||||||
|
);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> vaultManifest() async {
|
||||||
|
final uri = config.uri('/api/v1/vault/manifest');
|
||||||
|
if (uri == null) throw StateError('server URL not set');
|
||||||
|
final res = await _http.get(uri, headers: _authHeaders);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw StateError('manifest ${res.statusCode}: ${res.body}');
|
||||||
|
}
|
||||||
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() => _http.close();
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui' show Offset, Size;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../editor/board/board.dart';
|
||||||
|
import '../editor/engine/stroke_model.dart';
|
||||||
import '../models/bookmark.dart';
|
import '../models/bookmark.dart';
|
||||||
import '../models/document.dart' as doc;
|
import '../models/document.dart' as doc;
|
||||||
import '../models/ink_point.dart';
|
import '../models/ink_point.dart';
|
||||||
@@ -13,6 +18,8 @@ import '../models/ink_stroke.dart';
|
|||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import '../models/pointer_device_kind.dart';
|
import '../models/pointer_device_kind.dart';
|
||||||
|
import '../models/scratch_link.dart';
|
||||||
|
import 'vault_service.dart';
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
static DatabaseService? _instance;
|
static DatabaseService? _instance;
|
||||||
@@ -28,25 +35,83 @@ class DatabaseService {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only: drop the cached singleton so the next [getInstance] re-opens a
|
||||||
|
/// fresh database (e.g. after pointing PathProviderPlatform at a new temp
|
||||||
|
/// dir). Closes the current handle if one is open.
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<void> resetForTest() async {
|
||||||
|
final existing = _instance;
|
||||||
|
_instance = null;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing._database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Database get database => _database;
|
Database get database => _database;
|
||||||
|
|
||||||
|
/// Re-resolve the DB location and reopen the singleton there. Called once the
|
||||||
|
/// vault root becomes valid at startup so the live database moves from the
|
||||||
|
/// legacy app-documents `badnote.db` to the vault cache
|
||||||
|
/// `<vault>/.badnote/index.sqlite` (§A.1). No-op-safe: if the resolved path is
|
||||||
|
/// unchanged it simply reopens the same file. Closes the previous handle.
|
||||||
|
static Future<DatabaseService> reopen() async {
|
||||||
|
final existing = _instance;
|
||||||
|
if (existing != null) {
|
||||||
|
await existing._database.close();
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
return getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _initialize() async {
|
Future<void> _initialize() async {
|
||||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||||
sqfliteFfiInit();
|
sqfliteFfiInit();
|
||||||
databaseFactory = databaseFactoryFfi;
|
databaseFactory = databaseFactoryFfi;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dbPath = await _resolveDbPath();
|
||||||
final dbPath = p.join(dir.path, 'badnote.db');
|
// Ensure the parent dir exists (the vault's hidden `.badnote/` cache dir is
|
||||||
|
// not guaranteed to exist yet on first run).
|
||||||
|
await Directory(p.dirname(dbPath)).create(recursive: true);
|
||||||
|
|
||||||
_database = await openDatabase(
|
_database = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 6,
|
version: 8,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The application-documents path of the LEGACY (pre-vault) database. This is
|
||||||
|
/// the location [DatabaseService] used before the file-based re-architecture;
|
||||||
|
/// the one-time migrator reads from here, then renames it to `.premigration`.
|
||||||
|
static Future<String> legacyDbPath() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
return p.join(dir.path, 'badnote.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve where the live database should live. When a valid vault root is
|
||||||
|
/// set, the DB is the vault's rebuildable cache/index at
|
||||||
|
/// `<vault>/.badnote/index.sqlite` (§A.1). Otherwise (no vault yet — e.g. a
|
||||||
|
/// fresh first run before the gate, or tests) fall back to the legacy
|
||||||
|
/// app-documents `badnote.db` so the app still works.
|
||||||
|
Future<String> _resolveDbPath() async {
|
||||||
|
String? root;
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
root = prefs.getString(VaultService.vaultRootKey);
|
||||||
|
} catch (_) {
|
||||||
|
// SharedPreferences may be unavailable (e.g. a unit test that mocks only
|
||||||
|
// the path provider). Fall back to the legacy app-documents location so
|
||||||
|
// the DB still opens — it is never the source of truth anyway.
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (root != null && root.isNotEmpty && await Directory(root).exists()) {
|
||||||
|
return p.join(root, '.badnote', 'index.sqlite');
|
||||||
|
}
|
||||||
|
return legacyDbPath();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
// Core tables (original v1)
|
// Core tables (original v1)
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
@@ -184,6 +249,53 @@ class DatabaseService {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
// Sticky-note board cards (v7): F7 双链 + 无限便利贴.
|
||||||
|
await _createBoardCardsTable(db);
|
||||||
|
|
||||||
|
// PDF-anchored scratch links (v8).
|
||||||
|
await _createScratchLinksTable(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PDF-anchored scratch links table (v8). One row per [ScratchLink] anchor.
|
||||||
|
/// The anchor [id] doubles as the storage key for its private scratchpad
|
||||||
|
/// (reused from the [scratchpads] table — see [saveScratchpad]).
|
||||||
|
Future<void> _createScratchLinksTable(DatabaseExecutor db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE scratch_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL,
|
||||||
|
page_index INTEGER NOT NULL,
|
||||||
|
nx REAL NOT NULL,
|
||||||
|
ny REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_scratch_links_doc ON scratch_links(document_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the
|
||||||
|
/// set of rows sharing a [board_id]. Geometry is stored as plain columns
|
||||||
|
/// (rows, not a blob) so a board round-trips and could be queried later.
|
||||||
|
Future<void> _createBoardCardsTable(DatabaseExecutor db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE board_cards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
board_id TEXT NOT NULL,
|
||||||
|
x REAL NOT NULL,
|
||||||
|
y REAL NOT NULL,
|
||||||
|
w REAL NOT NULL,
|
||||||
|
h REAL NOT NULL,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
ordinal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_board_cards_board ON board_cards(board_id)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
@@ -191,6 +303,20 @@ class DatabaseService {
|
|||||||
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
||||||
if (oldVersion < 5) await _migrateV4toV5(db);
|
if (oldVersion < 5) await _migrateV4toV5(db);
|
||||||
if (oldVersion < 6) await _migrateV5toV6(db);
|
if (oldVersion < 6) await _migrateV5toV6(db);
|
||||||
|
if (oldVersion < 7) await _migrateV6toV7(db);
|
||||||
|
if (oldVersion < 8) await _migrateV7toV8(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateV7toV8(Database db) async {
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await _createScratchLinksTable(txn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateV6toV7(Database db) async {
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await _createBoardCardsTable(txn);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateV5toV6(Database db) async {
|
Future<void> _migrateV5toV6(Database db) async {
|
||||||
@@ -897,4 +1023,285 @@ class DatabaseService {
|
|||||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Board cards CRUD (F7 双链 + 无限便利贴) ──────────────────────────
|
||||||
|
|
||||||
|
/// Replace ALL cards for [boardId] with [cards]. Geometry is persisted as
|
||||||
|
/// rows (id, x, y, w, h, text) so the board survives restart. The whole
|
||||||
|
/// replace runs in one transaction so a crash mid-save cannot leave a
|
||||||
|
/// half-written board.
|
||||||
|
Future<void> saveBoardCards(
|
||||||
|
String boardId,
|
||||||
|
List<BoardCard> cards,
|
||||||
|
) async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
await _database.transaction((txn) async {
|
||||||
|
await txn.delete(
|
||||||
|
'board_cards',
|
||||||
|
where: 'board_id = ?',
|
||||||
|
whereArgs: [boardId],
|
||||||
|
);
|
||||||
|
for (var i = 0; i < cards.length; i++) {
|
||||||
|
final c = cards[i];
|
||||||
|
await txn.insert('board_cards', {
|
||||||
|
'id': c.id,
|
||||||
|
'board_id': boardId,
|
||||||
|
'x': c.position.dx,
|
||||||
|
'y': c.position.dy,
|
||||||
|
'w': c.size.width,
|
||||||
|
'h': c.size.height,
|
||||||
|
'text': c.text,
|
||||||
|
'ordinal': i,
|
||||||
|
'updated_at': now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the [Board] for [boardId] (empty board when nothing is stored).
|
||||||
|
Future<Board> loadBoard(String boardId) async {
|
||||||
|
final rows = await _database.query(
|
||||||
|
'board_cards',
|
||||||
|
where: 'board_id = ?',
|
||||||
|
whereArgs: [boardId],
|
||||||
|
orderBy: 'ordinal ASC',
|
||||||
|
);
|
||||||
|
return Board([
|
||||||
|
for (final row in rows)
|
||||||
|
BoardCard(
|
||||||
|
id: row['id'] as String,
|
||||||
|
position: Offset(
|
||||||
|
(row['x'] as num).toDouble(),
|
||||||
|
(row['y'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
size: Size(
|
||||||
|
(row['w'] as num).toDouble(),
|
||||||
|
(row['h'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
text: row['text'] as String? ?? '',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scratch links CRUD (PDF-anchored scratchpad tabs) ──────────────────
|
||||||
|
|
||||||
|
/// Insert or replace a [ScratchLink] anchor. The anchor's private scratchpad
|
||||||
|
/// lives in the [scratchpads] table keyed by [ScratchLink.id] — saved/loaded
|
||||||
|
/// via [saveScratchpad] / [loadScratchpad].
|
||||||
|
Future<void> saveScratchLink(ScratchLink link) async {
|
||||||
|
await _database.insert(
|
||||||
|
'scratch_links',
|
||||||
|
{
|
||||||
|
'id': link.id,
|
||||||
|
'document_id': link.documentId,
|
||||||
|
'page_index': link.pageIndex,
|
||||||
|
'nx': link.nx,
|
||||||
|
'ny': link.ny,
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all anchors for [documentId], oldest first.
|
||||||
|
Future<List<ScratchLink>> loadScratchLinks(String documentId) async {
|
||||||
|
final rows = await _database.query(
|
||||||
|
'scratch_links',
|
||||||
|
where: 'document_id = ?',
|
||||||
|
whereArgs: [documentId],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(row) => ScratchLink(
|
||||||
|
id: row['id'] as String,
|
||||||
|
documentId: row['document_id'] as String,
|
||||||
|
pageIndex: row['page_index'] as int,
|
||||||
|
nx: (row['nx'] as num).toDouble(),
|
||||||
|
ny: (row['ny'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an anchor and its private scratchpad (the scratchpad row keyed by
|
||||||
|
/// the anchor id), so a deleted anchor leaves no orphaned ink behind.
|
||||||
|
Future<void> deleteScratchLink(String id) async {
|
||||||
|
await _database.transaction((txn) async {
|
||||||
|
await txn.delete('scratch_links', where: 'id = ?', whereArgs: [id]);
|
||||||
|
await txn.delete('scratchpads', where: 'document_id = ?', whereArgs: [id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RAW legacy reads (one-time SQLite→sidecar migration, Phase 5) ───────────
|
||||||
|
//
|
||||||
|
// These operate on an arbitrary [Database] handle (the LEGACY db the migrator
|
||||||
|
// opens directly), NOT the live [_database] cache, so the migrator can read
|
||||||
|
// pre-migration data without touching the relocated index. They reuse this
|
||||||
|
// class's row-parsers so the JSON shapes stay identical to the live reads.
|
||||||
|
|
||||||
|
/// All `documents` rows from [db], oldest first (stable migration order).
|
||||||
|
static Future<List<doc.Document>> rawAllDocuments(Database db) async {
|
||||||
|
if (!await _tableExists(db, 'documents')) return const [];
|
||||||
|
final rows = await db.query('documents', orderBy: 'created_at ASC');
|
||||||
|
final dummy = DatabaseService._();
|
||||||
|
return rows.map(dummy._documentFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All `notes` rows (with their `strokes`) from [db], oldest first.
|
||||||
|
static Future<List<Note>> rawAllNotes(Database db) async {
|
||||||
|
if (!await _tableExists(db, 'notes')) return const [];
|
||||||
|
final rows = await db.query('notes', orderBy: 'created_at ASC');
|
||||||
|
final dummy = DatabaseService._();
|
||||||
|
final notes = <Note>[];
|
||||||
|
for (final row in rows) {
|
||||||
|
final strokeRows = await db.query(
|
||||||
|
'strokes',
|
||||||
|
where: 'note_id = ?',
|
||||||
|
whereArgs: [row['id'] as String],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
final strokes = strokeRows.map(dummy._strokeFromRow).toList();
|
||||||
|
final tagsJson = jsonDecode(row['tags'] as String) as List;
|
||||||
|
notes.add(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>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Committed editor strokes for [documentId] from [db], grouped by 0-based
|
||||||
|
/// page index. Parses the `ink.host_id = "doc:<documentId>:page:<i>"` scheme
|
||||||
|
/// (see [EditorRepository.loadDocument]) and decodes each `stroke_json`
|
||||||
|
/// straight into an [EditorStroke]. Returns `{}` when there is no `ink` table
|
||||||
|
/// or no rows.
|
||||||
|
static Future<Map<int, List<EditorStroke>>> rawStrokesByPage(
|
||||||
|
Database db,
|
||||||
|
String documentId,
|
||||||
|
) async {
|
||||||
|
if (!await _tableExists(db, 'ink')) return <int, List<EditorStroke>>{};
|
||||||
|
final rows = await db.query(
|
||||||
|
'ink',
|
||||||
|
where: 'host_kind = ? AND host_id LIKE ?',
|
||||||
|
whereArgs: ['page', 'doc:$documentId:page:%'],
|
||||||
|
orderBy: 'host_id ASC, ordinal ASC',
|
||||||
|
);
|
||||||
|
final out = <int, List<EditorStroke>>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
final hostId = row['host_id'] as String;
|
||||||
|
final pageIndex = _pageIndexFromHostId(hostId);
|
||||||
|
if (pageIndex == null) continue;
|
||||||
|
final json =
|
||||||
|
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
|
||||||
|
out.putIfAbsent(pageIndex, () => []).add(EditorStroke.fromJson(json));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookmarks for [documentId] from [db] (empty when no `bookmarks` table).
|
||||||
|
static Future<List<Bookmark>> rawBookmarks(
|
||||||
|
Database db,
|
||||||
|
String documentId,
|
||||||
|
) async {
|
||||||
|
if (!await _tableExists(db, 'bookmarks')) return const [];
|
||||||
|
final rows = await db.query(
|
||||||
|
'bookmarks',
|
||||||
|
where: 'document_id = ?',
|
||||||
|
whereArgs: [documentId],
|
||||||
|
orderBy: 'page_number ASC',
|
||||||
|
);
|
||||||
|
final dummy = DatabaseService._();
|
||||||
|
return rows.map(dummy._bookmarkFromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scratch-link anchors for [documentId] from [db] (empty when no table).
|
||||||
|
static Future<List<ScratchLink>> rawScratchLinks(
|
||||||
|
Database db,
|
||||||
|
String documentId,
|
||||||
|
) async {
|
||||||
|
if (!await _tableExists(db, 'scratch_links')) return const [];
|
||||||
|
final rows = await db.query(
|
||||||
|
'scratch_links',
|
||||||
|
where: 'document_id = ?',
|
||||||
|
whereArgs: [documentId],
|
||||||
|
orderBy: 'created_at ASC',
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.map(
|
||||||
|
(row) => ScratchLink(
|
||||||
|
id: row['id'] as String,
|
||||||
|
documentId: row['document_id'] as String,
|
||||||
|
pageIndex: row['page_index'] as int,
|
||||||
|
nx: (row['nx'] as num).toDouble(),
|
||||||
|
ny: (row['ny'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scratchpad strokes stored under [key] (an anchor id) from [db]. Empty when
|
||||||
|
/// there is no `scratchpads` table or no row.
|
||||||
|
static Future<List<InkStroke>> rawScratchpad(
|
||||||
|
Database db,
|
||||||
|
String key,
|
||||||
|
) async {
|
||||||
|
if (!await _tableExists(db, 'scratchpads')) return const [];
|
||||||
|
final rows = await db.query(
|
||||||
|
'scratchpads',
|
||||||
|
where: 'document_id = ?',
|
||||||
|
whereArgs: [key],
|
||||||
|
);
|
||||||
|
if (rows.isEmpty) return const [];
|
||||||
|
final json = rows.first['strokes_json'] as String;
|
||||||
|
if (json.isEmpty || json == '[]') return const [];
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list
|
||||||
|
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw legacy per-page `annotation_json` blobs for [documentId] from [db],
|
||||||
|
/// keyed by page number. These belong to the DEAD pre-editor annotation path
|
||||||
|
/// (§1 `annotations` table); the migrator copies them verbatim into the
|
||||||
|
/// sidecar's `legacyAnnotations` so nothing is silently dropped.
|
||||||
|
static Future<Map<int, String>> rawLegacyAnnotations(
|
||||||
|
Database db,
|
||||||
|
String documentId,
|
||||||
|
) async {
|
||||||
|
if (!await _tableExists(db, 'annotations')) return <int, String>{};
|
||||||
|
final rows = await db.query(
|
||||||
|
'annotations',
|
||||||
|
where: 'document_id = ?',
|
||||||
|
whereArgs: [documentId],
|
||||||
|
orderBy: 'page_number ASC',
|
||||||
|
);
|
||||||
|
final out = <int, String>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
out[row['page_number'] as int] = row['annotation_json'] as String;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the 0-based page index out of an `ink.host_id` of the form
|
||||||
|
/// `doc:<documentId>:page:<pageIndex>`. Returns null on an unexpected shape.
|
||||||
|
static int? _pageIndexFromHostId(String hostId) {
|
||||||
|
final i = hostId.lastIndexOf(':page:');
|
||||||
|
if (i == -1) return null;
|
||||||
|
return int.tryParse(hostId.substring(i + ':page:'.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff [name] is an existing table in [db]. Lets the raw readers tolerate
|
||||||
|
/// a legacy DB that predates a given table (older schema versions).
|
||||||
|
static Future<bool> _tableExists(Database db, String name) async {
|
||||||
|
final rows = await db.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
[name],
|
||||||
|
);
|
||||||
|
return rows.isNotEmpty;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../diagnostics/badnote_log.dart';
|
||||||
|
import '../editor/persistence/sidecar_repository.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import 'database_service.dart';
|
import 'database_service.dart';
|
||||||
@@ -6,8 +10,17 @@ import 'stroke_rasterizer.dart';
|
|||||||
|
|
||||||
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
||||||
class OcrService {
|
class OcrService {
|
||||||
/// Extract searchable text from [note] and merge into the local FTS index.
|
/// Extract searchable text from [note] and persist it for search. The text is
|
||||||
|
/// written to the note's `*.badnote.json` sidecar `ocrText` field — the
|
||||||
|
/// vault-scan source of truth the Phase 6 search index reads — and also
|
||||||
|
/// appended to the (rebuildable) SQLite FTS cache so legacy callers keep
|
||||||
|
/// working. [note.id] is the synthetic note path, which is exactly the
|
||||||
|
/// `sourceFilePath` the editor opened its [SidecarRepository] with.
|
||||||
Future<void> processNote(Note note) async {
|
Future<void> processNote(Note note) async {
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_start', fields: {
|
||||||
|
'note': note.id,
|
||||||
|
'strokes': note.strokes.length,
|
||||||
|
});
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
|
|
||||||
for (final stroke in note.strokes) {
|
for (final stroke in note.strokes) {
|
||||||
@@ -33,13 +46,35 @@ class OcrService {
|
|||||||
final recognized = await OcrEngine.recognizeImage(png);
|
final recognized = await OcrEngine.recognizeImage(png);
|
||||||
if (recognized != null && recognized.isNotEmpty) {
|
if (recognized != null && recognized.isNotEmpty) {
|
||||||
parts.add(recognized);
|
parts.add(recognized);
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_handwriting', fields: {
|
||||||
|
'chars': recognized.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final combined = parts.join(' ').trim();
|
final combined = parts.join(' ').trim();
|
||||||
if (combined.isEmpty) return;
|
if (combined.isEmpty) {
|
||||||
|
BadNoteLog.instance.debug(LogSubsystem.diag, 'ocr_empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist into the note's sidecar so the vault-scan search index finds it.
|
||||||
|
// Prefer the editor's already-open repo (same in-memory sidecar — no race);
|
||||||
|
// if the note is closed, open/flush/dispose a transient handle.
|
||||||
|
final open = SidecarRepositoryRegistry.forPath(note.id);
|
||||||
|
if (open != null) {
|
||||||
|
open.scheduleOcrTextSave(combined);
|
||||||
|
await open.flush();
|
||||||
|
} else if (await File('${note.id}$kSidecarSuffix').exists()) {
|
||||||
|
final repo = await SidecarRepository.open(note.id, docType: 'notebook');
|
||||||
|
repo.scheduleOcrTextSave(combined);
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also keep the legacy SQLite FTS cache warm (rebuildable; not the source
|
||||||
|
// of truth). Harmless if the row is never read.
|
||||||
final db = await DatabaseService.getInstance();
|
final db = await DatabaseService.getInstance();
|
||||||
await db.appendOcrToFts(note.id, combined);
|
await db.appendOcrToFts(note.id, combined);
|
||||||
}
|
}
|
||||||
|
|||||||
120
lib/services/office/docx_parser.dart
Normal file
120
lib/services/office/docx_parser.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import '../../diagnostics/badnote_log.dart';
|
||||||
|
import 'office_document.dart';
|
||||||
|
|
||||||
|
/// Native DOCX parser — block-level structure for BadNote annotation pages.
|
||||||
|
class DocxParser {
|
||||||
|
Future<ParsedDocx> parse(String docxPath, {Directory? cacheDir}) async {
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_start', fields: {
|
||||||
|
'path': docxPath,
|
||||||
|
});
|
||||||
|
final bytes = await File(docxPath).readAsBytes();
|
||||||
|
final archive = ZipDecoder().decodeBytes(bytes);
|
||||||
|
Directory out = cacheDir ??
|
||||||
|
Directory(p.join(Directory.systemTemp.path, 'badnote_docx_${DateTime.now().millisecondsSinceEpoch}'));
|
||||||
|
if (!await out.exists()) await out.create(recursive: true);
|
||||||
|
|
||||||
|
final documentXml = _decode(_find(archive, 'word/document.xml'));
|
||||||
|
if (documentXml == null) {
|
||||||
|
return ParsedDocx(sourcePath: docxPath, blocks: const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media map from relationships.
|
||||||
|
final media = <String, String>{};
|
||||||
|
final rels = _decode(_find(archive, 'word/_rels/document.xml.rels'));
|
||||||
|
if (rels != null) {
|
||||||
|
try {
|
||||||
|
final relDoc = XmlDocument.parse(rels);
|
||||||
|
for (final rel in relDoc.findAllElements('Relationship')) {
|
||||||
|
final id = rel.getAttribute('Id');
|
||||||
|
final type = rel.getAttribute('Type') ?? '';
|
||||||
|
final target = rel.getAttribute('Target') ?? '';
|
||||||
|
if (id == null || !type.contains('image') || target.isEmpty) continue;
|
||||||
|
final mediaPath = p.normalize(p.join('word', target));
|
||||||
|
final file = _find(archive, mediaPath);
|
||||||
|
if (file?.content is! List<int>) continue;
|
||||||
|
final outPath = p.join(out.path, p.basename(mediaPath));
|
||||||
|
await File(outPath).writeAsBytes(file!.content as List<int>);
|
||||||
|
media[id] = outPath;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final blocks = <DocBlock>[];
|
||||||
|
try {
|
||||||
|
final doc = XmlDocument.parse(documentXml);
|
||||||
|
for (final pEl in doc.findAllElements('w:p')) {
|
||||||
|
final style = pEl
|
||||||
|
.findElements('w:pPr')
|
||||||
|
.expand((e) => e.findElements('w:pStyle'))
|
||||||
|
.map((e) => e.getAttribute('w:val') ?? '')
|
||||||
|
.firstWhere((s) => s.isNotEmpty, orElse: () => '');
|
||||||
|
final texts = pEl.findAllElements('w:t').map((t) => t.innerText).join();
|
||||||
|
final blips = pEl.findAllElements('a:blip');
|
||||||
|
for (final blip in blips) {
|
||||||
|
final embed = blip.getAttribute('r:embed') ?? blip.getAttribute('embed');
|
||||||
|
if (embed != null && media[embed] != null) {
|
||||||
|
blocks.add(DocBlock(
|
||||||
|
type: DocBlockType.image,
|
||||||
|
text: '',
|
||||||
|
imagePath: media[embed],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (texts.trim().isEmpty && blips.isEmpty) continue;
|
||||||
|
if (texts.trim().isEmpty) continue;
|
||||||
|
final isHeading = style.toLowerCase().startsWith('heading') ||
|
||||||
|
RegExp(r'^Heading\s*\d', caseSensitive: false).hasMatch(style);
|
||||||
|
final level = int.tryParse(RegExp(r'(\d+)').firstMatch(style)?.group(1) ?? '') ??
|
||||||
|
(isHeading ? 1 : 0);
|
||||||
|
blocks.add(DocBlock(
|
||||||
|
type: isHeading ? DocBlockType.heading : DocBlockType.paragraph,
|
||||||
|
text: texts,
|
||||||
|
level: level,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Tables
|
||||||
|
for (final row in doc.findAllElements('w:tr')) {
|
||||||
|
final cells = row
|
||||||
|
.findElements('w:tc')
|
||||||
|
.map((tc) => tc.findAllElements('w:t').map((t) => t.innerText).join())
|
||||||
|
.where((s) => s.trim().isNotEmpty)
|
||||||
|
.join(' | ');
|
||||||
|
if (cells.isEmpty) continue;
|
||||||
|
blocks.add(DocBlock(type: DocBlockType.tableRow, text: cells));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
BadNoteLog.instance.warn(LogSubsystem.office, 'docx_parse_error', fields: {
|
||||||
|
'error': '$e',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_done', fields: {
|
||||||
|
'blocks': blocks.length,
|
||||||
|
});
|
||||||
|
return ParsedDocx(sourcePath: docxPath, blocks: blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> extractText(String docxPath) async {
|
||||||
|
final parsed = await parse(docxPath);
|
||||||
|
return parsed.plainText;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ArchiveFile? _find(Archive archive, String name) {
|
||||||
|
final n = name.replaceAll('\\', '/');
|
||||||
|
for (final f in archive.files) {
|
||||||
|
if (f.name.replaceAll('\\', '/') == n) return f;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _decode(ArchiveFile? file) {
|
||||||
|
if (file == null) return null;
|
||||||
|
return String.fromCharCodes(file.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
lib/services/office/office_document.dart
Normal file
98
lib/services/office/office_document.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/// Shared OOXML document models for native Word/PPT parsing.
|
||||||
|
library;
|
||||||
|
|
||||||
|
class OfficeTextRun {
|
||||||
|
const OfficeTextRun({
|
||||||
|
required this.text,
|
||||||
|
this.x = 0,
|
||||||
|
this.y = 0,
|
||||||
|
this.width = 0,
|
||||||
|
this.height = 0,
|
||||||
|
this.fontSize = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfficeImage {
|
||||||
|
const OfficeImage({
|
||||||
|
required this.bytesPath,
|
||||||
|
this.x = 0,
|
||||||
|
this.y = 0,
|
||||||
|
this.width = 0,
|
||||||
|
this.height = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String bytesPath;
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfficeSlide {
|
||||||
|
const OfficeSlide({
|
||||||
|
required this.index,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
this.runs = const [],
|
||||||
|
this.images = const [],
|
||||||
|
this.plainText = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final List<OfficeTextRun> runs;
|
||||||
|
final List<OfficeImage> images;
|
||||||
|
final String plainText;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParsedPptx {
|
||||||
|
const ParsedPptx({
|
||||||
|
required this.sourcePath,
|
||||||
|
required this.slides,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sourcePath;
|
||||||
|
final List<OfficeSlide> slides;
|
||||||
|
|
||||||
|
String get allText =>
|
||||||
|
slides.map((s) => '--- Slide ${s.index + 1} ---\n${s.plainText}').join('\n\n');
|
||||||
|
|
||||||
|
/// Alias used by [PptxService.extractText].
|
||||||
|
String get plainText => allText;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DocBlockType { heading, paragraph, tableRow, image }
|
||||||
|
|
||||||
|
class DocBlock {
|
||||||
|
const DocBlock({
|
||||||
|
required this.type,
|
||||||
|
required this.text,
|
||||||
|
this.level = 0,
|
||||||
|
this.imagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DocBlockType type;
|
||||||
|
final String text;
|
||||||
|
final int level;
|
||||||
|
final String? imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParsedDocx {
|
||||||
|
const ParsedDocx({
|
||||||
|
required this.sourcePath,
|
||||||
|
required this.blocks,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sourcePath;
|
||||||
|
final List<DocBlock> blocks;
|
||||||
|
|
||||||
|
String get plainText => blocks.map((b) => b.text).where((t) => t.isNotEmpty).join('\n');
|
||||||
|
}
|
||||||
164
lib/services/office/pptx_parser.dart
Normal file
164
lib/services/office/pptx_parser.dart
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
import '../../diagnostics/badnote_log.dart';
|
||||||
|
import 'office_document.dart';
|
||||||
|
|
||||||
|
/// Native PPTX parser — no LibreOffice. Reads OOXML zip + slide XML.
|
||||||
|
class PptxParser {
|
||||||
|
/// EMUs per English inch (Office drawing unit).
|
||||||
|
static const double _emuPerInch = 914400;
|
||||||
|
static const double _defaultDpi = 96;
|
||||||
|
|
||||||
|
Future<ParsedPptx> parse(String pptxPath, {Directory? cacheDir, String? cacheDirPath}) async {
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_start', fields: {
|
||||||
|
'path': pptxPath,
|
||||||
|
});
|
||||||
|
final bytes = await File(pptxPath).readAsBytes();
|
||||||
|
final archive = ZipDecoder().decodeBytes(bytes);
|
||||||
|
|
||||||
|
Directory out = cacheDir ??
|
||||||
|
(cacheDirPath != null
|
||||||
|
? Directory(cacheDirPath)
|
||||||
|
: Directory(p.join(Directory.systemTemp.path, 'badnote_pptx_${DateTime.now().millisecondsSinceEpoch}')));
|
||||||
|
if (!await out.exists()) await out.create(recursive: true);
|
||||||
|
|
||||||
|
// Default slide size (widescreen 13.333" x 7.5") in pixels at 96dpi.
|
||||||
|
double slideW = 13.333 * _defaultDpi;
|
||||||
|
double slideH = 7.5 * _defaultDpi;
|
||||||
|
final sldSz = _file(archive, 'ppt/presentation.xml');
|
||||||
|
if (sldSz != null) {
|
||||||
|
try {
|
||||||
|
final doc = XmlDocument.parse(sldSz);
|
||||||
|
final candidates = [
|
||||||
|
...doc.findAllElements('sldSz', namespace: '*'),
|
||||||
|
...doc.findAllElements('p:sldSz'),
|
||||||
|
];
|
||||||
|
final el = candidates.isEmpty ? null : candidates.first;
|
||||||
|
if (el != null) {
|
||||||
|
final cx = int.tryParse(el.getAttribute('cx') ?? '') ?? 0;
|
||||||
|
final cy = int.tryParse(el.getAttribute('cy') ?? '') ?? 0;
|
||||||
|
if (cx > 0 && cy > 0) {
|
||||||
|
slideW = cx / _emuPerInch * _defaultDpi;
|
||||||
|
slideH = cy / _emuPerInch * _defaultDpi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final slideFiles = archive.files
|
||||||
|
.where((f) =>
|
||||||
|
f.name.startsWith('ppt/slides/slide') &&
|
||||||
|
f.name.endsWith('.xml') &&
|
||||||
|
!f.name.contains('_rels'))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => _slideNum(a.name).compareTo(_slideNum(b.name)));
|
||||||
|
|
||||||
|
final slides = <OfficeSlide>[];
|
||||||
|
for (var i = 0; i < slideFiles.length; i++) {
|
||||||
|
final file = slideFiles[i];
|
||||||
|
final xml = _decode(file);
|
||||||
|
if (xml == null) continue;
|
||||||
|
final runs = <OfficeTextRun>[];
|
||||||
|
final images = <OfficeImage>[];
|
||||||
|
final textBuf = StringBuffer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final doc = XmlDocument.parse(xml);
|
||||||
|
for (final t in doc.findAllElements('a:t')) {
|
||||||
|
final text = t.innerText;
|
||||||
|
if (text.isEmpty) continue;
|
||||||
|
textBuf.writeln(text);
|
||||||
|
// Approximate: stack text vertically when no transform is parsed.
|
||||||
|
runs.add(OfficeTextRun(
|
||||||
|
text: text,
|
||||||
|
x: 48,
|
||||||
|
y: 48.0 + runs.length * 28,
|
||||||
|
width: math.max(120, slideW - 96),
|
||||||
|
height: 28,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract images referenced by this slide's relationships.
|
||||||
|
final relsName =
|
||||||
|
'ppt/slides/_rels/slide${_slideNum(file.name)}.xml.rels';
|
||||||
|
final relsXml = _file(archive, relsName);
|
||||||
|
if (relsXml != null) {
|
||||||
|
final relsDoc = XmlDocument.parse(relsXml);
|
||||||
|
for (final rel in relsDoc.findAllElements('Relationship')) {
|
||||||
|
final type = rel.getAttribute('Type') ?? '';
|
||||||
|
if (!type.contains('image')) continue;
|
||||||
|
var target = rel.getAttribute('Target') ?? '';
|
||||||
|
if (target.isEmpty) continue;
|
||||||
|
// Targets are relative to ppt/slides/ → often ../media/image1.png
|
||||||
|
final mediaPath = p.normalize(p.join('ppt/slides', target));
|
||||||
|
final media = _archiveFile(archive, mediaPath) ??
|
||||||
|
_archiveFile(archive, target.replaceFirst('../', 'ppt/'));
|
||||||
|
if (media == null) continue;
|
||||||
|
final content = media.content;
|
||||||
|
final outPath = p.join(out.path, p.basename(mediaPath));
|
||||||
|
await File(outPath).writeAsBytes(content);
|
||||||
|
images.add(OfficeImage(
|
||||||
|
bytesPath: outPath,
|
||||||
|
x: 80,
|
||||||
|
y: slideH * 0.35,
|
||||||
|
width: slideW * 0.4,
|
||||||
|
height: slideH * 0.4,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
BadNoteLog.instance.warn(LogSubsystem.office, 'slide_parse_error', fields: {
|
||||||
|
'slide': file.name,
|
||||||
|
'error': '$e',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
slides.add(OfficeSlide(
|
||||||
|
index: i,
|
||||||
|
width: slideW,
|
||||||
|
height: slideH,
|
||||||
|
runs: runs,
|
||||||
|
images: images,
|
||||||
|
plainText: textBuf.toString().trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_done', fields: {
|
||||||
|
'slides': slides.length,
|
||||||
|
});
|
||||||
|
return ParsedPptx(sourcePath: pptxPath, slides: slides);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> extractText(String pptxPath) async {
|
||||||
|
final parsed = await parse(pptxPath);
|
||||||
|
return parsed.allText;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _slideNum(String name) {
|
||||||
|
final m = RegExp(r'slide(\d+)\.xml').firstMatch(name);
|
||||||
|
return int.tryParse(m?.group(1) ?? '') ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _file(Archive archive, String name) {
|
||||||
|
final f = _archiveFile(archive, name);
|
||||||
|
return _decode(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ArchiveFile? _archiveFile(Archive archive, String name) {
|
||||||
|
final normalized = name.replaceAll('\\', '/');
|
||||||
|
for (final f in archive.files) {
|
||||||
|
if (f.name.replaceAll('\\', '/') == normalized) return f;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _decode(ArchiveFile? file) {
|
||||||
|
if (file == null) return null;
|
||||||
|
return String.fromCharCodes(file.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,6 +308,12 @@ class PdfService {
|
|||||||
// ONE shared recipe with the on-screen painter (R7): export can no longer
|
// ONE shared recipe with the on-screen painter (R7): export can no longer
|
||||||
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
|
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
|
||||||
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
|
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
|
||||||
|
//
|
||||||
|
// TODO(brush-persist) / TODO(brush-export): InkStroke does not persist the
|
||||||
|
// brush, so export can only use the legacy (no-brush) recipe — it does NOT
|
||||||
|
// yet pass `brush:` here. Once the brush is persisted on InkStroke, resolve
|
||||||
|
// brushProfileFor(stroke.brush) and pass it so export matches the brush-aware
|
||||||
|
// screen geometry (taper/caps/per-brush thinning) for non-fountain brushes.
|
||||||
final outline = freehandOutlinePoints(
|
final outline = freehandOutlinePoints(
|
||||||
pfPoints: pfPoints,
|
pfPoints: pfPoints,
|
||||||
size: pixelWidth,
|
size: pixelWidth,
|
||||||
|
|||||||
198
lib/services/pdf_text_indexer.dart
Normal file
198
lib/services/pdf_text_indexer.dart
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// lib/services/pdf_text_indexer.dart
|
||||||
|
//
|
||||||
|
// Import-time document-body text indexing for file-backed PDF notebooks, so the
|
||||||
|
// vault-scan search index (VaultSearchIndex) covers the underlying document —
|
||||||
|
// not just the user's annotations. Three text sources now feed search:
|
||||||
|
// (a) handwriting → OCR'd into the sidecar's `ocrText` (OcrService),
|
||||||
|
// (b) a PDF text layer → its embedded printed text, captured here,
|
||||||
|
// (c) a RASTERIZED PDF → background OCR of the rendered pages, captured here.
|
||||||
|
//
|
||||||
|
// Flow (kicked off after VaultService.createNotebook, fire-and-forget):
|
||||||
|
// 1. IDEMPOTENCY: if the sidecar already carries `pageText`, do nothing.
|
||||||
|
// 2. EXTRACT the embedded text layer per page (pdfrx `loadText`).
|
||||||
|
// 3. DECIDE text-layer vs rasterized: sum the embedded text length across all
|
||||||
|
// pages; if it clears [textLayerThreshold] the PDF has a usable text layer
|
||||||
|
// and we persist that. Otherwise the PDF is rasterized (scanned image, no
|
||||||
|
// text) and we OCR each rendered page.
|
||||||
|
// 4. PERSIST the per-page text (joined by form-feed) into the sidecar's
|
||||||
|
// `pageText` field — writing THROUGH an open SidecarRepository when the
|
||||||
|
// editor already has the doc open (no race), else a transient handle.
|
||||||
|
//
|
||||||
|
// GRACEFUL DEGRADATION / HONESTY:
|
||||||
|
// * The OCR engine is whatever OcrService/OcrEngine resolves to: the bundled
|
||||||
|
// ONNX PP-OCR recognizer if present, else the native Windows OCR
|
||||||
|
// MethodChannel, else NONE. On a platform/CI without any backend, OCR
|
||||||
|
// returns null and a rasterized PDF simply gets no `pageText` — no crash,
|
||||||
|
// and the embedded-text path still works.
|
||||||
|
// * pdfrx render + native OCR can only be exercised on-device. The pdfrx-
|
||||||
|
// backed loaders are injected through [PdfTextIndexer] so unit tests fake
|
||||||
|
// them; the production wiring lives in [PdfrxPageTextSource].
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../editor/persistence/sidecar_repository.dart';
|
||||||
|
import '../storage/badnote_sidecar.dart';
|
||||||
|
import '../storage/sidecar_store.dart';
|
||||||
|
|
||||||
|
/// Extracts a PDF's embedded text layer, one entry per page (page order). An
|
||||||
|
/// empty list (or all-empty entries) means "no usable text layer".
|
||||||
|
typedef PdfEmbeddedTextLoader = Future<List<String>> Function(String pdfPath);
|
||||||
|
|
||||||
|
/// OCRs the rendered pages of a rasterized PDF, returning one entry per page
|
||||||
|
/// (page order). Entries may be empty where a page yielded nothing. Returns an
|
||||||
|
/// empty list when no OCR backend is available (a clean no-op).
|
||||||
|
typedef PdfPageOcrRunner = Future<List<String>> Function(String pdfPath);
|
||||||
|
|
||||||
|
/// Indexes a PDF's document body into its sidecar's `pageText` at import time.
|
||||||
|
///
|
||||||
|
/// Stateless apart from the two injected text sources; safe to construct per
|
||||||
|
/// import. All disk/native work is awaited internally — call [indexPdf] without
|
||||||
|
/// awaiting (fire-and-forget) from the import handler to keep import snappy.
|
||||||
|
class PdfTextIndexer {
|
||||||
|
PdfTextIndexer({
|
||||||
|
required PdfEmbeddedTextLoader loadEmbeddedText,
|
||||||
|
required PdfPageOcrRunner ocrPages,
|
||||||
|
this.textLayerThreshold = 16,
|
||||||
|
}) : _loadEmbeddedText = loadEmbeddedText,
|
||||||
|
_ocrPages = ocrPages;
|
||||||
|
|
||||||
|
final PdfEmbeddedTextLoader _loadEmbeddedText;
|
||||||
|
final PdfPageOcrRunner _ocrPages;
|
||||||
|
|
||||||
|
/// Minimum total embedded-text length (across all pages, after trimming) for a
|
||||||
|
/// PDF to count as having a usable text layer. Below this it is treated as
|
||||||
|
/// rasterized and routed to OCR. Small on purpose: a scanned PDF typically
|
||||||
|
/// yields zero or a few stray ligature chars, while any real text page clears
|
||||||
|
/// it easily.
|
||||||
|
final int textLayerThreshold;
|
||||||
|
|
||||||
|
/// The page separator stored inside `pageText` (form feed). The search index
|
||||||
|
/// treats `pageText` as a flat blob, so this is purely cosmetic / future-proof.
|
||||||
|
static const String pageSeparator = '\f';
|
||||||
|
|
||||||
|
/// Index the PDF at [pdfPath] (an in-vault copy) into its sidecar's `pageText`.
|
||||||
|
///
|
||||||
|
/// Idempotent: returns immediately if the sidecar already has non-empty
|
||||||
|
/// `pageText`. Never throws — any failure (unreadable PDF, missing OCR backend)
|
||||||
|
/// degrades to leaving `pageText` unset. Returns the text it persisted (for
|
||||||
|
/// tests), or null when nothing was indexed.
|
||||||
|
Future<String?> indexPdf(String pdfPath) async {
|
||||||
|
try {
|
||||||
|
// 1. Idempotency: skip a doc whose body has already been indexed.
|
||||||
|
final existing = await _currentPageText(pdfPath);
|
||||||
|
if (existing != null && existing.trim().isNotEmpty) return null;
|
||||||
|
|
||||||
|
// 2. Embedded text layer.
|
||||||
|
final embedded = await _loadEmbeddedText(pdfPath);
|
||||||
|
final embeddedLen = embedded.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, page) => sum + page.trim().length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Text-layer vs rasterized decision.
|
||||||
|
List<String> pages;
|
||||||
|
if (embeddedLen >= textLayerThreshold) {
|
||||||
|
pages = embedded;
|
||||||
|
} else {
|
||||||
|
// Rasterized (scanned, no text layer) → background OCR.
|
||||||
|
pages = await _ocrPages(pdfPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
final joined = _joinPages(pages);
|
||||||
|
if (joined.isEmpty) return null;
|
||||||
|
|
||||||
|
// 4. Persist into the sidecar (through an open repo if the editor holds it).
|
||||||
|
await _persistPageText(pdfPath, joined);
|
||||||
|
return joined;
|
||||||
|
} catch (_) {
|
||||||
|
// Background indexing must never surface an error to the import flow.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the embedded text in [embedded] clears [textLayerThreshold], i.e.
|
||||||
|
/// the PDF has a usable text layer (false → rasterized, needs OCR). Exposed for
|
||||||
|
/// unit-testing the decision in isolation.
|
||||||
|
bool hasUsableTextLayer(List<String> embedded) {
|
||||||
|
final len = embedded.fold<int>(0, (sum, p) => sum + p.trim().length);
|
||||||
|
return len >= textLayerThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _joinPages(List<String> pages) {
|
||||||
|
final nonEmpty = pages.map((p) => p.trim()).where((p) => p.isNotEmpty);
|
||||||
|
return nonEmpty.join(pageSeparator).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the sidecar's current `pageText` (for the idempotency check), from the
|
||||||
|
/// open repo if present else from disk. Null when no sidecar exists yet.
|
||||||
|
Future<String?> _currentPageText(String pdfPath) async {
|
||||||
|
final open = SidecarRepositoryRegistry.forPath(pdfPath);
|
||||||
|
if (open != null) return open.loadedPageText;
|
||||||
|
final sidecar = await SidecarStore.read(
|
||||||
|
File('$pdfPath$kSidecarSuffix'),
|
||||||
|
);
|
||||||
|
return sidecar?.pageText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write [pageText] into the sidecar. Prefer the editor's already-open repo
|
||||||
|
/// (same in-memory sidecar — no race); otherwise merge into the on-disk
|
||||||
|
/// sidecar (creating one if the editor hasn't yet).
|
||||||
|
Future<void> _persistPageText(String pdfPath, String pageText) async {
|
||||||
|
final open = SidecarRepositoryRegistry.forPath(pdfPath);
|
||||||
|
if (open != null) {
|
||||||
|
open.schedulePageTextSave(pageText);
|
||||||
|
await open.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = File('$pdfPath$kSidecarSuffix');
|
||||||
|
final current = await SidecarStore.read(file);
|
||||||
|
final merged = _withPageText(current, pdfPath, pageText);
|
||||||
|
await SidecarStore.writeAtomic(file, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a sidecar carrying [pageText], preserving every other field of
|
||||||
|
/// [current] (or a fresh minimal sidecar when none exists yet).
|
||||||
|
static BadnoteSidecar _withPageText(
|
||||||
|
BadnoteSidecar? current,
|
||||||
|
String pdfPath,
|
||||||
|
String pageText,
|
||||||
|
) {
|
||||||
|
if (current == null) {
|
||||||
|
return BadnoteSidecar(
|
||||||
|
sourceFile: _basename(pdfPath),
|
||||||
|
docType: 'pdf',
|
||||||
|
createdAt: DateTime.now().toUtc(),
|
||||||
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
pageText: pageText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return BadnoteSidecar(
|
||||||
|
version: current.version,
|
||||||
|
sourceFile: current.sourceFile,
|
||||||
|
docType: current.docType,
|
||||||
|
title: current.title,
|
||||||
|
pageCount: current.pageCount,
|
||||||
|
rotation: current.rotation,
|
||||||
|
createdAt: current.createdAt,
|
||||||
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
strokes: current.strokes,
|
||||||
|
highlights: current.highlights,
|
||||||
|
texts: current.texts,
|
||||||
|
bookmarks: current.bookmarks,
|
||||||
|
scratchLinks: current.scratchLinks,
|
||||||
|
legacyAnnotations: current.legacyAnnotations,
|
||||||
|
ocrText: current.ocrText,
|
||||||
|
pageText: pageText,
|
||||||
|
legacyId: current.legacyId,
|
||||||
|
background: current.background,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _basename(String path) {
|
||||||
|
final norm = path.replaceAll('\\', '/');
|
||||||
|
final i = norm.lastIndexOf('/');
|
||||||
|
return i == -1 ? norm : norm.substring(i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
lib/services/pdfrx_page_text_source.dart
Normal file
111
lib/services/pdfrx_page_text_source.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// lib/services/pdfrx_page_text_source.dart
|
||||||
|
//
|
||||||
|
// Production wiring for [PdfTextIndexer]'s two injected text sources, backed by
|
||||||
|
// pdfrx (the same engine the editor renders with). Kept SEPARATE from
|
||||||
|
// PdfTextIndexer so the indexer's logic (the threshold decision, idempotency,
|
||||||
|
// sidecar persistence) is unit-testable without the native pdfium/OCR stack —
|
||||||
|
// only this file touches pdfrx, dart:ui, and the OCR engine, and it is exercised
|
||||||
|
// on-device, not in CI.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
|
import 'ocr_engine.dart';
|
||||||
|
|
||||||
|
/// pdfrx-backed loaders for [PdfTextIndexer].
|
||||||
|
class PdfrxPageTextSource {
|
||||||
|
const PdfrxPageTextSource._();
|
||||||
|
|
||||||
|
/// Load the embedded text layer of every page (page order). Each entry is a
|
||||||
|
/// page's raw text (possibly empty). Returns an empty list on any failure, so
|
||||||
|
/// the indexer treats the PDF as having no text layer (→ OCR fallback).
|
||||||
|
static Future<List<String>> loadEmbeddedText(String pdfPath) async {
|
||||||
|
PdfDocument? doc;
|
||||||
|
try {
|
||||||
|
doc = await PdfDocument.openFile(pdfPath);
|
||||||
|
final out = <String>[];
|
||||||
|
for (final page in doc.pages) {
|
||||||
|
final raw = await page.loadText();
|
||||||
|
out.add(raw?.fullText ?? '');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
} finally {
|
||||||
|
await doc?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render each page and OCR it (page order). Returns one entry per page
|
||||||
|
/// (empty where nothing was recognized), or an empty list when the PDF can't
|
||||||
|
/// be opened. Honours the OCR engine's own graceful no-op: when no backend is
|
||||||
|
/// available every page comes back empty.
|
||||||
|
///
|
||||||
|
/// Rendering is done at [renderScale]× the page's native 72-dpi size to give
|
||||||
|
/// the recognizer enough resolution on scanned scans without exploding memory.
|
||||||
|
static Future<List<String>> ocrPages(
|
||||||
|
String pdfPath, {
|
||||||
|
double renderScale = 2.0,
|
||||||
|
}) async {
|
||||||
|
PdfDocument? doc;
|
||||||
|
try {
|
||||||
|
doc = await PdfDocument.openFile(pdfPath);
|
||||||
|
final out = <String>[];
|
||||||
|
for (final page in doc.pages) {
|
||||||
|
final text = await _ocrOnePage(page, renderScale);
|
||||||
|
out.add(text ?? '');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
} finally {
|
||||||
|
await doc?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _ocrOnePage(PdfPage page, double renderScale) async {
|
||||||
|
PdfImage? image;
|
||||||
|
try {
|
||||||
|
final fullWidth = page.width * renderScale;
|
||||||
|
final fullHeight = page.height * renderScale;
|
||||||
|
image = await page.render(
|
||||||
|
fullWidth: fullWidth,
|
||||||
|
fullHeight: fullHeight,
|
||||||
|
);
|
||||||
|
if (image == null) return null;
|
||||||
|
final png = await _bgraToPng(image.pixels, image.width, image.height);
|
||||||
|
if (png == null) return null;
|
||||||
|
return OcrEngine.recognizeImage(png);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
image?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode pdfrx's BGRA8888 raw pixels as PNG (the format [OcrEngine] expects).
|
||||||
|
static Future<Uint8List?> _bgraToPng(
|
||||||
|
Uint8List bgra,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<ui.Image>();
|
||||||
|
ui.decodeImageFromPixels(
|
||||||
|
bgra,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
ui.PixelFormat.bgra8888,
|
||||||
|
completer.complete,
|
||||||
|
);
|
||||||
|
final image = await completer.future;
|
||||||
|
try {
|
||||||
|
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
return data?.buffer.asUint8List();
|
||||||
|
} finally {
|
||||||
|
image.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,24 +5,83 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Service for processing PPTX files: text extraction, image conversion, file picking.
|
import 'office/office_document.dart';
|
||||||
|
import 'office/pptx_parser.dart';
|
||||||
|
|
||||||
|
/// Service for processing PPTX files: text extraction, structured slide parse,
|
||||||
|
/// optional LibreOffice image conversion, and file picking.
|
||||||
///
|
///
|
||||||
/// PPTX files are ZIP archives containing XML. We extract text from
|
/// **Native OOXML parsing is primary** ([PptxParser] via `package:archive` +
|
||||||
/// `ppt/slides/slide*.xml` `<a:t>` elements and convert slides to images
|
/// `package:xml`). LibreOffice (`soffice`) is an optional fallback ONLY when
|
||||||
/// using LibreOffice (headless) or generate placeholder images as fallback.
|
/// the native path fails AND the binary is present on the machine.
|
||||||
class PptxService {
|
class PptxService {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
final PptxParser _parser;
|
||||||
|
|
||||||
|
PptxService({PptxParser? parser}) : _parser = parser ?? PptxParser();
|
||||||
|
|
||||||
/// Extract all text content from a PPTX file.
|
/// Extract all text content from a PPTX file.
|
||||||
///
|
///
|
||||||
/// PPTX is a ZIP archive. Slide text lives in `ppt/slides/slide*.xml`
|
/// Prefers the native [PptxParser]. Falls back to a legacy unzip+regex path
|
||||||
/// inside `<a:t>` (ASCII text) elements within `<a:r>` (run) or
|
/// only if native parsing throws.
|
||||||
/// `<a:p>` (paragraph) nodes.
|
|
||||||
Future<String> extractText(String pptxPath) async {
|
Future<String> extractText(String pptxPath) async {
|
||||||
|
try {
|
||||||
|
final parsed = await _parser.parse(pptxPath);
|
||||||
|
return parsed.plainText;
|
||||||
|
} catch (_) {
|
||||||
|
return _extractTextLegacy(pptxPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse PPTX into structured slides (text runs with approximate positions,
|
||||||
|
/// embedded images extracted to a cache dir). Native-only — no LibreOffice.
|
||||||
|
Future<ParsedPptx> parseSlides(String pptxPath, {String? cacheDir}) {
|
||||||
|
return _parser.parse(pptxPath, cacheDirPath: cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert PPTX slides to a list of image file paths (legacy PenSlideScreen).
|
||||||
|
///
|
||||||
|
/// Prefer [parseSlides] for native text+image rendering. This method only
|
||||||
|
/// invokes LibreOffice when native parse fails AND `soffice` exists;
|
||||||
|
/// otherwise it emits placeholder PNGs from the native slide count.
|
||||||
|
Future<List<String>> convertToImages(String pptxPath) async {
|
||||||
|
try {
|
||||||
|
final parsed = await _parser.parse(pptxPath);
|
||||||
|
// Native succeeded — do NOT call LibreOffice; placeholders for callers
|
||||||
|
// that still expect image paths. OfficeDocumentScreen uses [parseSlides].
|
||||||
|
if (parsed.slides.isEmpty) return [];
|
||||||
|
return _generatePlaceholderImages(pptxPath);
|
||||||
|
} catch (_) {
|
||||||
|
// Native failed — LibreOffice fallback ONLY if soffice exists.
|
||||||
|
final soffice = await resolveSoffice();
|
||||||
|
if (soffice != null) {
|
||||||
|
final loImages = await _convertViaLibreOffice(pptxPath);
|
||||||
|
if (loImages.isNotEmpty) return loImages;
|
||||||
|
}
|
||||||
|
return _generatePlaceholderImages(pptxPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file picker dialog and return the selected PPTX path, or null.
|
||||||
|
Future<String?> openPptxFile() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pptx', 'ppt'],
|
||||||
|
);
|
||||||
|
final files = result?.files;
|
||||||
|
if (files == null || files.isEmpty) return null;
|
||||||
|
return files.first.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy / LibreOffice helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<String> _extractTextLegacy(String pptxPath) async {
|
||||||
final tmpDir = await _makeTmpDir('pptx_text');
|
final tmpDir = await _makeTmpDir('pptx_text');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Unzip the PPTX
|
|
||||||
final unzipResult = await Process.run('unzip', [
|
final unzipResult = await Process.run('unzip', [
|
||||||
'-o',
|
'-o',
|
||||||
'-q',
|
'-q',
|
||||||
@@ -35,7 +94,6 @@ class PptxService {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all slide XML files
|
|
||||||
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
||||||
if (!await slidesDir.exists()) return '';
|
if (!await slidesDir.exists()) return '';
|
||||||
|
|
||||||
@@ -44,7 +102,6 @@ class PptxService {
|
|||||||
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
|
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Sort by slide number
|
|
||||||
slideFiles.sort((a, b) {
|
slideFiles.sort((a, b) {
|
||||||
final aNum = _extractSlideNumber(a.path);
|
final aNum = _extractSlideNumber(a.path);
|
||||||
final bNum = _extractSlideNumber(b.path);
|
final bNum = _extractSlideNumber(b.path);
|
||||||
@@ -67,47 +124,14 @@ class PptxService {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return '';
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup
|
|
||||||
try {
|
try {
|
||||||
await tmpDir.delete(recursive: true);
|
await tmpDir.delete(recursive: true);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert PPTX slides to a list of image file paths.
|
|
||||||
///
|
|
||||||
/// Attempts LibreOffice headless conversion first. Falls back to
|
|
||||||
/// generating placeholder slide images (colored rectangles with slide numbers).
|
|
||||||
Future<List<String>> convertToImages(String pptxPath) async {
|
|
||||||
// Try LibreOffice first
|
|
||||||
final loImages = await _convertViaLibreOffice(pptxPath);
|
|
||||||
if (loImages.isNotEmpty) return loImages;
|
|
||||||
|
|
||||||
// Fallback: generate placeholder images
|
|
||||||
return _generatePlaceholderImages(pptxPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a file picker dialog and return the selected PPTX path, or null.
|
|
||||||
///
|
|
||||||
/// Uses the cross-platform file_picker package.
|
|
||||||
Future<String?> openPptxFile() async {
|
|
||||||
final result = await FilePicker.platform.pickFiles(
|
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['pptx', 'ppt'],
|
|
||||||
);
|
|
||||||
final files = result?.files;
|
|
||||||
if (files == null || files.isEmpty) return null;
|
|
||||||
return files.first.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Implementation helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Extract text from PPTX slide XML by finding `<a:t>` content.
|
|
||||||
String _extractTextFromXml(String xml) {
|
String _extractTextFromXml(String xml) {
|
||||||
final lines = <String>[];
|
final lines = <String>[];
|
||||||
// Match <a:t>...</a:t> — handles both <a:t>text</a:t> and <a:t xml:space="preserve">text</a:t>
|
|
||||||
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
|
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
|
||||||
for (final match in regex.allMatches(xml)) {
|
for (final match in regex.allMatches(xml)) {
|
||||||
final text = match.group(1) ?? '';
|
final text = match.group(1) ?? '';
|
||||||
@@ -124,16 +148,16 @@ class PptxService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try converting via LibreOffice headless.
|
/// LibreOffice fallback — only when native fails or caller wants PNGs and
|
||||||
|
/// soffice is installed.
|
||||||
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
||||||
try {
|
try {
|
||||||
// Check if LibreOffice is available
|
final soffice = await resolveSoffice();
|
||||||
final which = await Process.run('which', ['libreoffice']);
|
if (soffice == null) return [];
|
||||||
if (which.exitCode != 0) return [];
|
|
||||||
|
|
||||||
final outDir = await _makeTmpDir('pptx_images');
|
final outDir = await _makeTmpDir('pptx_images');
|
||||||
|
|
||||||
final result = await Process.run('libreoffice', [
|
final result = await Process.run(soffice, [
|
||||||
'--headless',
|
'--headless',
|
||||||
'--convert-to',
|
'--convert-to',
|
||||||
'png',
|
'png',
|
||||||
@@ -144,7 +168,6 @@ class PptxService {
|
|||||||
|
|
||||||
if (result.exitCode != 0) return [];
|
if (result.exitCode != 0) return [];
|
||||||
|
|
||||||
// Collect generated PNGs, sorted by name
|
|
||||||
final pngs = await outDir
|
final pngs = await outDir
|
||||||
.list()
|
.list()
|
||||||
.where((f) => f.path.endsWith('.png'))
|
.where((f) => f.path.endsWith('.png'))
|
||||||
@@ -153,7 +176,6 @@ class PptxService {
|
|||||||
|
|
||||||
pngs.sort();
|
pngs.sort();
|
||||||
|
|
||||||
// Move to a persistent temp location so outDir can be cleaned up
|
|
||||||
final persistDir = await _makeTmpDir('pptx_slides');
|
final persistDir = await _makeTmpDir('pptx_slides');
|
||||||
final persistentPaths = <String>[];
|
final persistentPaths = <String>[];
|
||||||
for (var i = 0; i < pngs.length; i++) {
|
for (var i = 0; i < pngs.length; i++) {
|
||||||
@@ -163,7 +185,6 @@ class PptxService {
|
|||||||
persistentPaths.add(dst);
|
persistentPaths.add(dst);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the LibreOffice output dir
|
|
||||||
try {
|
try {
|
||||||
await outDir.delete(recursive: true);
|
await outDir.delete(recursive: true);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -174,20 +195,68 @@ class PptxService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate placeholder slide images when LibreOffice is not available.
|
/// Resolve the LibreOffice CLI binary, or null when unavailable.
|
||||||
///
|
static Future<String?> resolveSoffice() async {
|
||||||
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
if (Platform.isWindows) {
|
||||||
/// If ImageMagick is not available, writes minimal 1x1 white PNGs as
|
const candidates = [
|
||||||
/// last-resort placeholders.
|
r'C:\Program Files\LibreOffice\program\soffice.exe',
|
||||||
|
r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
|
||||||
|
];
|
||||||
|
for (final c in candidates) {
|
||||||
|
if (await File(c).exists()) return c;
|
||||||
|
}
|
||||||
|
if (await _whichOk('soffice')) return 'soffice';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (await _whichOk('libreoffice')) return 'libreoffice';
|
||||||
|
if (await _whichOk('soffice')) return 'soffice';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> _whichOk(String cmd) async {
|
||||||
|
try {
|
||||||
|
final r = await Process.run('which', [cmd]);
|
||||||
|
return r.exitCode == 0;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an arbitrary office document (e.g. DOCX) to PDF via LibreOffice.
|
||||||
|
/// Optional — native [DocxParser] is preferred for opening in BadNote.
|
||||||
|
Future<String?> convertToPdf(String sourcePath) async {
|
||||||
|
final soffice = await resolveSoffice();
|
||||||
|
if (soffice == null) return null;
|
||||||
|
|
||||||
|
final outDir = p.dirname(sourcePath);
|
||||||
|
try {
|
||||||
|
final result = await Process.run(soffice, [
|
||||||
|
'--headless',
|
||||||
|
'--convert-to',
|
||||||
|
'pdf',
|
||||||
|
'--outdir',
|
||||||
|
outDir,
|
||||||
|
sourcePath,
|
||||||
|
]);
|
||||||
|
if (result.exitCode != 0) return null;
|
||||||
|
final pdfPath = p.join(
|
||||||
|
outDir,
|
||||||
|
'${p.basenameWithoutExtension(sourcePath)}.pdf',
|
||||||
|
);
|
||||||
|
if (await File(pdfPath).exists()) return pdfPath;
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
|
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
|
||||||
// Count slides by unzipping and counting slide XML files
|
final slideCount = await _countSlidesNative(pptxPath);
|
||||||
final slideCount = await _countSlides(pptxPath);
|
|
||||||
if (slideCount == 0) return [];
|
if (slideCount == 0) return [];
|
||||||
|
|
||||||
final outDir = await _makeTmpDir('pptx_placeholders');
|
final outDir = await _makeTmpDir('pptx_placeholders');
|
||||||
final paths = <String>[];
|
final paths = <String>[];
|
||||||
|
|
||||||
// Try ImageMagick
|
|
||||||
final hasConvert = await _hasCommand('convert');
|
final hasConvert = await _hasCommand('convert');
|
||||||
|
|
||||||
for (var i = 1; i <= slideCount; i++) {
|
for (var i = 1; i <= slideCount; i++) {
|
||||||
@@ -203,7 +272,16 @@ class PptxService {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> _countSlides(String pptxPath) async {
|
Future<int> _countSlidesNative(String pptxPath) async {
|
||||||
|
try {
|
||||||
|
final parsed = await _parser.parse(pptxPath);
|
||||||
|
return parsed.slides.length;
|
||||||
|
} catch (_) {
|
||||||
|
return _countSlidesUnzip(pptxPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _countSlidesUnzip(String pptxPath) async {
|
||||||
final tmpDir = await _makeTmpDir('pptx_count');
|
final tmpDir = await _makeTmpDir('pptx_count');
|
||||||
try {
|
try {
|
||||||
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
|
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
|
||||||
@@ -237,8 +315,7 @@ class PptxService {
|
|||||||
int slideNum,
|
int slideNum,
|
||||||
int total,
|
int total,
|
||||||
) async {
|
) async {
|
||||||
// Light pastel background with slide number
|
final hue = ((slideNum - 1) * 137) % 360;
|
||||||
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
|
|
||||||
await Process.run('convert', [
|
await Process.run('convert', [
|
||||||
'-size',
|
'-size',
|
||||||
'1920x1080',
|
'1920x1080',
|
||||||
@@ -256,30 +333,24 @@ class PptxService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write a minimal valid 1x1 white PNG as an absolute last resort.
|
|
||||||
/// This is a hand-crafted PNG (IHDR + single white pixel IDAT + IEND).
|
|
||||||
Future<void> _writeMinimalPng(String path) async {
|
Future<void> _writeMinimalPng(String path) async {
|
||||||
// Minimal valid 1x1 white PNG
|
|
||||||
const pngBytes = <int>[
|
const pngBytes = <int>[
|
||||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||||
// IHDR chunk
|
0x00, 0x00, 0x00, 0x0D,
|
||||||
0x00, 0x00, 0x00, 0x0D, // length = 13
|
0x49, 0x48, 0x44, 0x52,
|
||||||
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
0x00, 0x00, 0x00, 0x01,
|
||||||
0x00, 0x00, 0x00, 0x01, // width = 1
|
0x00, 0x00, 0x00, 0x01,
|
||||||
0x00, 0x00, 0x00, 0x01, // height = 1
|
0x08, 0x02,
|
||||||
0x08, 0x02, // bit depth = 8, color type = 2 (RGB)
|
0x00, 0x00, 0x00,
|
||||||
0x00, 0x00, 0x00, // compression, filter, interlace
|
0x90, 0x77, 0x53, 0xDE,
|
||||||
0x90, 0x77, 0x53, 0xDE, // CRC
|
0x00, 0x00, 0x00, 0x0C,
|
||||||
// IDAT chunk
|
0x49, 0x44, 0x41, 0x54,
|
||||||
0x00, 0x00, 0x00, 0x0C, // length = 12
|
|
||||||
0x49, 0x44, 0x41, 0x54, // "IDAT"
|
|
||||||
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
|
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
|
||||||
0x01, 0x01, 0x01, 0x00, // compressed data
|
0x01, 0x01, 0x01, 0x00,
|
||||||
0x18, 0xDD, 0x8D, 0xB4, // CRC
|
0x18, 0xDD, 0x8D, 0xB4,
|
||||||
// IEND chunk
|
0x00, 0x00, 0x00, 0x00,
|
||||||
0x00, 0x00, 0x00, 0x00, // length = 0
|
0x49, 0x45, 0x4E, 0x44,
|
||||||
0x49, 0x45, 0x4E, 0x44, // "IEND"
|
0xAE, 0x42, 0x60, 0x82,
|
||||||
0xAE, 0x42, 0x60, 0x82, // CRC
|
|
||||||
];
|
];
|
||||||
await File(path).writeAsBytes(pngBytes);
|
await File(path).writeAsBytes(pngBytes);
|
||||||
}
|
}
|
||||||
|
|||||||
194
lib/services/vault_search_index.dart
Normal file
194
lib/services/vault_search_index.dart
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// lib/services/vault_search_index.dart
|
||||||
|
//
|
||||||
|
// Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||||
|
// storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars
|
||||||
|
// (the source of truth) rather than the demoted SQLite cache.
|
||||||
|
//
|
||||||
|
// What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar:
|
||||||
|
// * the title (standalone notebooks) / source filename (file-backed docs),
|
||||||
|
// * every typed text box — EditorStroke(tool: text).textContent across all
|
||||||
|
// pages,
|
||||||
|
// * the handwriting OCR text persisted in the sidecar's `ocrText` field.
|
||||||
|
//
|
||||||
|
// It ALSO indexes a file-backed PDF's document body text, captured once at
|
||||||
|
// import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded
|
||||||
|
// (printed) text layer for a normal PDF, or background OCR of the rendered pages
|
||||||
|
// for a RASTERIZED / scanned PDF that has no text layer. Matching uses the
|
||||||
|
// existing pure search primitives (normalize / rank / snippet), so CJK substring
|
||||||
|
// search works.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../editor/search/search_ranking.dart';
|
||||||
|
import '../editor/search/search_snippet.dart';
|
||||||
|
import '../storage/badnote_sidecar.dart';
|
||||||
|
import '../storage/sidecar_store.dart';
|
||||||
|
import 'vault_service.dart';
|
||||||
|
|
||||||
|
/// One indexed notebook: where it lives and the text harvested from its sidecar.
|
||||||
|
class VaultSearchEntry {
|
||||||
|
const VaultSearchEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.openPath,
|
||||||
|
required this.docType,
|
||||||
|
required this.text,
|
||||||
|
required this.isNote,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stable id (the notebook folder path).
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Display title (note title / source filename).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Path to pass to the editor: the in-vault source file for docs, or the
|
||||||
|
/// synthetic `<folder>/notebook` note path for standalone notebooks.
|
||||||
|
final String openPath;
|
||||||
|
|
||||||
|
/// `pdf` / `pptx` / `ppt` / `docx` / `notebook`.
|
||||||
|
final String docType;
|
||||||
|
|
||||||
|
/// All searchable text harvested from the sidecar (title + typed text + OCR),
|
||||||
|
/// joined for substring matching.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// True for standalone (free-ink) notebooks, false for file-backed documents.
|
||||||
|
final bool isNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A search hit over the vault: the entry plus a display snippet of the match.
|
||||||
|
class VaultSearchHit {
|
||||||
|
const VaultSearchHit({required this.entry, required this.snippet});
|
||||||
|
|
||||||
|
final VaultSearchEntry entry;
|
||||||
|
final Snippet snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds and queries a scan-based full-text index over the vault sidecars.
|
||||||
|
///
|
||||||
|
/// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a
|
||||||
|
/// pure substring/rank over their harvested text (CJK-safe). Cheap enough to
|
||||||
|
/// rebuild lazily on demand for a single-user vault; there is no background
|
||||||
|
/// thread and no persisted index file (the SQLite cache is no longer the search
|
||||||
|
/// source of truth).
|
||||||
|
///
|
||||||
|
/// HONEST SCOPE (what search covers / does NOT):
|
||||||
|
/// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted
|
||||||
|
/// into a sidecar's `ocrText` field, AND a PDF's document body text persisted
|
||||||
|
/// into `pageText` at import — the embedded text layer, or background OCR of
|
||||||
|
/// a rasterized/scanned PDF (see [PdfTextIndexer]).
|
||||||
|
/// * CAVEAT: `pageText` is only present once import-time indexing has run and
|
||||||
|
/// written it back to the sidecar. A PDF imported before this feature (or
|
||||||
|
/// whose OCR backend was unavailable) has no `pageText`, so only its
|
||||||
|
/// annotations are searchable until it is re-indexed.
|
||||||
|
class VaultSearchIndex {
|
||||||
|
VaultSearchIndex(this._vault);
|
||||||
|
|
||||||
|
final VaultService _vault;
|
||||||
|
|
||||||
|
List<VaultSearchEntry> _entries = const [];
|
||||||
|
bool _built = false;
|
||||||
|
|
||||||
|
/// The entries from the most recent [rebuild] (for tests / inspection).
|
||||||
|
List<VaultSearchEntry> get entries => List.unmodifiable(_entries);
|
||||||
|
|
||||||
|
/// Scan the vault and (re)build the in-memory index. Safe on an empty/missing
|
||||||
|
/// vault (yields an empty index). Never throws on a single unreadable sidecar.
|
||||||
|
Future<void> rebuild() async {
|
||||||
|
final entries = <VaultSearchEntry>[];
|
||||||
|
|
||||||
|
final notebooks = await _vault.scanNotebooks();
|
||||||
|
for (final nb in notebooks) {
|
||||||
|
final sidecar = await SidecarStore.read(
|
||||||
|
File('${nb.sourceFilePath}$kVaultSidecarSuffix'),
|
||||||
|
);
|
||||||
|
entries.add(
|
||||||
|
VaultSearchEntry(
|
||||||
|
id: nb.folderPath,
|
||||||
|
title: nb.filename,
|
||||||
|
openPath: nb.sourceFilePath,
|
||||||
|
docType: nb.docType,
|
||||||
|
text: _harvest(title: nb.filename, sidecar: sidecar),
|
||||||
|
isNote: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final notes = await _vault.scanNotes();
|
||||||
|
for (final note in notes) {
|
||||||
|
final sidecar = await SidecarStore.read(
|
||||||
|
File('${note.notePath}$kVaultSidecarSuffix'),
|
||||||
|
);
|
||||||
|
entries.add(
|
||||||
|
VaultSearchEntry(
|
||||||
|
id: note.folderPath,
|
||||||
|
title: note.title,
|
||||||
|
openPath: note.notePath,
|
||||||
|
docType: 'notebook',
|
||||||
|
text: _harvest(title: note.title, sidecar: sidecar),
|
||||||
|
isNote: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_entries = entries;
|
||||||
|
_built = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the index for [query], rebuilding it first if it has never been
|
||||||
|
/// built. Returns the best-matching notebooks/notes, most-relevant first. An
|
||||||
|
/// empty/whitespace query yields no hits.
|
||||||
|
Future<List<VaultSearchHit>> search(String query) async {
|
||||||
|
if (query.trim().isEmpty) return const [];
|
||||||
|
if (!_built) await rebuild();
|
||||||
|
|
||||||
|
final sources = <String, String>{
|
||||||
|
for (final e in _entries) e.id: e.text,
|
||||||
|
};
|
||||||
|
final ranked = rankHits(sources, query);
|
||||||
|
final byId = {for (final e in _entries) e.id: e};
|
||||||
|
|
||||||
|
final hits = <VaultSearchHit>[];
|
||||||
|
for (final hit in ranked) {
|
||||||
|
final entry = byId[hit.ref];
|
||||||
|
if (entry == null) continue;
|
||||||
|
hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet));
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concatenate every searchable string from one sidecar: the [title], every
|
||||||
|
/// typed text box across all pages, and the persisted handwriting OCR text.
|
||||||
|
static String _harvest({
|
||||||
|
required String title,
|
||||||
|
required BadnoteSidecar? sidecar,
|
||||||
|
}) {
|
||||||
|
final parts = <String>[title];
|
||||||
|
if (sidecar != null) {
|
||||||
|
// Typed text boxes on the pen-first text tool (SidecarText), plus any
|
||||||
|
// legacy stroke-embedded textContent.
|
||||||
|
for (final pageTexts in sidecar.texts.values) {
|
||||||
|
for (final t in pageTexts) {
|
||||||
|
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final pageStrokes in sidecar.strokes.values) {
|
||||||
|
for (final stroke in pageStrokes) {
|
||||||
|
final text = stroke.textContent;
|
||||||
|
if (text != null && text.trim().isNotEmpty) {
|
||||||
|
parts.add(text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final ocr = sidecar.ocrText;
|
||||||
|
if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim());
|
||||||
|
// Document body text captured at import: the PDF's embedded text layer,
|
||||||
|
// or background OCR of a rasterized/scanned PDF. Covers the underlying
|
||||||
|
// document, not just the user's annotations.
|
||||||
|
final body = sidecar.pageText;
|
||||||
|
if (body != null && body.trim().isNotEmpty) parts.add(body.trim());
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
546
lib/services/vault_service.dart
Normal file
546
lib/services/vault_service.dart
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../storage/badnote_sidecar.dart';
|
||||||
|
import '../storage/notebook_manifest.dart';
|
||||||
|
import '../storage/sidecar_store.dart';
|
||||||
|
|
||||||
|
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
|
||||||
|
/// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering
|
||||||
|
/// dependency from the service onto the editor.
|
||||||
|
const String kVaultSidecarSuffix = '.badnote.json';
|
||||||
|
|
||||||
|
/// A notebook discovered by scanning the vault: one folder holding a source
|
||||||
|
/// file (and, optionally, its sidecar). This is the file-backed source of truth
|
||||||
|
/// for the home screen list (the SQLite `documents` table is no longer read).
|
||||||
|
class VaultNotebook {
|
||||||
|
const VaultNotebook({
|
||||||
|
required this.folderPath,
|
||||||
|
required this.sourceFilePath,
|
||||||
|
required this.filename,
|
||||||
|
required this.docType,
|
||||||
|
required this.modified,
|
||||||
|
this.hasSidecar = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Absolute path to the notebook folder.
|
||||||
|
final String folderPath;
|
||||||
|
|
||||||
|
/// Absolute path to the annotatable source file inside the folder.
|
||||||
|
final String sourceFilePath;
|
||||||
|
|
||||||
|
/// Source filename including extension, e.g. `Calculus Lecture 3.pdf`.
|
||||||
|
final String filename;
|
||||||
|
|
||||||
|
/// Lowercased extension without the dot: `pdf` / `pptx` / `ppt` / `docx`.
|
||||||
|
final String docType;
|
||||||
|
|
||||||
|
/// Last-modified time of the source file (used for recency sorting).
|
||||||
|
final DateTime modified;
|
||||||
|
|
||||||
|
/// Whether a `<file>.badnote.json` sidecar exists next to the source file.
|
||||||
|
final bool hasSidecar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basename (without the sidecar suffix) of a standalone notebook's synthetic
|
||||||
|
/// note "source". Opening `SidecarRepository.open('<folder>/notebook', …)`
|
||||||
|
/// therefore writes `<folder>/notebook.badnote.json`.
|
||||||
|
const String kNotebookBaseName = 'notebook';
|
||||||
|
|
||||||
|
/// Full sidecar filename for a standalone (free-ink) notebook folder.
|
||||||
|
const String kNotebookSidecarName = '$kNotebookBaseName$kVaultSidecarSuffix';
|
||||||
|
|
||||||
|
/// A standalone (non-file-backed) free-ink notebook discovered by scanning the
|
||||||
|
/// vault: a folder holding a `notebook.badnote.json` and NO importable source
|
||||||
|
/// file. This is the file-based replacement for the old SQLite `notes` table.
|
||||||
|
class VaultNote {
|
||||||
|
const VaultNote({
|
||||||
|
required this.folderPath,
|
||||||
|
required this.notePath,
|
||||||
|
required this.title,
|
||||||
|
required this.modified,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Absolute path to the notebook folder.
|
||||||
|
final String folderPath;
|
||||||
|
|
||||||
|
/// Synthetic note "source" path `<folder>/notebook`. Pass this to
|
||||||
|
/// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the
|
||||||
|
/// `<folder>/notebook.badnote.json` sidecar. Doubles as the note's stable id.
|
||||||
|
final String notePath;
|
||||||
|
|
||||||
|
/// Display title (from the sidecar's `title`, falling back to the folder name).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Last-modified time of the sidecar (used for recency sorting).
|
||||||
|
final DateTime modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
|
||||||
|
class VaultContainer {
|
||||||
|
const VaultContainer({
|
||||||
|
required this.folderPath,
|
||||||
|
required this.title,
|
||||||
|
required this.modified,
|
||||||
|
required this.memberCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String folderPath;
|
||||||
|
final String title;
|
||||||
|
final DateTime modified;
|
||||||
|
final int memberCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
||||||
|
/// gates app startup behind a valid choice.
|
||||||
|
///
|
||||||
|
/// The vault root is the single folder under which all notebooks will live.
|
||||||
|
/// Phase 0 only persists the path and validates it exists; no data is moved
|
||||||
|
/// into the vault yet (later phases do that).
|
||||||
|
///
|
||||||
|
/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is
|
||||||
|
/// easily mockable: inject a [SharedPreferences] (e.g. from
|
||||||
|
/// `SharedPreferences.setMockInitialValues`) via the constructor for tests.
|
||||||
|
class VaultService {
|
||||||
|
/// SharedPreferences key under which the vault root path is stored.
|
||||||
|
static const String vaultRootKey = 'vaultRoot';
|
||||||
|
|
||||||
|
/// SharedPreferences key gating the one-time SQLite→sidecar migration
|
||||||
|
/// (Phase 5). Set true once the migration completes so it never re-runs.
|
||||||
|
static const String vaultMigrationDoneKey = 'vaultMigrationDone';
|
||||||
|
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
|
||||||
|
VaultService._(this._prefs);
|
||||||
|
|
||||||
|
static VaultService? _instance;
|
||||||
|
|
||||||
|
/// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads
|
||||||
|
/// the shared [SharedPreferences] instance.
|
||||||
|
static Future<VaultService> getInstance() async {
|
||||||
|
if (_instance != null) return _instance!;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final service = VaultService._(prefs);
|
||||||
|
_instance = service;
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only constructor: inject a (typically mock) [SharedPreferences] so
|
||||||
|
/// the vault root can be exercised without platform channels.
|
||||||
|
@visibleForTesting
|
||||||
|
VaultService.forTest(SharedPreferences prefs) : _prefs = prefs;
|
||||||
|
|
||||||
|
/// Test-only: drop the cached singleton so the next [getInstance] rebuilds.
|
||||||
|
@visibleForTesting
|
||||||
|
static void resetForTest() {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently stored vault root path, or null if none has been chosen.
|
||||||
|
String? get vaultRoot => _prefs.getString(vaultRootKey);
|
||||||
|
|
||||||
|
/// Persist [path] as the vault root.
|
||||||
|
Future<void> setVaultRoot(String path) async {
|
||||||
|
await _prefs.setString(vaultRootKey, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the stored vault root (e.g. to re-prompt the user).
|
||||||
|
Future<void> clearVaultRoot() async {
|
||||||
|
await _prefs.remove(vaultRootKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
|
||||||
|
/// When false, startup runs the migrator before opening the home screen.
|
||||||
|
bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;
|
||||||
|
|
||||||
|
/// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
|
||||||
|
Future<void> setVaultMigrationDone() async {
|
||||||
|
await _prefs.setBool(vaultMigrationDoneKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff a vault root is set AND that directory currently exists.
|
||||||
|
///
|
||||||
|
/// Returns false when no path is stored or when the stored path no longer
|
||||||
|
/// resolves to a directory (external drive unplugged, folder deleted) — the
|
||||||
|
/// caller then re-prompts rather than silently scattering data elsewhere.
|
||||||
|
Future<bool> vaultRootValid() async {
|
||||||
|
final path = vaultRoot;
|
||||||
|
if (path == null || path.isEmpty) return false;
|
||||||
|
return Directory(path).exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source-file extensions BadNote can import as notebooks.
|
||||||
|
static const Set<String> importableExtensions = {'pdf', 'docx', 'pptx', 'ppt'};
|
||||||
|
|
||||||
|
/// Create a notebook FOLDER under the vault root, COPY the source file at
|
||||||
|
/// [sourceFilePath] into it, and return the path of the in-vault copy.
|
||||||
|
///
|
||||||
|
/// The folder name is the sanitized source basename (without extension),
|
||||||
|
/// de-duplicated with a numeric suffix on collision (`Lecture`, `Lecture 2`,
|
||||||
|
/// …). The sidecar (`<file>.badnote.json`) will live next to the copy — the
|
||||||
|
/// [SidecarRepository] keys off the returned path, so nothing else is needed.
|
||||||
|
///
|
||||||
|
/// Throws [StateError] if no valid vault root is set.
|
||||||
|
Future<String> createNotebook(String sourceFilePath) async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
throw StateError('No vault root is set; cannot create a notebook.');
|
||||||
|
}
|
||||||
|
final source = File(sourceFilePath);
|
||||||
|
final filename = p.basename(sourceFilePath);
|
||||||
|
final baseName = _sanitizeFolderName(p.basenameWithoutExtension(filename));
|
||||||
|
|
||||||
|
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||||
|
await folder.create(recursive: true);
|
||||||
|
|
||||||
|
final destPath = p.join(folder.path, filename);
|
||||||
|
await source.copy(destPath);
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the vault root for notebook folders. A notebook is a direct
|
||||||
|
/// subfolder (excluding the hidden `.badnote` metadata folder) that contains
|
||||||
|
/// at least one importable source file. Returns the notebooks sorted by
|
||||||
|
/// source-file mtime, most-recent first. An empty / missing vault yields an
|
||||||
|
/// empty list (never throws).
|
||||||
|
/// Scan vault folders that hold a [kNotebookManifestName] container.
|
||||||
|
Future<List<VaultContainer>> scanContainers() async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return const [];
|
||||||
|
final dir = Directory(root);
|
||||||
|
if (!await dir.exists()) return const [];
|
||||||
|
|
||||||
|
final out = <VaultContainer>[];
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! Directory) continue;
|
||||||
|
final folderName = p.basename(entity.path);
|
||||||
|
if (folderName.startsWith('.')) continue;
|
||||||
|
final c = await _readContainerFolder(entity);
|
||||||
|
if (c != null) out.add(c);
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.modified.compareTo(a.modified));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
|
||||||
|
final manifest = await NotebookManifest.read(folder.path);
|
||||||
|
if (manifest == null) return null;
|
||||||
|
final file = NotebookManifest.fileIn(folder.path);
|
||||||
|
final stat = await file.stat();
|
||||||
|
final title = manifest.title.trim().isNotEmpty
|
||||||
|
? manifest.title.trim()
|
||||||
|
: p.basename(folder.path);
|
||||||
|
return VaultContainer(
|
||||||
|
folderPath: folder.path,
|
||||||
|
title: title,
|
||||||
|
modified: stat.modified,
|
||||||
|
memberCount: manifest.members.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an OneNote-style notebook container with one blank ink page.
|
||||||
|
Future<VaultContainer> createNotebookContainer(String title) async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
throw StateError('No vault root is set; cannot create a notebook.');
|
||||||
|
}
|
||||||
|
final trimmed = title.trim();
|
||||||
|
final baseName = _sanitizeFolderName(trimmed);
|
||||||
|
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||||
|
await folder.create(recursive: true);
|
||||||
|
|
||||||
|
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final pageRel = p.join('pages', pageId, kNotebookBaseName);
|
||||||
|
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
|
||||||
|
await pageDir.create(recursive: true);
|
||||||
|
|
||||||
|
final notePath = p.join(folder.path, pageRel);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: pageTitle,
|
||||||
|
pageCount: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final manifest = NotebookManifest(
|
||||||
|
title: pageTitle,
|
||||||
|
members: [
|
||||||
|
NotebookMember(
|
||||||
|
id: pageId,
|
||||||
|
kind: NotebookMemberKind.note,
|
||||||
|
relativePath: pageRel.replaceAll('\\', '/'),
|
||||||
|
title: pageTitle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(folder.path, manifest);
|
||||||
|
|
||||||
|
return VaultContainer(
|
||||||
|
folderPath: folder.path,
|
||||||
|
title: pageTitle,
|
||||||
|
modified: now,
|
||||||
|
memberCount: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a blank ink page to an existing container. Returns the new member.
|
||||||
|
Future<NotebookMember> addBlankPageToContainer(
|
||||||
|
String folderPath, {
|
||||||
|
String title = 'Untitled page',
|
||||||
|
}) async {
|
||||||
|
final manifest = await NotebookManifest.read(folderPath);
|
||||||
|
if (manifest == null) {
|
||||||
|
throw StateError('Not a notebook container: $folderPath');
|
||||||
|
}
|
||||||
|
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final pageRel = 'pages/$pageId/$kNotebookBaseName';
|
||||||
|
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
|
||||||
|
await pageDir.create(recursive: true);
|
||||||
|
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: title,
|
||||||
|
pageCount: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final member = NotebookMember(
|
||||||
|
id: pageId,
|
||||||
|
kind: NotebookMemberKind.note,
|
||||||
|
relativePath: pageRel,
|
||||||
|
title: title,
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(
|
||||||
|
folderPath,
|
||||||
|
manifest.copyWith(members: [...manifest.members, member]),
|
||||||
|
);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy [sourceAbsolutePath] into the container and register it as a member.
|
||||||
|
Future<NotebookMember> importFileIntoContainer(
|
||||||
|
String folderPath,
|
||||||
|
String sourceAbsolutePath,
|
||||||
|
) async {
|
||||||
|
final manifest = await NotebookManifest.read(folderPath);
|
||||||
|
if (manifest == null) {
|
||||||
|
throw StateError('Not a notebook container: $folderPath');
|
||||||
|
}
|
||||||
|
final basename = p.basename(sourceAbsolutePath);
|
||||||
|
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
|
||||||
|
final kind = notebookMemberKindFromExt(ext);
|
||||||
|
if (kind == null || kind == NotebookMemberKind.note) {
|
||||||
|
throw StateError('Unsupported import type: $ext');
|
||||||
|
}
|
||||||
|
final destRel = basename;
|
||||||
|
var destPath = p.join(folderPath, destRel);
|
||||||
|
var n = 2;
|
||||||
|
while (await File(destPath).exists()) {
|
||||||
|
final stem = p.basenameWithoutExtension(basename);
|
||||||
|
destPath = p.join(folderPath, '$stem $n.$ext');
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
await File(sourceAbsolutePath).copy(destPath);
|
||||||
|
final member = NotebookMember(
|
||||||
|
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
kind: kind,
|
||||||
|
relativePath: p.basename(destPath),
|
||||||
|
title: p.basenameWithoutExtension(destPath),
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(
|
||||||
|
folderPath,
|
||||||
|
manifest.copyWith(members: [...manifest.members, member]),
|
||||||
|
);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path for a member inside [folderPath].
|
||||||
|
String memberAbsolutePath(String folderPath, NotebookMember member) =>
|
||||||
|
p.normalize(p.join(folderPath, member.relativePath));
|
||||||
|
|
||||||
|
Future<List<VaultNotebook>> scanNotebooks() async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return const [];
|
||||||
|
final dir = Directory(root);
|
||||||
|
if (!await dir.exists()) return const [];
|
||||||
|
|
||||||
|
final notebooks = <VaultNotebook>[];
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! Directory) continue;
|
||||||
|
final folderName = p.basename(entity.path);
|
||||||
|
if (folderName.startsWith('.')) continue;
|
||||||
|
// Container folders are listed by [scanContainers], not here.
|
||||||
|
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||||
|
|
||||||
|
final notebook = await _readNotebookFolder(entity);
|
||||||
|
if (notebook != null) notebooks.add(notebook);
|
||||||
|
}
|
||||||
|
|
||||||
|
notebooks.sort((a, b) => b.modified.compareTo(a.modified));
|
||||||
|
return notebooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an empty (free-ink) standalone notebook FOLDER under the vault root
|
||||||
|
/// named from [title], write an initial `notebook.badnote.json` carrying that
|
||||||
|
/// title (so the scan sees it immediately), and return the synthetic note
|
||||||
|
/// path `<folder>/notebook`.
|
||||||
|
///
|
||||||
|
/// Pass the returned path to `SidecarRepository.open(path, docType:
|
||||||
|
/// 'notebook')`, which keys the folder's `notebook.badnote.json` — there is
|
||||||
|
/// NO fake source file. Throws [StateError] if no valid vault root is set.
|
||||||
|
Future<String> createEmptyNotebook(String title) async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
throw StateError('No vault root is set; cannot create a notebook.');
|
||||||
|
}
|
||||||
|
final trimmed = title.trim();
|
||||||
|
final baseName = _sanitizeFolderName(trimmed);
|
||||||
|
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||||
|
await folder.create(recursive: true);
|
||||||
|
|
||||||
|
final notePath = p.join(folder.path, kNotebookBaseName);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final sidecar = BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: trimmed.isEmpty ? null : trimmed,
|
||||||
|
pageCount: 1,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
sidecar,
|
||||||
|
);
|
||||||
|
return notePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the vault root for standalone (free-ink) notebook folders: direct
|
||||||
|
/// subfolders (excluding hidden `.` folders) that contain a
|
||||||
|
/// `notebook.badnote.json` and NO importable source file. Returns them sorted
|
||||||
|
/// by sidecar mtime, most-recent first. Missing / empty vault → empty list.
|
||||||
|
///
|
||||||
|
/// File-backed document folders (which DO hold an importable source file) are
|
||||||
|
/// surfaced by [scanNotebooks] instead, so the two scans never overlap.
|
||||||
|
Future<List<VaultNote>> scanNotes() async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return const [];
|
||||||
|
final dir = Directory(root);
|
||||||
|
if (!await dir.exists()) return const [];
|
||||||
|
|
||||||
|
final notes = <VaultNote>[];
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! Directory) continue;
|
||||||
|
final folderName = p.basename(entity.path);
|
||||||
|
if (folderName.startsWith('.')) continue;
|
||||||
|
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||||
|
|
||||||
|
final note = await _readNoteFolder(entity);
|
||||||
|
if (note != null) notes.add(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.sort((a, b) => b.modified.compareTo(a.modified));
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect a folder, returning a [VaultNote] iff it holds a
|
||||||
|
/// `notebook.badnote.json` and NO importable source file, else null.
|
||||||
|
Future<VaultNote?> _readNoteFolder(Directory folder) async {
|
||||||
|
File? noteSidecar;
|
||||||
|
var hasSource = false;
|
||||||
|
await for (final entity in folder.list(followLinks: false)) {
|
||||||
|
if (entity is! File) continue;
|
||||||
|
final name = p.basename(entity.path);
|
||||||
|
if (name == kNotebookSidecarName) {
|
||||||
|
noteSidecar = entity;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.endsWith(kVaultSidecarSuffix)) continue;
|
||||||
|
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
|
||||||
|
if (importableExtensions.contains(ext)) hasSource = true;
|
||||||
|
}
|
||||||
|
if (noteSidecar == null || hasSource) return null;
|
||||||
|
|
||||||
|
final notePath = p.join(folder.path, kNotebookBaseName);
|
||||||
|
final loaded = await SidecarStore.read(noteSidecar);
|
||||||
|
final stat = await noteSidecar.stat();
|
||||||
|
final title = (loaded?.title?.trim().isNotEmpty ?? false)
|
||||||
|
? loaded!.title!.trim()
|
||||||
|
: p.basename(folder.path);
|
||||||
|
return VaultNote(
|
||||||
|
folderPath: folder.path,
|
||||||
|
notePath: notePath,
|
||||||
|
title: title,
|
||||||
|
modified: stat.modified,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect a single notebook folder, returning a [VaultNotebook] when it
|
||||||
|
/// holds an importable source file, else null. Picks the first importable
|
||||||
|
/// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF).
|
||||||
|
Future<VaultNotebook?> _readNotebookFolder(Directory folder) async {
|
||||||
|
File? chosen;
|
||||||
|
String? chosenExt;
|
||||||
|
await for (final entity in folder.list(followLinks: false)) {
|
||||||
|
if (entity is! File) continue;
|
||||||
|
final name = p.basename(entity.path);
|
||||||
|
if (name.endsWith(kVaultSidecarSuffix)) continue;
|
||||||
|
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
|
||||||
|
if (!importableExtensions.contains(ext)) continue;
|
||||||
|
// Prefer a PDF artifact when present (DOCX-converted notebooks keep both).
|
||||||
|
if (chosen == null || (ext == 'pdf' && chosenExt != 'pdf')) {
|
||||||
|
chosen = entity;
|
||||||
|
chosenExt = ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen == null || chosenExt == null) return null;
|
||||||
|
|
||||||
|
final stat = await chosen.stat();
|
||||||
|
final sidecar = File('${chosen.path}$kVaultSidecarSuffix');
|
||||||
|
return VaultNotebook(
|
||||||
|
folderPath: folder.path,
|
||||||
|
sourceFilePath: chosen.path,
|
||||||
|
filename: p.basename(chosen.path),
|
||||||
|
docType: chosenExt,
|
||||||
|
modified: stat.modified,
|
||||||
|
hasSidecar: await sidecar.exists(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find an unused notebook folder under [root] for [baseName], appending a
|
||||||
|
/// ` 2`, ` 3`, … suffix on collision.
|
||||||
|
Future<Directory> _uniqueNotebookFolder(String root, String baseName) async {
|
||||||
|
final safeBase = baseName.isEmpty ? 'Untitled' : baseName;
|
||||||
|
var candidate = Directory(p.join(root, safeBase));
|
||||||
|
var n = 2;
|
||||||
|
while (await candidate.exists()) {
|
||||||
|
candidate = Directory(p.join(root, '$safeBase $n'));
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a basename into a safe folder name: strip characters illegal on
|
||||||
|
/// Windows/POSIX (`\ / : * ? " < > |`) and control chars, collapse
|
||||||
|
/// whitespace, and trim trailing dots/spaces (illegal on Windows).
|
||||||
|
static String _sanitizeFolderName(String name) {
|
||||||
|
final cleaned = name
|
||||||
|
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim()
|
||||||
|
.replaceAll(RegExp(r'[. ]+$'), '');
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
348
lib/services/webdav_client.dart
Normal file
348
lib/services/webdav_client.dart
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// lib/services/webdav_client.dart
|
||||||
|
//
|
||||||
|
// A minimal WebDAV client abstraction for vault sync. The [WebDavClient]
|
||||||
|
// interface is intentionally tiny (the four verbs the sync algorithm needs:
|
||||||
|
// list / download / upload / mkcol) so that:
|
||||||
|
// * the sync ALGORITHM in WebDavSyncService can be unit-tested against a
|
||||||
|
// FAKE in-memory implementation (no real server), and
|
||||||
|
// * the real network adapter ([HttpWebDavClient]) stays a thin shim over
|
||||||
|
// `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL).
|
||||||
|
//
|
||||||
|
// Paths handled here are REMOTE paths relative to the configured remote root,
|
||||||
|
// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths
|
||||||
|
// to/from these remote paths lives in WebDavSyncService.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
/// One remote resource returned by a directory listing (PROPFIND).
|
||||||
|
class RemoteEntry {
|
||||||
|
const RemoteEntry({
|
||||||
|
required this.path,
|
||||||
|
required this.isDirectory,
|
||||||
|
this.modified,
|
||||||
|
this.size,
|
||||||
|
this.etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Remote path RELATIVE to the configured remote root, forward-slashed and
|
||||||
|
/// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no
|
||||||
|
/// trailing slash here (normalized by the client).
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
/// Whether this entry is a collection (directory) rather than a file.
|
||||||
|
final bool isDirectory;
|
||||||
|
|
||||||
|
/// Server last-modified time (UTC) if the server reported one.
|
||||||
|
final DateTime? modified;
|
||||||
|
|
||||||
|
/// Content length in bytes if reported (files only).
|
||||||
|
final int? size;
|
||||||
|
|
||||||
|
/// Weak/strong ETag if reported (quotes stripped).
|
||||||
|
final String? etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown by [WebDavClient] implementations for any transport/protocol error.
|
||||||
|
/// Carries a human-readable [message] suitable for surfacing in the UI.
|
||||||
|
class WebDavException implements Exception {
|
||||||
|
WebDavException(this.message, {this.statusCode});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'WebDavException($message'
|
||||||
|
'${statusCode != null ? ', status: $statusCode' : ''})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The four WebDAV operations the sync algorithm depends on. Inject a fake in
|
||||||
|
/// tests; inject [HttpWebDavClient] in production.
|
||||||
|
abstract class WebDavClient {
|
||||||
|
/// List the immediate-and-nested files under [remoteDir] (relative to the
|
||||||
|
/// remote root, `''` meaning the root itself). Returns every FILE found in
|
||||||
|
/// the subtree (directories are created on demand via [makeCollection], so
|
||||||
|
/// callers care about files). Implementations PROPFIND with Depth: infinity
|
||||||
|
/// and flatten the result. A missing remote dir yields an empty list.
|
||||||
|
Future<List<RemoteEntry>> list(String remoteDir);
|
||||||
|
|
||||||
|
/// Download the bytes of the remote file at [remotePath].
|
||||||
|
Future<Uint8List> download(String remotePath);
|
||||||
|
|
||||||
|
/// Upload [bytes] to [remotePath], creating/overwriting the remote file.
|
||||||
|
/// Parent collections must already exist (use [makeCollection]).
|
||||||
|
Future<void> upload(String remotePath, Uint8List bytes);
|
||||||
|
|
||||||
|
/// Create the collection (directory) at [remotePath]. Idempotent: an
|
||||||
|
/// already-existing collection is not an error.
|
||||||
|
Future<void> makeCollection(String remotePath);
|
||||||
|
|
||||||
|
/// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
|
||||||
|
/// Throws [WebDavException] on failure; returns normally on success.
|
||||||
|
Future<void> testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real WebDAV adapter over `package:http`. Thin by design — all the sync
|
||||||
|
/// decision logic lives in WebDavSyncService, NOT here.
|
||||||
|
///
|
||||||
|
/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips
|
||||||
|
/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below
|
||||||
|
/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync
|
||||||
|
/// algorithm that consumes it is what the unit tests cover, via a fake client.
|
||||||
|
class HttpWebDavClient implements WebDavClient {
|
||||||
|
HttpWebDavClient({
|
||||||
|
required String baseUrl,
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
String remoteRoot = '',
|
||||||
|
http.Client? httpClient,
|
||||||
|
this.timeout = const Duration(seconds: 30),
|
||||||
|
}) : _client = httpClient ?? http.Client(),
|
||||||
|
_ownsClient = httpClient == null,
|
||||||
|
_baseUri = _normalizeBase(baseUrl, remoteRoot),
|
||||||
|
_authHeader =
|
||||||
|
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
final bool _ownsClient;
|
||||||
|
|
||||||
|
/// Absolute base URI INCLUDING the remote root path, always ending in `/`.
|
||||||
|
final Uri _baseUri;
|
||||||
|
final String _authHeader;
|
||||||
|
final Duration timeout;
|
||||||
|
|
||||||
|
/// Combine the server [baseUrl] with the [remoteRoot] folder into a single
|
||||||
|
/// absolute base URI ending in a slash. Tolerates trailing/leading slashes.
|
||||||
|
static Uri _normalizeBase(String baseUrl, String remoteRoot) {
|
||||||
|
var base = baseUrl.trim();
|
||||||
|
if (!base.endsWith('/')) base = '$base/';
|
||||||
|
var uri = Uri.parse(base);
|
||||||
|
final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||||
|
if (root.isNotEmpty) {
|
||||||
|
uri = uri.resolve('${Uri.encodeFull(root)}/');
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a remote-root-relative [remotePath] to an absolute URI.
|
||||||
|
Uri _resolve(String remotePath) {
|
||||||
|
final clean = remotePath.replaceAll(RegExp(r'^/+'), '');
|
||||||
|
if (clean.isEmpty) return _baseUri;
|
||||||
|
// Encode each segment but keep the slashes.
|
||||||
|
final encoded = clean.split('/').map(Uri.encodeComponent).join('/');
|
||||||
|
return _baseUri.resolve(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _headers => {'Authorization': _authHeader};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> testConnection() async {
|
||||||
|
final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'});
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw WebDavException(
|
||||||
|
'Server responded ${res.statusCode}',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RemoteEntry>> list(String remoteDir) async {
|
||||||
|
final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/');
|
||||||
|
final http.Response res;
|
||||||
|
try {
|
||||||
|
res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'});
|
||||||
|
} on WebDavException {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
if (res.statusCode == 404) return const [];
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw WebDavException(
|
||||||
|
'PROPFIND failed (${res.statusCode})',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _parseMultiStatus(res.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List> download(String remotePath) async {
|
||||||
|
final res = await _get(_resolve(remotePath));
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw WebDavException(
|
||||||
|
'Download failed (${res.statusCode})',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.bodyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upload(String remotePath, Uint8List bytes) async {
|
||||||
|
final res = await _put(_resolve(remotePath), bytes);
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw WebDavException(
|
||||||
|
'Upload failed (${res.statusCode})',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> makeCollection(String remotePath) async {
|
||||||
|
final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/');
|
||||||
|
final res = await _send('MKCOL', uri);
|
||||||
|
// 201 created; 405 method-not-allowed means it already exists (fine).
|
||||||
|
if (res.statusCode == 201 || res.statusCode == 405) return;
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw WebDavException(
|
||||||
|
'MKCOL failed (${res.statusCode})',
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping
|
||||||
|
/// collections. Paths are made relative to [_baseUri]'s path and stripped of
|
||||||
|
/// a leading slash.
|
||||||
|
List<RemoteEntry> _parseMultiStatus(String body) {
|
||||||
|
final doc = XmlDocument.parse(body);
|
||||||
|
final basePath = _baseUri.path; // ends with '/'
|
||||||
|
final entries = <RemoteEntry>[];
|
||||||
|
|
||||||
|
for (final response in doc.findAllElements('response', namespace: '*')) {
|
||||||
|
final href = response
|
||||||
|
.findElements('href', namespace: '*')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((_) => true, orElse: () => '');
|
||||||
|
if (href.isEmpty) continue;
|
||||||
|
|
||||||
|
// href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative
|
||||||
|
// (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath.
|
||||||
|
var hrefPath = Uri.parse(href).path;
|
||||||
|
hrefPath = Uri.decodeFull(hrefPath);
|
||||||
|
final decodedBase = Uri.decodeFull(basePath);
|
||||||
|
if (!hrefPath.startsWith(decodedBase)) {
|
||||||
|
// Some servers omit the app prefix; try a looser suffix match.
|
||||||
|
final idx = hrefPath.indexOf(decodedBase);
|
||||||
|
if (idx < 0) continue;
|
||||||
|
hrefPath = hrefPath.substring(idx);
|
||||||
|
}
|
||||||
|
var rel = hrefPath.substring(decodedBase.length);
|
||||||
|
final isDir = rel.endsWith('/');
|
||||||
|
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||||
|
if (rel.isEmpty) continue; // the root collection itself
|
||||||
|
|
||||||
|
final propstat = response.findElements('propstat', namespace: '*');
|
||||||
|
DateTime? modified;
|
||||||
|
int? size;
|
||||||
|
String? etag;
|
||||||
|
var collection = isDir;
|
||||||
|
for (final ps in propstat) {
|
||||||
|
for (final prop in ps.findElements('prop', namespace: '*')) {
|
||||||
|
final lm = prop
|
||||||
|
.findElements('getlastmodified', namespace: '*')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((_) => true, orElse: () => '');
|
||||||
|
if (lm.isNotEmpty) modified = _parseHttpDate(lm);
|
||||||
|
final cl = prop
|
||||||
|
.findElements('getcontentlength', namespace: '*')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((_) => true, orElse: () => '');
|
||||||
|
if (cl.isNotEmpty) size = int.tryParse(cl);
|
||||||
|
final et = prop
|
||||||
|
.findElements('getetag', namespace: '*')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((_) => true, orElse: () => '');
|
||||||
|
if (et.isNotEmpty) etag = et.replaceAll('"', '');
|
||||||
|
if (prop.findAllElements('collection', namespace: '*').isNotEmpty) {
|
||||||
|
collection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (collection) continue; // sync only cares about files
|
||||||
|
entries.add(RemoteEntry(
|
||||||
|
path: rel,
|
||||||
|
isDirectory: false,
|
||||||
|
modified: modified?.toUtc(),
|
||||||
|
size: size,
|
||||||
|
etag: etag,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseHttpDate(String s) {
|
||||||
|
try {
|
||||||
|
return parseHttpDate(s);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> _send(
|
||||||
|
String method,
|
||||||
|
Uri uri, {
|
||||||
|
Map<String, String>? headers,
|
||||||
|
}) async {
|
||||||
|
final req = http.Request(method, uri)..headers.addAll(_headers);
|
||||||
|
if (headers != null) req.headers.addAll(headers);
|
||||||
|
try {
|
||||||
|
final streamed = await _client.send(req).timeout(timeout);
|
||||||
|
return http.Response.fromStream(streamed);
|
||||||
|
} on WebDavException {
|
||||||
|
rethrow;
|
||||||
|
} catch (e) {
|
||||||
|
throw WebDavException('Network error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> _get(Uri uri) async {
|
||||||
|
try {
|
||||||
|
return await _client.get(uri, headers: _headers).timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
throw WebDavException('Network error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<http.Response> _put(Uri uri, Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
return await _client
|
||||||
|
.put(uri, headers: _headers, body: bytes)
|
||||||
|
.timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
throw WebDavException('Network error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release the underlying [http.Client] if this instance created it.
|
||||||
|
void close() {
|
||||||
|
if (_ownsClient) _client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather
|
||||||
|
/// than pulling `http_parser`) since only `getlastmodified` needs it.
|
||||||
|
DateTime? parseHttpDate(String input) {
|
||||||
|
final s = input.trim();
|
||||||
|
// RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT"
|
||||||
|
final months = {
|
||||||
|
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
||||||
|
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12,
|
||||||
|
};
|
||||||
|
final m = RegExp(
|
||||||
|
r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})',
|
||||||
|
).firstMatch(s);
|
||||||
|
if (m == null) return null;
|
||||||
|
final day = int.parse(m.group(1)!);
|
||||||
|
final month = months[m.group(2)!];
|
||||||
|
if (month == null) return null;
|
||||||
|
final year = int.parse(m.group(3)!);
|
||||||
|
final hour = int.parse(m.group(4)!);
|
||||||
|
final min = int.parse(m.group(5)!);
|
||||||
|
final sec = int.parse(m.group(6)!);
|
||||||
|
return DateTime.utc(year, month, day, hour, min, sec);
|
||||||
|
}
|
||||||
573
lib/services/webdav_sync_service.dart
Normal file
573
lib/services/webdav_sync_service.dart
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
// lib/services/webdav_sync_service.dart
|
||||||
|
//
|
||||||
|
// Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote.
|
||||||
|
// The vault is a flat folder of notebook subfolders, each holding a source file
|
||||||
|
// plus its `<file>.badnote.json` sidecar; sync operates on the FILES only and
|
||||||
|
// never touches editors or storage formats.
|
||||||
|
//
|
||||||
|
// DESIGN: all sync DECISION logic (per-file winner, conflict handling, path
|
||||||
|
// mapping, last-synced bookkeeping) lives here and is injected with a
|
||||||
|
// [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires
|
||||||
|
// an [HttpWebDavClient]. The real network round-trip is device/server-validated
|
||||||
|
// only (no WebDAV server in CI).
|
||||||
|
//
|
||||||
|
// ALGORITHM (per file, keyed by its vault-root-relative path):
|
||||||
|
// Let L = local state (exists? mtime), R = remote state (exists? mtime),
|
||||||
|
// and B = the per-file LAST-SYNCED baseline we stored after the previous sync
|
||||||
|
// (the mtime we last reconciled to, or absent for never-synced files).
|
||||||
|
//
|
||||||
|
// * local-only (L, !R) -> upload L (create remote dirs)
|
||||||
|
// * remote-only (!L, R):
|
||||||
|
// - known-before (B present) -> remote was DELETED by peer? We
|
||||||
|
// do NOT delete locally (conservative);
|
||||||
|
// we re-UPLOAD to restore. [see note]
|
||||||
|
// - never-seen (B absent) -> download R
|
||||||
|
// * both exist (L, R):
|
||||||
|
// - localChanged = L.mtime != B.mtime (or B absent)
|
||||||
|
// - remoteChanged = R.mtime != B.mtime (or B absent)
|
||||||
|
// - neither changed -> skip
|
||||||
|
// - only local changed -> upload L
|
||||||
|
// - only remote changed -> download R
|
||||||
|
// - BOTH changed (true conflict) -> last-write-wins by mtime:
|
||||||
|
// keep the WINNER as the canonical file, write the LOSER's bytes to
|
||||||
|
// `<file>.conflict-<winnerMtimeMillis>` on BOTH sides so NO data is
|
||||||
|
// lost, then converge canonical (upload or download as needed).
|
||||||
|
// After acting, record the converged mtime as the new baseline B.
|
||||||
|
//
|
||||||
|
// Deletes are handled CONSERVATIVELY: we never delete a file on either side as a
|
||||||
|
// result of sync. A file vanishing on one side is treated as "restore from the
|
||||||
|
// other side" rather than "propagate the delete", because we cannot distinguish
|
||||||
|
// an intentional delete from a half-finished transfer without a tombstone log
|
||||||
|
// (a deliberate TODO — see report).
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'webdav_client.dart';
|
||||||
|
|
||||||
|
/// Persisted WebDAV connection + sync configuration.
|
||||||
|
class WebDavConfig {
|
||||||
|
const WebDavConfig({
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
this.remoteRoot = '',
|
||||||
|
this.autoSync = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String baseUrl;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
/// Folder under the server's WebDAV root to sync into, e.g. `BadNote`.
|
||||||
|
final String remoteRoot;
|
||||||
|
|
||||||
|
/// When true, sync runs on launch/resume (non-blocking, failure-tolerant).
|
||||||
|
final bool autoSync;
|
||||||
|
|
||||||
|
/// True once enough is set to attempt a sync (URL present).
|
||||||
|
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||||
|
|
||||||
|
WebDavConfig copyWith({
|
||||||
|
String? baseUrl,
|
||||||
|
String? username,
|
||||||
|
String? password,
|
||||||
|
String? remoteRoot,
|
||||||
|
bool? autoSync,
|
||||||
|
}) {
|
||||||
|
return WebDavConfig(
|
||||||
|
baseUrl: baseUrl ?? this.baseUrl,
|
||||||
|
username: username ?? this.username,
|
||||||
|
password: password ?? this.password,
|
||||||
|
remoteRoot: remoteRoot ?? this.remoteRoot,
|
||||||
|
autoSync: autoSync ?? this.autoSync,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI.
|
||||||
|
class SyncResult {
|
||||||
|
const SyncResult({
|
||||||
|
this.uploaded = 0,
|
||||||
|
this.downloaded = 0,
|
||||||
|
this.conflicts = 0,
|
||||||
|
this.skipped = 0,
|
||||||
|
this.finishedAt,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int uploaded;
|
||||||
|
final int downloaded;
|
||||||
|
final int conflicts;
|
||||||
|
final int skipped;
|
||||||
|
final DateTime? finishedAt;
|
||||||
|
|
||||||
|
/// Friendly error message when the run failed wholesale; null on success.
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
bool get ok => error == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to do with one file after comparing local/remote/baseline.
|
||||||
|
@visibleForTesting
|
||||||
|
enum SyncAction { skip, upload, download, conflict }
|
||||||
|
|
||||||
|
/// A single planned per-file decision (exposed for testing the algorithm).
|
||||||
|
@visibleForTesting
|
||||||
|
class SyncDecision {
|
||||||
|
const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal});
|
||||||
|
final String relPath;
|
||||||
|
final SyncAction action;
|
||||||
|
|
||||||
|
/// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the
|
||||||
|
/// remote copy is the loser kept as `.conflict-*`; false if remote won.
|
||||||
|
final bool? conflictWinnerIsLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-file last-synced baseline. After each sync we record BOTH sides'
|
||||||
|
/// observed mtimes, because an upload makes the server stamp its OWN mtime (≠
|
||||||
|
/// the local one) — a single shared timestamp would then look "changed" on the
|
||||||
|
/// next run. Comparing each side to its own baseline avoids that false conflict.
|
||||||
|
@visibleForTesting
|
||||||
|
class SyncBaseline {
|
||||||
|
const SyncBaseline({this.localMtime, this.remoteMtime});
|
||||||
|
final DateTime? localMtime;
|
||||||
|
final DateTime? remoteMtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact local/remote snapshot of a file for the decision function.
|
||||||
|
@visibleForTesting
|
||||||
|
class FileFacts {
|
||||||
|
const FileFacts({
|
||||||
|
required this.relPath,
|
||||||
|
required this.localMtime,
|
||||||
|
required this.remoteMtime,
|
||||||
|
this.baseline,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String relPath;
|
||||||
|
|
||||||
|
/// Local file mtime (UTC, whole-second), or null if the file is absent.
|
||||||
|
final DateTime? localMtime;
|
||||||
|
|
||||||
|
/// Remote file mtime (UTC, whole-second), or null if absent.
|
||||||
|
final DateTime? remoteMtime;
|
||||||
|
|
||||||
|
/// Per-side mtimes recorded after the last successful sync, or null if never.
|
||||||
|
final SyncBaseline? baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebDavSyncService {
|
||||||
|
WebDavSyncService(this._prefs);
|
||||||
|
|
||||||
|
final SharedPreferences _prefs;
|
||||||
|
|
||||||
|
static const String _kBaseUrl = 'webdav.baseUrl';
|
||||||
|
static const String _kUsername = 'webdav.username';
|
||||||
|
static const String _kPassword = 'webdav.password';
|
||||||
|
static const String _kRemoteRoot = 'webdav.remoteRoot';
|
||||||
|
static const String _kAutoSync = 'webdav.autoSync';
|
||||||
|
static const String _kLastSyncMillis = 'webdav.lastSyncMillis';
|
||||||
|
|
||||||
|
/// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across
|
||||||
|
/// runs — the per-file, per-side baseline that lets us detect which side
|
||||||
|
/// changed since the last successful sync.
|
||||||
|
static const String _kBaselineJson = 'webdav.baseline';
|
||||||
|
|
||||||
|
/// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated
|
||||||
|
/// conflicts from clobbering each other.
|
||||||
|
static const String conflictMarker = '.conflict-';
|
||||||
|
|
||||||
|
// ---- Configuration (SharedPreferences-backed) -------------------------
|
||||||
|
|
||||||
|
WebDavConfig get config => WebDavConfig(
|
||||||
|
baseUrl: _prefs.getString(_kBaseUrl) ?? '',
|
||||||
|
username: _prefs.getString(_kUsername) ?? '',
|
||||||
|
password: _prefs.getString(_kPassword) ?? '',
|
||||||
|
remoteRoot: _prefs.getString(_kRemoteRoot) ?? '',
|
||||||
|
autoSync: _prefs.getBool(_kAutoSync) ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> saveConfig(WebDavConfig c) async {
|
||||||
|
await _prefs.setString(_kBaseUrl, c.baseUrl.trim());
|
||||||
|
await _prefs.setString(_kUsername, c.username);
|
||||||
|
await _prefs.setString(_kPassword, c.password);
|
||||||
|
await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim());
|
||||||
|
await _prefs.setBool(_kAutoSync, c.autoSync);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get lastSyncTime {
|
||||||
|
final millis = _prefs.getInt(_kLastSyncMillis);
|
||||||
|
return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Baseline map -----------------------------------------------------
|
||||||
|
|
||||||
|
Map<String, SyncBaseline> _readBaseline() {
|
||||||
|
final raw = _prefs.getString(_kBaselineJson);
|
||||||
|
if (raw == null || raw.isEmpty) return {};
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return decoded.map((k, v) {
|
||||||
|
final m = v as Map<String, dynamic>;
|
||||||
|
final lm = m['l'] as int?;
|
||||||
|
final rm = m['r'] as int?;
|
||||||
|
return MapEntry(
|
||||||
|
k,
|
||||||
|
SyncBaseline(
|
||||||
|
localMtime:
|
||||||
|
lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm),
|
||||||
|
remoteMtime:
|
||||||
|
rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeBaseline(Map<String, SyncBaseline> baseline) async {
|
||||||
|
final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, {
|
||||||
|
'l': v.localMtime?.millisecondsSinceEpoch,
|
||||||
|
'r': v.remoteMtime?.millisecondsSinceEpoch,
|
||||||
|
})));
|
||||||
|
await _prefs.setString(_kBaselineJson, encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The pure decision function (unit-tested) -------------------------
|
||||||
|
|
||||||
|
/// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is
|
||||||
|
/// the heart of the algorithm and is exercised directly by the tests.
|
||||||
|
///
|
||||||
|
/// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified`
|
||||||
|
/// has 1-second resolution); callers must truncate before passing facts in.
|
||||||
|
@visibleForTesting
|
||||||
|
static SyncDecision decide(FileFacts f) {
|
||||||
|
final l = f.localMtime;
|
||||||
|
final r = f.remoteMtime;
|
||||||
|
final bl = f.baseline?.localMtime;
|
||||||
|
final br = f.baseline?.remoteMtime;
|
||||||
|
|
||||||
|
if (l != null && r == null) {
|
||||||
|
// Local-only: either brand new locally, or remote vanished. Either way we
|
||||||
|
// (re)upload — never delete the local file.
|
||||||
|
return SyncDecision(f.relPath, SyncAction.upload);
|
||||||
|
}
|
||||||
|
if (l == null && r != null) {
|
||||||
|
// Never synced OR previously known but now gone locally — in both cases we
|
||||||
|
// conservatively pull from remote rather than propagating a delete.
|
||||||
|
return SyncDecision(f.relPath, SyncAction.download);
|
||||||
|
}
|
||||||
|
if (l == null && r == null) {
|
||||||
|
return SyncDecision(f.relPath, SyncAction.skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides have the file. Compare each side to ITS OWN baseline so an
|
||||||
|
// upload-stamped remote mtime isn't mistaken for a remote edit.
|
||||||
|
final localChanged = bl == null || !_sameSecond(l!, bl);
|
||||||
|
final remoteChanged = br == null || !_sameSecond(r!, br);
|
||||||
|
|
||||||
|
if (!localChanged && !remoteChanged) {
|
||||||
|
return SyncDecision(f.relPath, SyncAction.skip);
|
||||||
|
}
|
||||||
|
if (localChanged && !remoteChanged) {
|
||||||
|
return SyncDecision(f.relPath, SyncAction.upload);
|
||||||
|
}
|
||||||
|
if (!localChanged && remoteChanged) {
|
||||||
|
return SyncDecision(f.relPath, SyncAction.download);
|
||||||
|
}
|
||||||
|
// Both changed since baseline -> true conflict. Newer mtime wins.
|
||||||
|
final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy)
|
||||||
|
return SyncDecision(
|
||||||
|
f.relPath,
|
||||||
|
SyncAction.conflict,
|
||||||
|
conflictWinnerIsLocal: localWins,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameSecond(DateTime a, DateTime b) =>
|
||||||
|
a.toUtc().millisecondsSinceEpoch ~/ 1000 ==
|
||||||
|
b.toUtc().millisecondsSinceEpoch ~/ 1000;
|
||||||
|
|
||||||
|
// ---- Path mapping (vault <-> remote), unit-tested ---------------------
|
||||||
|
|
||||||
|
/// Map a vault-root-relative path (OS separators) to a forward-slashed remote
|
||||||
|
/// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`.
|
||||||
|
@visibleForTesting
|
||||||
|
static String toRemotePath(String relPath) =>
|
||||||
|
p.split(relPath).where((s) => s.isNotEmpty).join('/');
|
||||||
|
|
||||||
|
/// Map a forward-slashed remote path back to a vault-root-relative path using
|
||||||
|
/// OS separators.
|
||||||
|
@visibleForTesting
|
||||||
|
static String toLocalRelPath(String remotePath) =>
|
||||||
|
p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty));
|
||||||
|
|
||||||
|
/// True for files sync must IGNORE: sidecar temp/backup artifacts and our own
|
||||||
|
/// conflict copies (conflict copies stay local; they are not re-synced as if
|
||||||
|
/// canonical, but ARE uploaded as plain new files if the user keeps them).
|
||||||
|
@visibleForTesting
|
||||||
|
static bool isSyncable(String relPath) {
|
||||||
|
final name = p.basename(relPath);
|
||||||
|
if (name.startsWith('.')) return false; // hidden / .badnote metadata
|
||||||
|
if (name.endsWith('.tmp') || name.endsWith('.bak')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The orchestrator (I/O; device/server-validated) ------------------
|
||||||
|
|
||||||
|
/// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions
|
||||||
|
/// from [decide] drive uploads/downloads; conflicts keep the loser as a
|
||||||
|
/// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale
|
||||||
|
/// failure returns a [SyncResult] with [SyncResult.error] set (never throws).
|
||||||
|
Future<SyncResult> syncNow({
|
||||||
|
required String vaultRoot,
|
||||||
|
required WebDavClient client,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final root = Directory(vaultRoot);
|
||||||
|
if (!await root.exists()) {
|
||||||
|
return const SyncResult(error: 'Vault folder not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Snapshot both sides keyed by vault-relative path.
|
||||||
|
final localFiles = await _scanLocal(root);
|
||||||
|
final remoteList = await client.list('');
|
||||||
|
final remoteFiles = <String, RemoteEntry>{};
|
||||||
|
for (final e in remoteList) {
|
||||||
|
final rel = toLocalRelPath(e.path);
|
||||||
|
if (isSyncable(rel)) remoteFiles[rel] = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseline = _readBaseline();
|
||||||
|
|
||||||
|
var uploaded = 0;
|
||||||
|
var downloaded = 0;
|
||||||
|
var conflicts = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
|
||||||
|
// Rel paths that ended up converged (and so deserve a fresh baseline).
|
||||||
|
final converged = <String>{};
|
||||||
|
|
||||||
|
final allPaths = <String>{...localFiles.keys, ...remoteFiles.keys};
|
||||||
|
|
||||||
|
for (final rel in allPaths) {
|
||||||
|
final localMtime = localFiles[rel];
|
||||||
|
final remoteEntry = remoteFiles[rel];
|
||||||
|
final facts = FileFacts(
|
||||||
|
relPath: rel,
|
||||||
|
localMtime: localMtime == null ? null : _truncate(localMtime),
|
||||||
|
remoteMtime: remoteEntry?.modified == null
|
||||||
|
? null
|
||||||
|
: _truncate(remoteEntry!.modified!),
|
||||||
|
baseline: baseline[rel],
|
||||||
|
);
|
||||||
|
final decision = decide(facts);
|
||||||
|
|
||||||
|
switch (decision.action) {
|
||||||
|
case SyncAction.skip:
|
||||||
|
skipped++;
|
||||||
|
converged.add(rel);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncAction.upload:
|
||||||
|
await _doUpload(root, client, rel);
|
||||||
|
uploaded++;
|
||||||
|
converged.add(rel);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncAction.download:
|
||||||
|
await _doDownload(root, client, rel, remoteEntry!.modified);
|
||||||
|
downloaded++;
|
||||||
|
converged.add(rel);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SyncAction.conflict:
|
||||||
|
await _doConflict(
|
||||||
|
root,
|
||||||
|
client,
|
||||||
|
rel,
|
||||||
|
localWins: decision.conflictWinnerIsLocal == true,
|
||||||
|
localMtime: localMtime!,
|
||||||
|
remoteMtime: remoteEntry!.modified,
|
||||||
|
);
|
||||||
|
conflicts++;
|
||||||
|
converged.add(rel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-snapshot both sides so the new baseline records each side's ACTUAL
|
||||||
|
// post-sync mtime (uploads make the server stamp its own mtime). Comparing
|
||||||
|
// each side to its own baseline next time avoids a false "remote changed".
|
||||||
|
final finalLocal = await _scanLocal(root);
|
||||||
|
final finalRemote = <String, DateTime?>{};
|
||||||
|
for (final e in await client.list('')) {
|
||||||
|
final r = toLocalRelPath(e.path);
|
||||||
|
if (isSyncable(r)) finalRemote[r] = e.modified;
|
||||||
|
}
|
||||||
|
final newBaseline = <String, SyncBaseline>{};
|
||||||
|
for (final rel in converged) {
|
||||||
|
final lm = finalLocal[rel];
|
||||||
|
final rm = finalRemote[rel];
|
||||||
|
// Only keep a baseline once a file exists on BOTH sides; a one-sided
|
||||||
|
// file (mid-restore) stays "new" so the next run finishes converging it.
|
||||||
|
if (lm != null && rm != null) {
|
||||||
|
newBaseline[rel] = SyncBaseline(
|
||||||
|
localMtime: _truncate(lm),
|
||||||
|
remoteMtime: _truncate(rm),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writeBaseline(newBaseline);
|
||||||
|
final finishedAt = DateTime.now();
|
||||||
|
await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
return SyncResult(
|
||||||
|
uploaded: uploaded,
|
||||||
|
downloaded: downloaded,
|
||||||
|
conflicts: conflicts,
|
||||||
|
skipped: skipped,
|
||||||
|
finishedAt: finishedAt,
|
||||||
|
);
|
||||||
|
} on WebDavException catch (e) {
|
||||||
|
return SyncResult(error: e.message);
|
||||||
|
} catch (e) {
|
||||||
|
return SyncResult(error: e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect every syncable file under [root], keyed by its
|
||||||
|
/// vault-root-relative path, mapped to its mtime.
|
||||||
|
Future<Map<String, DateTime>> _scanLocal(Directory root) async {
|
||||||
|
final out = <String, DateTime>{};
|
||||||
|
await for (final entity in root.list(recursive: true, followLinks: false)) {
|
||||||
|
if (entity is! File) continue;
|
||||||
|
final rel = p.relative(entity.path, from: root.path);
|
||||||
|
// Skip anything inside a hidden folder (e.g. .badnote) or hidden file.
|
||||||
|
if (p.split(rel).any((seg) => seg.startsWith('.'))) continue;
|
||||||
|
if (!isSyncable(rel)) continue;
|
||||||
|
final stat = await entity.stat();
|
||||||
|
out[rel] = stat.modified;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload the local file at [rel], creating remote parent collections.
|
||||||
|
Future<void> _doUpload(Directory root, WebDavClient client, String rel) async {
|
||||||
|
final file = File(p.join(root.path, rel));
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
await _ensureRemoteDirs(client, rel);
|
||||||
|
await client.upload(toRemotePath(rel), bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the remote file at [rel] into the vault, creating local parent
|
||||||
|
/// dirs. Sets the local mtime to the remote's so the next run sees no drift.
|
||||||
|
Future<void> _doDownload(
|
||||||
|
Directory root,
|
||||||
|
WebDavClient client,
|
||||||
|
String rel,
|
||||||
|
DateTime? remoteMtime,
|
||||||
|
) async {
|
||||||
|
final bytes = await client.download(toRemotePath(rel));
|
||||||
|
final file = File(p.join(root.path, rel));
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
if (remoteMtime != null) {
|
||||||
|
try {
|
||||||
|
await file.setLastModified(remoteMtime);
|
||||||
|
} catch (_) {
|
||||||
|
// Some filesystems reject setLastModified; the post-sync re-snapshot
|
||||||
|
// captures whatever mtime landed, so convergence still holds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a true conflict: keep the loser as `<file>.conflict-<winnerMillis>`
|
||||||
|
/// on BOTH sides (no data lost), then converge the canonical to the winner.
|
||||||
|
Future<void> _doConflict(
|
||||||
|
Directory root,
|
||||||
|
WebDavClient client,
|
||||||
|
String rel, {
|
||||||
|
required bool localWins,
|
||||||
|
required DateTime localMtime,
|
||||||
|
required DateTime? remoteMtime,
|
||||||
|
}) async {
|
||||||
|
final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime);
|
||||||
|
final stamp = _truncate(winnerMtime).millisecondsSinceEpoch;
|
||||||
|
final conflictRel = '$rel$conflictMarker$stamp';
|
||||||
|
|
||||||
|
final localFile = File(p.join(root.path, rel));
|
||||||
|
final remoteBytes = await client.download(toRemotePath(rel));
|
||||||
|
|
||||||
|
if (localWins) {
|
||||||
|
// Local is canonical. Save the REMOTE bytes as the local conflict copy,
|
||||||
|
// upload that conflict copy remotely too, then push local up as canonical.
|
||||||
|
final conflictFile = File(p.join(root.path, conflictRel));
|
||||||
|
await conflictFile.parent.create(recursive: true);
|
||||||
|
await conflictFile.writeAsBytes(remoteBytes, flush: true);
|
||||||
|
|
||||||
|
await _ensureRemoteDirs(client, conflictRel);
|
||||||
|
await client.upload(toRemotePath(conflictRel), remoteBytes);
|
||||||
|
|
||||||
|
final localBytes = await localFile.readAsBytes();
|
||||||
|
await _ensureRemoteDirs(client, rel);
|
||||||
|
await client.upload(toRemotePath(rel), localBytes);
|
||||||
|
} else {
|
||||||
|
// Remote is canonical. Save the LOCAL bytes as the local conflict copy
|
||||||
|
// and push it remotely, then overwrite local with the remote (winner).
|
||||||
|
final localBytes = await localFile.readAsBytes();
|
||||||
|
final conflictFile = File(p.join(root.path, conflictRel));
|
||||||
|
await conflictFile.parent.create(recursive: true);
|
||||||
|
await conflictFile.writeAsBytes(localBytes, flush: true);
|
||||||
|
|
||||||
|
await _ensureRemoteDirs(client, conflictRel);
|
||||||
|
await client.upload(toRemotePath(conflictRel), localBytes);
|
||||||
|
|
||||||
|
await localFile.writeAsBytes(remoteBytes, flush: true);
|
||||||
|
if (remoteMtime != null) {
|
||||||
|
try {
|
||||||
|
await localFile.setLastModified(remoteMtime);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create each remote parent collection of [rel] from the root down (MKCOL is
|
||||||
|
/// idempotent), so an upload never 409s on a missing directory.
|
||||||
|
Future<void> _ensureRemoteDirs(WebDavClient client, String rel) async {
|
||||||
|
final segments = p.split(rel).where((s) => s.isNotEmpty).toList();
|
||||||
|
if (segments.length <= 1) return; // file at root, no dirs needed
|
||||||
|
var acc = '';
|
||||||
|
for (var i = 0; i < segments.length - 1; i++) {
|
||||||
|
acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}';
|
||||||
|
await client.makeCollection(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
(t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Convenience: build a real client from the saved config -----------
|
||||||
|
|
||||||
|
/// Construct an [HttpWebDavClient] from the persisted [config], or null when
|
||||||
|
/// unconfigured (caller disables the sync button). Caller owns close().
|
||||||
|
HttpWebDavClient? buildClient() {
|
||||||
|
final c = config;
|
||||||
|
if (!c.isConfigured) return null;
|
||||||
|
return HttpWebDavClient(
|
||||||
|
baseUrl: c.baseUrl,
|
||||||
|
username: c.username,
|
||||||
|
password: c.password,
|
||||||
|
remoteRoot: c.remoteRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
529
lib/storage/badnote_sidecar.dart
Normal file
529
lib/storage/badnote_sidecar.dart
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
// lib/storage/badnote_sidecar.dart
|
||||||
|
//
|
||||||
|
// The on-disk sidecar model: all annotations for one source file, serialized as
|
||||||
|
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
|
||||||
|
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
|
||||||
|
// pure model with NO runtime wiring yet.
|
||||||
|
//
|
||||||
|
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
|
||||||
|
// stroke format. Specifically:
|
||||||
|
// * per-page ink → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
|
||||||
|
// byte-for-byte the `ink.stroke_json` column today)
|
||||||
|
// * scratchpad ink → List<InkStroke> (lib/models/ink_stroke.dart; the exact
|
||||||
|
// format scratchpads already persist, absolute world px)
|
||||||
|
// * scratch anchors → ScratchLink (lib/models/scratch_link.dart)
|
||||||
|
// * bookmarks → Bookmark (lib/models/bookmark.dart)
|
||||||
|
//
|
||||||
|
// Only the *containers* and the (previously in-memory-only) highlight rect are
|
||||||
|
// new here. Unknown JSON fields are ignored on read so the schema is
|
||||||
|
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
|
import '../editor/engine/stroke_model.dart';
|
||||||
|
import '../models/bookmark.dart';
|
||||||
|
import '../models/ink_stroke.dart';
|
||||||
|
import '../models/scratch_link.dart';
|
||||||
|
|
||||||
|
/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
|
||||||
|
/// forward-compat; readers tolerate unknown extra fields.
|
||||||
|
const int kBadnoteSidecarVersion = 1;
|
||||||
|
|
||||||
|
/// A single highlighted text rectangle on a page, normalized to the page rect
|
||||||
|
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
|
||||||
|
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
|
||||||
|
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
|
||||||
|
/// `TODO(persist-highlights)`), so this small value class is the representation.
|
||||||
|
class SidecarHighlight {
|
||||||
|
const SidecarHighlight({
|
||||||
|
required this.l,
|
||||||
|
required this.t,
|
||||||
|
required this.r,
|
||||||
|
required this.b,
|
||||||
|
this.color = 0xFFFFFF00,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Normalized left edge in [0,1].
|
||||||
|
final double l;
|
||||||
|
|
||||||
|
/// Normalized top edge in [0,1].
|
||||||
|
final double t;
|
||||||
|
|
||||||
|
/// Normalized right edge in [0,1].
|
||||||
|
final double r;
|
||||||
|
|
||||||
|
/// Normalized bottom edge in [0,1].
|
||||||
|
final double b;
|
||||||
|
|
||||||
|
/// ARGB color of the highlight.
|
||||||
|
final int color;
|
||||||
|
|
||||||
|
/// Builds a highlight from a normalized [Rect] (as stored in
|
||||||
|
/// `_highlightsByPage`) and an ARGB color.
|
||||||
|
factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
|
||||||
|
SidecarHighlight(
|
||||||
|
l: rect.left,
|
||||||
|
t: rect.top,
|
||||||
|
r: rect.right,
|
||||||
|
b: rect.bottom,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The normalized rect (page-relative) for rendering.
|
||||||
|
Rect toRect() => Rect.fromLTRB(l, t, r, b);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'l': l,
|
||||||
|
't': t,
|
||||||
|
'r': r,
|
||||||
|
'b': b,
|
||||||
|
'color': color,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarHighlight(
|
||||||
|
l: (json['l'] as num).toDouble(),
|
||||||
|
t: (json['t'] as num).toDouble(),
|
||||||
|
r: (json['r'] as num).toDouble(),
|
||||||
|
b: (json['b'] as num).toDouble(),
|
||||||
|
color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarHighlight &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
l == other.l &&
|
||||||
|
t == other.t &&
|
||||||
|
r == other.r &&
|
||||||
|
b == other.b &&
|
||||||
|
color == other.color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(l, t, r, b, color);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single typed-text annotation on a page (PDF editor for now). Position is
|
||||||
|
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
|
||||||
|
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
|
||||||
|
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
|
||||||
|
/// with the page; the editor multiplies it by the on-screen page width.
|
||||||
|
class SidecarText {
|
||||||
|
const SidecarText({
|
||||||
|
required this.id,
|
||||||
|
required this.nx,
|
||||||
|
required this.ny,
|
||||||
|
required this.text,
|
||||||
|
this.fontSize = 0.03,
|
||||||
|
this.color = 0xFF000000,
|
||||||
|
this.fontWeight = 400,
|
||||||
|
this.fontFamily,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stable id (uuid) so edits/deletes address a specific box.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Normalized x of the box's top-left in [0,1].
|
||||||
|
final double nx;
|
||||||
|
|
||||||
|
/// Normalized y of the box's top-left in [0,1].
|
||||||
|
final double ny;
|
||||||
|
|
||||||
|
/// The typed text.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
|
||||||
|
final double fontSize;
|
||||||
|
|
||||||
|
/// ARGB text color.
|
||||||
|
final int color;
|
||||||
|
|
||||||
|
/// CSS-like numeric weight (100–900). Default 400 (regular).
|
||||||
|
final int fontWeight;
|
||||||
|
|
||||||
|
/// Optional family name. Null → editor default (IBM Plex Sans).
|
||||||
|
final String? fontFamily;
|
||||||
|
|
||||||
|
SidecarText copyWith({
|
||||||
|
String? id,
|
||||||
|
double? nx,
|
||||||
|
double? ny,
|
||||||
|
String? text,
|
||||||
|
double? fontSize,
|
||||||
|
int? color,
|
||||||
|
int? fontWeight,
|
||||||
|
String? fontFamily,
|
||||||
|
bool clearFontFamily = false,
|
||||||
|
}) =>
|
||||||
|
SidecarText(
|
||||||
|
id: id ?? this.id,
|
||||||
|
nx: nx ?? this.nx,
|
||||||
|
ny: ny ?? this.ny,
|
||||||
|
text: text ?? this.text,
|
||||||
|
fontSize: fontSize ?? this.fontSize,
|
||||||
|
color: color ?? this.color,
|
||||||
|
fontWeight: fontWeight ?? this.fontWeight,
|
||||||
|
fontFamily:
|
||||||
|
clearFontFamily ? null : (fontFamily ?? this.fontFamily),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'nx': nx,
|
||||||
|
'ny': ny,
|
||||||
|
'text': text,
|
||||||
|
'fontSize': fontSize,
|
||||||
|
'color': color,
|
||||||
|
'fontWeight': fontWeight,
|
||||||
|
if (fontFamily != null) 'fontFamily': fontFamily,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
|
||||||
|
id: json['id'] as String,
|
||||||
|
nx: (json['nx'] as num).toDouble(),
|
||||||
|
ny: (json['ny'] as num).toDouble(),
|
||||||
|
text: (json['text'] as String?) ?? '',
|
||||||
|
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
|
||||||
|
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
||||||
|
fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400,
|
||||||
|
fontFamily: json['fontFamily'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarText &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
id == other.id &&
|
||||||
|
nx == other.nx &&
|
||||||
|
ny == other.ny &&
|
||||||
|
text == other.text &&
|
||||||
|
fontSize == other.fontSize &&
|
||||||
|
color == other.color &&
|
||||||
|
fontWeight == other.fontWeight &&
|
||||||
|
fontFamily == other.fontFamily;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
text,
|
||||||
|
fontSize,
|
||||||
|
color,
|
||||||
|
fontWeight,
|
||||||
|
fontFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
|
||||||
|
'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
|
||||||
|
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
|
||||||
|
/// so it restores (today the canvas always resets to 4000×4000).
|
||||||
|
class SidecarScratchpad {
|
||||||
|
const SidecarScratchpad({
|
||||||
|
this.canvasWidth = 4000.0,
|
||||||
|
this.canvasHeight = 4000.0,
|
||||||
|
this.strokes = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final double canvasWidth;
|
||||||
|
final double canvasHeight;
|
||||||
|
|
||||||
|
/// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
|
||||||
|
final List<InkStroke> strokes;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'canvasWidth': canvasWidth,
|
||||||
|
'canvasHeight': canvasHeight,
|
||||||
|
'strokes': strokes.map((s) => s.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarScratchpad(
|
||||||
|
canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
|
||||||
|
canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
|
||||||
|
strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarScratchpad &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
canvasWidth == other.canvasWidth &&
|
||||||
|
canvasHeight == other.canvasHeight &&
|
||||||
|
_listEq(strokes, other.strokes);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
|
||||||
|
'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
|
||||||
|
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
|
||||||
|
class SidecarScratchLink {
|
||||||
|
const SidecarScratchLink({
|
||||||
|
required this.link,
|
||||||
|
this.scratchpad = const SidecarScratchpad(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Anchor geometry (reuses [ScratchLink] verbatim).
|
||||||
|
final ScratchLink link;
|
||||||
|
|
||||||
|
/// The anchor's private scratchpad.
|
||||||
|
final SidecarScratchpad scratchpad;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
...link.toJson(),
|
||||||
|
'scratchpad': scratchpad.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SidecarScratchLink(
|
||||||
|
link: ScratchLink.fromJson(json),
|
||||||
|
scratchpad: json['scratchpad'] == null
|
||||||
|
? const SidecarScratchpad()
|
||||||
|
: SidecarScratchpad.fromJson(
|
||||||
|
json['scratchpad'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is SidecarScratchLink &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
link == other.link &&
|
||||||
|
scratchpad == other.scratchpad;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(link, scratchpad);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole sidecar: all annotations for one source file.
|
||||||
|
///
|
||||||
|
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
|
||||||
|
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
|
||||||
|
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
|
||||||
|
/// the scratchpad).
|
||||||
|
class BadnoteSidecar {
|
||||||
|
BadnoteSidecar({
|
||||||
|
this.version = kBadnoteSidecarVersion,
|
||||||
|
this.sourceFile,
|
||||||
|
this.docType,
|
||||||
|
this.title,
|
||||||
|
this.pageCount,
|
||||||
|
this.rotation = 0,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
|
Map<int, List<SidecarText>>? texts,
|
||||||
|
List<Bookmark>? bookmarks,
|
||||||
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
|
Map<int, String>? legacyAnnotations,
|
||||||
|
this.ocrText,
|
||||||
|
this.pageText,
|
||||||
|
this.legacyId,
|
||||||
|
this.background,
|
||||||
|
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||||
|
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
||||||
|
texts = texts ?? <int, List<SidecarText>>{},
|
||||||
|
bookmarks = bookmarks ?? <Bookmark>[],
|
||||||
|
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
|
||||||
|
legacyAnnotations = legacyAnnotations ?? <int, String>{};
|
||||||
|
|
||||||
|
/// Schema version (`badnoteSidecarVersion`).
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
|
||||||
|
final String? sourceFile;
|
||||||
|
|
||||||
|
/// `pdf` / `pptx` / `notebook` etc.
|
||||||
|
final String? docType;
|
||||||
|
|
||||||
|
/// Display title for a standalone (non-file-backed) notebook (`docType ==
|
||||||
|
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
|
||||||
|
final String? title;
|
||||||
|
|
||||||
|
final int? pageCount;
|
||||||
|
final int rotation;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
/// Page index → committed [EditorStroke]s (normalized page coords).
|
||||||
|
final Map<int, List<EditorStroke>> strokes;
|
||||||
|
|
||||||
|
/// Page index → highlighted text rects (normalized).
|
||||||
|
final Map<int, List<SidecarHighlight>> highlights;
|
||||||
|
|
||||||
|
/// Page index → typed-text annotations (normalized position, page-relative
|
||||||
|
/// font size). PDF editor only for now (note text is a later increment).
|
||||||
|
final Map<int, List<SidecarText>> texts;
|
||||||
|
|
||||||
|
final List<Bookmark> bookmarks;
|
||||||
|
final List<SidecarScratchLink> scratchLinks;
|
||||||
|
|
||||||
|
/// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
|
||||||
|
/// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
|
||||||
|
/// only by the one-time SQLite→sidecar migration so no legacy data is silently
|
||||||
|
/// dropped; the live editor ignores it. Empty for all freshly authored
|
||||||
|
/// sidecars.
|
||||||
|
final Map<int, String> legacyAnnotations;
|
||||||
|
|
||||||
|
/// Searchable text recovered from this notebook's handwriting via local OCR
|
||||||
|
/// (Phase 6 search index). Persisted in the sidecar — the source of truth —
|
||||||
|
/// so the vault-scan search index can find handwritten notes WITHOUT the
|
||||||
|
/// (rebuildable, per-device) SQLite cache. Typed text already lives in the
|
||||||
|
/// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
|
||||||
|
/// the notebook has no handwriting or OCR hasn't run.
|
||||||
|
final String? ocrText;
|
||||||
|
|
||||||
|
/// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook
|
||||||
|
/// (a PDF), captured ONCE at import time so the vault-scan search index covers
|
||||||
|
/// the document — not just the user's annotations. It is either the PDF's
|
||||||
|
/// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no
|
||||||
|
/// text layer — the result of a background OCR pass over the rendered pages.
|
||||||
|
/// Pages are joined with `\f` (form feed) but the index treats it as a flat
|
||||||
|
/// blob. Null when the document has not been indexed yet (back-compat: an old
|
||||||
|
/// sidecar simply omits the field) or has no extractable/recognized text. This
|
||||||
|
/// is distinct from [ocrText], which holds ONLY handwriting OCR.
|
||||||
|
final String? pageText;
|
||||||
|
|
||||||
|
/// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
|
||||||
|
/// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
|
||||||
|
/// idempotent (a re-run recognizes an already-migrated item by this id even if
|
||||||
|
/// its folder name collided). Null for all freshly authored sidecars.
|
||||||
|
final String? legacyId;
|
||||||
|
|
||||||
|
/// The page-background template for a standalone notebook, stored as the
|
||||||
|
/// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
|
||||||
|
/// here so the storage model stays UI-decoupled; the editor decodes it via
|
||||||
|
/// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
|
||||||
|
final String? background;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'badnoteSidecarVersion': version,
|
||||||
|
if (sourceFile != null) 'sourceFile': sourceFile,
|
||||||
|
if (docType != null) 'docType': docType,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (pageCount != null) 'pageCount': pageCount,
|
||||||
|
'rotation': rotation,
|
||||||
|
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
|
||||||
|
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||||
|
'strokes': {
|
||||||
|
for (final entry in strokes.entries)
|
||||||
|
entry.key.toString():
|
||||||
|
entry.value.map((s) => s.toJson()).toList(),
|
||||||
|
},
|
||||||
|
'highlights': {
|
||||||
|
for (final entry in highlights.entries)
|
||||||
|
entry.key.toString():
|
||||||
|
entry.value.map((h) => h.toJson()).toList(),
|
||||||
|
},
|
||||||
|
if (texts.isNotEmpty)
|
||||||
|
'texts': {
|
||||||
|
for (final entry in texts.entries)
|
||||||
|
entry.key.toString():
|
||||||
|
entry.value.map((t) => t.toJson()).toList(),
|
||||||
|
},
|
||||||
|
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
|
||||||
|
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
|
||||||
|
if (legacyAnnotations.isNotEmpty)
|
||||||
|
'legacyAnnotations': {
|
||||||
|
for (final entry in legacyAnnotations.entries)
|
||||||
|
entry.key.toString(): entry.value,
|
||||||
|
},
|
||||||
|
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
||||||
|
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
|
||||||
|
if (legacyId != null) 'legacyId': legacyId,
|
||||||
|
if (background != null) 'background': background,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
|
||||||
|
Map<int, List<T>> decodePageMap<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) item,
|
||||||
|
) {
|
||||||
|
final out = <int, List<T>>{};
|
||||||
|
if (raw is Map) {
|
||||||
|
raw.forEach((key, value) {
|
||||||
|
final pageIndex = int.tryParse(key.toString());
|
||||||
|
if (pageIndex == null || value is! List) return;
|
||||||
|
out[pageIndex] = value
|
||||||
|
.map((e) => item(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadnoteSidecar(
|
||||||
|
version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
|
||||||
|
kBadnoteSidecarVersion,
|
||||||
|
sourceFile: json['sourceFile'] as String?,
|
||||||
|
docType: json['docType'] as String?,
|
||||||
|
title: json['title'] as String?,
|
||||||
|
pageCount: (json['pageCount'] as num?)?.toInt(),
|
||||||
|
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
|
||||||
|
createdAt: json['createdAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['createdAt'] as String),
|
||||||
|
updatedAt: json['updatedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(json['updatedAt'] as String),
|
||||||
|
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
|
||||||
|
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
|
||||||
|
texts: decodePageMap(json['texts'], SidecarText.fromJson),
|
||||||
|
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
|
||||||
|
.map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
legacyAnnotations: () {
|
||||||
|
final raw = json['legacyAnnotations'];
|
||||||
|
final out = <int, String>{};
|
||||||
|
if (raw is Map) {
|
||||||
|
raw.forEach((key, value) {
|
||||||
|
final page = int.tryParse(key.toString());
|
||||||
|
if (page != null && value is String) out[page] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}(),
|
||||||
|
ocrText: json['ocrText'] as String?,
|
||||||
|
pageText: json['pageText'] as String?,
|
||||||
|
legacyId: json['legacyId'] as String?,
|
||||||
|
background: json['background'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _listEq<T>(List<T> a, List<T> b) {
|
||||||
|
if (identical(a, b)) return true;
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
160
lib/storage/notebook_manifest.dart
Normal file
160
lib/storage/notebook_manifest.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// lib/storage/notebook_manifest.dart
|
||||||
|
//
|
||||||
|
// OneNote-style notebook container: a vault folder with `notebook.json` that
|
||||||
|
// lists members (blank notes + imported PDF/PPTX/DOCX). Each member still uses
|
||||||
|
// its own sidecar; this file is only the table of contents.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// Filename of the notebook container manifest inside a vault folder.
|
||||||
|
const String kNotebookManifestName = 'notebook.json';
|
||||||
|
|
||||||
|
/// Default annotation font family (matches app UI theme).
|
||||||
|
const String kAnnotationFontFamily = 'IBM Plex Sans';
|
||||||
|
|
||||||
|
/// Kind of a notebook member.
|
||||||
|
enum NotebookMemberKind {
|
||||||
|
note,
|
||||||
|
pdf,
|
||||||
|
pptx,
|
||||||
|
ppt,
|
||||||
|
docx,
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookMemberKind? notebookMemberKindFromExt(String ext) {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case 'pdf':
|
||||||
|
return NotebookMemberKind.pdf;
|
||||||
|
case 'pptx':
|
||||||
|
return NotebookMemberKind.pptx;
|
||||||
|
case 'ppt':
|
||||||
|
return NotebookMemberKind.ppt;
|
||||||
|
case 'docx':
|
||||||
|
return NotebookMemberKind.docx;
|
||||||
|
case 'note':
|
||||||
|
case 'notebook':
|
||||||
|
return NotebookMemberKind.note;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String notebookMemberKindToExt(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => 'note',
|
||||||
|
NotebookMemberKind.pdf => 'pdf',
|
||||||
|
NotebookMemberKind.pptx => 'pptx',
|
||||||
|
NotebookMemberKind.ppt => 'ppt',
|
||||||
|
NotebookMemberKind.docx => 'docx',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One page/section inside a notebook container.
|
||||||
|
class NotebookMember {
|
||||||
|
const NotebookMember({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.relativePath,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final NotebookMemberKind kind;
|
||||||
|
|
||||||
|
/// Path relative to the notebook folder (POSIX separators preferred).
|
||||||
|
final String relativePath;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'kind': kind.name,
|
||||||
|
'path': relativePath,
|
||||||
|
'title': title,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NotebookMember.fromJson(Map<String, dynamic> json) {
|
||||||
|
final kindName = (json['kind'] as String?) ?? 'note';
|
||||||
|
final kind = NotebookMemberKind.values.firstWhere(
|
||||||
|
(k) => k.name == kindName,
|
||||||
|
orElse: () => NotebookMemberKind.note,
|
||||||
|
);
|
||||||
|
return NotebookMember(
|
||||||
|
id: (json['id'] as String?) ?? '',
|
||||||
|
kind: kind,
|
||||||
|
relativePath: (json['path'] as String?) ?? '',
|
||||||
|
title: (json['title'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookMember copyWith({String? title, String? relativePath}) =>
|
||||||
|
NotebookMember(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
relativePath: relativePath ?? this.relativePath,
|
||||||
|
title: title ?? this.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table of contents for a multi-document notebook folder.
|
||||||
|
class NotebookManifest {
|
||||||
|
const NotebookManifest({
|
||||||
|
required this.title,
|
||||||
|
required this.members,
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String title;
|
||||||
|
final List<NotebookMember> members;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
'title': title,
|
||||||
|
'members': members.map((m) => m.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NotebookManifest.fromJson(Map<String, dynamic> json) {
|
||||||
|
final raw = (json['members'] as List<dynamic>?) ?? const [];
|
||||||
|
return NotebookManifest(
|
||||||
|
version: (json['version'] as num?)?.toInt() ?? 1,
|
||||||
|
title: (json['title'] as String?) ?? '',
|
||||||
|
members: [
|
||||||
|
for (final e in raw)
|
||||||
|
NotebookMember.fromJson(e as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookManifest copyWith({
|
||||||
|
String? title,
|
||||||
|
List<NotebookMember>? members,
|
||||||
|
}) =>
|
||||||
|
NotebookManifest(
|
||||||
|
version: version,
|
||||||
|
title: title ?? this.title,
|
||||||
|
members: members ?? this.members,
|
||||||
|
);
|
||||||
|
|
||||||
|
static File fileIn(String folderPath) =>
|
||||||
|
File(p.join(folderPath, kNotebookManifestName));
|
||||||
|
|
||||||
|
static Future<NotebookManifest?> read(String folderPath) async {
|
||||||
|
final file = fileIn(folderPath);
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||||
|
return NotebookManifest.fromJson(map);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> write(String folderPath, NotebookManifest manifest) async {
|
||||||
|
final file = fileIn(folderPath);
|
||||||
|
await file.writeAsString(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(manifest.toJson()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
lib/storage/sidecar_store.dart
Normal file
84
lib/storage/sidecar_store.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// lib/storage/sidecar_store.dart
|
||||||
|
//
|
||||||
|
// Atomic read/write for `<file>.badnote.json` sidecars (Phase 1 / §F.1 of
|
||||||
|
// docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI.
|
||||||
|
//
|
||||||
|
// Write protocol (§F.1):
|
||||||
|
// 1. Serialize to pretty JSON, write to `<target>.tmp` with flush:true.
|
||||||
|
// 2. Before clobbering, copy the current good `<target>` to `<target>.bak`
|
||||||
|
// (one-deep backup — cheap insurance against a corrupt write).
|
||||||
|
// 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS /
|
||||||
|
// POSIX), so a reader never observes a half-written sidecar.
|
||||||
|
//
|
||||||
|
// Read protocol: parse `<target>`; if it is missing OR fails to parse, fall back
|
||||||
|
// to `<target>.bak`. If neither yields valid JSON, return null.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'badnote_sidecar.dart';
|
||||||
|
|
||||||
|
/// Stateless helper namespace for sidecar persistence.
|
||||||
|
class SidecarStore {
|
||||||
|
const SidecarStore._();
|
||||||
|
|
||||||
|
static const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
|
||||||
|
|
||||||
|
/// Suffix for the in-progress temp file.
|
||||||
|
static const String tmpSuffix = '.tmp';
|
||||||
|
|
||||||
|
/// Suffix for the one-deep backup of the last good sidecar.
|
||||||
|
static const String bakSuffix = '.bak';
|
||||||
|
|
||||||
|
/// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak`
|
||||||
|
/// of the previous good file. Never leaves a partial sidecar at [target]:
|
||||||
|
/// either the previous content (on failure before rename) or the new content.
|
||||||
|
static Future<void> writeAtomic(File target, BadnoteSidecar sidecar) async {
|
||||||
|
final json = _encoder.convert(sidecar.toJson());
|
||||||
|
await writeAtomicJson(target, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower-level variant for callers that already hold the JSON string.
|
||||||
|
static Future<void> writeAtomicJson(File target, String json) async {
|
||||||
|
await target.parent.create(recursive: true);
|
||||||
|
|
||||||
|
final tmp = File('${target.path}$tmpSuffix');
|
||||||
|
await tmp.writeAsString(json, flush: true);
|
||||||
|
|
||||||
|
// Back up the previous good file before clobbering it.
|
||||||
|
if (await target.exists()) {
|
||||||
|
final bak = File('${target.path}$bakSuffix');
|
||||||
|
try {
|
||||||
|
await target.copy(bak.path);
|
||||||
|
} catch (_) {
|
||||||
|
// A failed backup must not block the write; the atomic rename below
|
||||||
|
// still guarantees the new content lands intact.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic on the same filesystem.
|
||||||
|
await tmp.rename(target.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and parses the sidecar at [target], falling back to `<target>.bak`
|
||||||
|
/// if the primary is missing or corrupt. Returns null if neither is readable.
|
||||||
|
static Future<BadnoteSidecar?> read(File target) async {
|
||||||
|
final primary = await _tryRead(target);
|
||||||
|
if (primary != null) return primary;
|
||||||
|
|
||||||
|
final bak = File('${target.path}$bakSuffix');
|
||||||
|
return _tryRead(bak);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<BadnoteSidecar?> _tryRead(File file) async {
|
||||||
|
try {
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
final raw = await file.readAsString();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
return BadnoteSidecar.fromJson(decoded);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
335
lib/storage/sqlite_to_sidecar_migrator.dart
Normal file
335
lib/storage/sqlite_to_sidecar_migrator.dart
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
// lib/storage/sqlite_to_sidecar_migrator.dart
|
||||||
|
//
|
||||||
|
// Phase 5 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||||
|
// storage.md §B): a ONE-TIME, ADDITIVE, IDEMPOTENT migration of legacy SQLite
|
||||||
|
// data into vault sidecars. A user upgrading from the SQLite era has existing
|
||||||
|
// documents/notes/ink/scratchpads/bookmarks that the new editors no longer
|
||||||
|
// write to; this lifts that data into `<file>.badnote.json` sidecars so nothing
|
||||||
|
// is lost.
|
||||||
|
//
|
||||||
|
// Guarantees (§B / acceptance):
|
||||||
|
// * NEVER loses data. The legacy DB is renamed to `*.premigration`, never
|
||||||
|
// deleted, BEFORE the migration flag is flipped (so a failed run loses
|
||||||
|
// nothing and the caller can retry).
|
||||||
|
// * Additive: only WRITES sidecars + COPIES source files into the vault. Reads
|
||||||
|
// the legacy DB read-only.
|
||||||
|
// * Idempotent / re-runnable: a target sidecar that already exists is skipped,
|
||||||
|
// so a half-finished run resumes on relaunch and a completed run is a no-op.
|
||||||
|
// * Missing-source-graceful: if a legacy document's source file is gone, its
|
||||||
|
// annotations (the precious part) are still migrated into a notebook folder;
|
||||||
|
// the absence is recorded in [MigrationReport.missingSources], not fatal.
|
||||||
|
//
|
||||||
|
// Mapping (table → sidecar field):
|
||||||
|
// documents → a notebook folder + `<file>.badnote.json`
|
||||||
|
// ink (host page) → sidecar.strokes[pageIndex] (EditorStroke JSON)
|
||||||
|
// bookmarks → sidecar.bookmarks (Bookmark JSON)
|
||||||
|
// scratch_links → sidecar.scratchLinks[].link (ScratchLink JSON)
|
||||||
|
// scratchpads → sidecar.scratchLinks[].scratchpad (InkStroke JSON, abs px)
|
||||||
|
// annotations → sidecar.legacyAnnotations (raw blob, never dropped)
|
||||||
|
// highlights → none in legacy data (always empty)
|
||||||
|
// notes + strokes → a standalone notebook folder + `notebook.badnote.json`
|
||||||
|
// (strokes normalized onto page 0 as EditorStroke, exactly
|
||||||
|
// as the runtime note editor persists them)
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import '../editor/engine/stroke_model.dart';
|
||||||
|
import '../editor/notebook/ink_stroke_adapter.dart';
|
||||||
|
import '../models/document.dart' as doc;
|
||||||
|
import '../models/ink_stroke.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import 'badnote_sidecar.dart';
|
||||||
|
import 'sidecar_store.dart';
|
||||||
|
|
||||||
|
/// Outcome of one [SqliteToSidecarMigrator.run] call. Carries counts + the
|
||||||
|
/// notebook/source paths touched so callers (and tests) can assert coverage and
|
||||||
|
/// surface a brief summary.
|
||||||
|
class MigrationReport {
|
||||||
|
MigrationReport({
|
||||||
|
this.documentsMigrated = 0,
|
||||||
|
this.documentsSkipped = 0,
|
||||||
|
this.notesMigrated = 0,
|
||||||
|
this.notesSkipped = 0,
|
||||||
|
List<String>? missingSources,
|
||||||
|
this.legacyDbFound = false,
|
||||||
|
this.legacyDbPreservedPath,
|
||||||
|
}) : missingSources = missingSources ?? <String>[];
|
||||||
|
|
||||||
|
/// File-backed documents written as new sidecars this run.
|
||||||
|
int documentsMigrated;
|
||||||
|
|
||||||
|
/// File-backed documents skipped because their sidecar already existed.
|
||||||
|
int documentsSkipped;
|
||||||
|
|
||||||
|
/// Standalone notes written as new `notebook.badnote.json` this run.
|
||||||
|
int notesMigrated;
|
||||||
|
|
||||||
|
/// Standalone notes skipped because their sidecar already existed.
|
||||||
|
int notesSkipped;
|
||||||
|
|
||||||
|
/// Filenames of legacy documents whose source file no longer existed on disk
|
||||||
|
/// (annotations were still migrated into a notebook folder without a file).
|
||||||
|
final List<String> missingSources;
|
||||||
|
|
||||||
|
/// Whether a legacy DB file was actually found and opened.
|
||||||
|
bool legacyDbFound;
|
||||||
|
|
||||||
|
/// Where the legacy DB ended up (its `*.premigration` path), or null if there
|
||||||
|
/// was no legacy DB to preserve.
|
||||||
|
String? legacyDbPreservedPath;
|
||||||
|
|
||||||
|
bool get didAnything =>
|
||||||
|
documentsMigrated > 0 || notesMigrated > 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MigrationReport(documentsMigrated: $documentsMigrated, '
|
||||||
|
'documentsSkipped: $documentsSkipped, notesMigrated: $notesMigrated, '
|
||||||
|
'notesSkipped: $notesSkipped, missingSources: ${missingSources.length}, '
|
||||||
|
'legacyDbFound: $legacyDbFound)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time SQLite → sidecar migrator. Construct with the target [VaultService]
|
||||||
|
/// and (optionally) an explicit legacy DB path for tests; call [run] once.
|
||||||
|
class SqliteToSidecarMigrator {
|
||||||
|
SqliteToSidecarMigrator(this._vault, {String? legacyDbPath})
|
||||||
|
: _legacyDbPathOverride = legacyDbPath;
|
||||||
|
|
||||||
|
final VaultService _vault;
|
||||||
|
final String? _legacyDbPathOverride;
|
||||||
|
|
||||||
|
/// Suffix the legacy DB is renamed to so it is preserved (never destroyed).
|
||||||
|
static const String premigrationSuffix = '.premigration';
|
||||||
|
|
||||||
|
/// Logical page a legacy free-ink note was drawn on (its `InkStroke`s are in
|
||||||
|
/// absolute pixels on this rect). Migrated strokes are normalized against it,
|
||||||
|
/// exactly as the runtime note editor does on load.
|
||||||
|
static const _noteLogicalPage = kNoteLogicalPage;
|
||||||
|
|
||||||
|
/// Run the migration. Safe to call when there is nothing to migrate (fresh
|
||||||
|
/// install → no legacy DB → no-op). Returns a [MigrationReport].
|
||||||
|
///
|
||||||
|
/// Throws [StateError] if the vault root is not valid (callers must gate on a
|
||||||
|
/// valid vault first).
|
||||||
|
Future<MigrationReport> run() async {
|
||||||
|
if (!await _vault.vaultRootValid()) {
|
||||||
|
throw StateError('Cannot migrate: vault root is not valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final report = MigrationReport();
|
||||||
|
|
||||||
|
final legacyPath = _legacyDbPathOverride ?? await DatabaseService.legacyDbPath();
|
||||||
|
final legacyFile = File(legacyPath);
|
||||||
|
if (!await legacyFile.exists()) {
|
||||||
|
// Fresh install (or already migrated + renamed): nothing to do.
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
report.legacyDbFound = true;
|
||||||
|
|
||||||
|
// sqflite ffi must be initialised before opening (the migrator may run on
|
||||||
|
// desktop before any DatabaseService.getInstance call).
|
||||||
|
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||||
|
sqfliteFfiInit();
|
||||||
|
}
|
||||||
|
final db = await databaseFactoryFfi.openDatabase(
|
||||||
|
legacyPath,
|
||||||
|
options: OpenDatabaseOptions(readOnly: true, singleInstance: false),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _migrateDocuments(db, report);
|
||||||
|
await _migrateNotes(db, report);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the legacy DB as `*.premigration` (never delete). Done AFTER a
|
||||||
|
// successful pass so a crash mid-migration leaves the original in place for
|
||||||
|
// a clean retry. Idempotent: if already renamed on a prior run, skip.
|
||||||
|
final preserved = File('$legacyPath$premigrationSuffix');
|
||||||
|
if (!await preserved.exists()) {
|
||||||
|
await legacyFile.rename(preserved.path);
|
||||||
|
}
|
||||||
|
report.legacyDbPreservedPath = preserved.path;
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateDocuments(Database db, MigrationReport report) async {
|
||||||
|
final documents = await DatabaseService.rawAllDocuments(db);
|
||||||
|
// Robust idempotency: a set of legacy ids already migrated, recovered by
|
||||||
|
// scanning every existing sidecar's `legacyId`. Re-running recognizes
|
||||||
|
// already-migrated items even if folder names collided.
|
||||||
|
final migratedIds = await _migratedLegacyIds();
|
||||||
|
for (final document in documents) {
|
||||||
|
if (migratedIds.contains(document.id)) {
|
||||||
|
report.documentsSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final sourceExists = await File(document.filePath).exists();
|
||||||
|
|
||||||
|
// Locate/create the notebook folder. Reuse VaultService.createNotebook
|
||||||
|
// (folder + file copy) when the source exists; otherwise make an
|
||||||
|
// annotations-only folder so the precious ink is never lost.
|
||||||
|
final String vaultSourcePath;
|
||||||
|
if (sourceExists) {
|
||||||
|
vaultSourcePath = await _vault.createNotebook(document.filePath);
|
||||||
|
} else {
|
||||||
|
report.missingSources.add(document.filename);
|
||||||
|
vaultSourcePath = await _ensureNotebookForMissingSource(
|
||||||
|
document.filename,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sidecarFile = File('$vaultSourcePath$kVaultSidecarSuffix');
|
||||||
|
final sidecar = await _buildDocumentSidecar(db, document, vaultSourcePath);
|
||||||
|
await SidecarStore.writeAtomic(sidecarFile, sidecar);
|
||||||
|
migratedIds.add(document.id);
|
||||||
|
report.documentsMigrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BadnoteSidecar> _buildDocumentSidecar(
|
||||||
|
Database db,
|
||||||
|
doc.Document document,
|
||||||
|
String vaultSourcePath,
|
||||||
|
) async {
|
||||||
|
final documentId = document.id;
|
||||||
|
final strokes = await DatabaseService.rawStrokesByPage(db, documentId);
|
||||||
|
final bookmarks = await DatabaseService.rawBookmarks(db, documentId);
|
||||||
|
final legacyAnnotations =
|
||||||
|
await DatabaseService.rawLegacyAnnotations(db, documentId);
|
||||||
|
|
||||||
|
final links = await DatabaseService.rawScratchLinks(db, documentId);
|
||||||
|
final scratchLinks = <SidecarScratchLink>[];
|
||||||
|
for (final link in links) {
|
||||||
|
// The scratchpad row is keyed by the ANCHOR id (see saveScratchpad).
|
||||||
|
final pad = await DatabaseService.rawScratchpad(db, link.id);
|
||||||
|
scratchLinks.add(SidecarScratchLink(
|
||||||
|
link: link,
|
||||||
|
scratchpad: SidecarScratchpad(strokes: pad),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadnoteSidecar(
|
||||||
|
sourceFile: p.basename(vaultSourcePath),
|
||||||
|
docType: document.docType,
|
||||||
|
pageCount: document.pageCount,
|
||||||
|
rotation: document.rotation,
|
||||||
|
createdAt: document.createdAt,
|
||||||
|
updatedAt: document.updatedAt,
|
||||||
|
strokes: strokes,
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
scratchLinks: scratchLinks,
|
||||||
|
legacyAnnotations: legacyAnnotations,
|
||||||
|
legacyId: documentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateNotes(Database db, MigrationReport report) async {
|
||||||
|
final notes = await DatabaseService.rawAllNotes(db);
|
||||||
|
final migratedIds = await _migratedLegacyIds();
|
||||||
|
for (final note in notes) {
|
||||||
|
// Idempotent: recognize an already-migrated note by its legacy id stored
|
||||||
|
// in some existing `notebook.badnote.json` (folder name may have collided
|
||||||
|
// / been de-duped, so a path guess is unreliable — the id is canonical).
|
||||||
|
if (migratedIds.contains(note.id)) {
|
||||||
|
report.notesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the standalone notebook folder + initial sidecar (title), then
|
||||||
|
// overwrite the sidecar with the migrated strokes on page 0.
|
||||||
|
final notePath = await _vault.createEmptyNotebook(note.title);
|
||||||
|
|
||||||
|
// Legacy note strokes are InkStroke in ABSOLUTE px on the note's logical
|
||||||
|
// page. Normalize them the same way the runtime note editor does on load
|
||||||
|
// (penStrokeFromInk → EditorStroke.fromPenStroke) so the migrated note
|
||||||
|
// renders identically.
|
||||||
|
final editorStrokes = <EditorStroke>[
|
||||||
|
for (final InkStroke s in note.strokes)
|
||||||
|
if (_toEditor(s) case final EditorStroke es) es,
|
||||||
|
];
|
||||||
|
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final sidecar = BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: note.title.trim().isEmpty ? null : note.title.trim(),
|
||||||
|
createdAt: note.createdAt,
|
||||||
|
updatedAt: note.updatedAt.isAfter(now) ? now : note.updatedAt,
|
||||||
|
strokes: editorStrokes.isEmpty ? null : {0: editorStrokes},
|
||||||
|
legacyId: note.id,
|
||||||
|
);
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
sidecar,
|
||||||
|
);
|
||||||
|
migratedIds.add(note.id);
|
||||||
|
report.notesMigrated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a legacy note [InkStroke] (absolute px) to a normalized
|
||||||
|
/// [EditorStroke] via the exact runtime chain. Returns null for non-freehand
|
||||||
|
/// strokes (shapes/text), which the pen canvas cannot represent.
|
||||||
|
EditorStroke? _toEditor(InkStroke s) {
|
||||||
|
final pen = penStrokeFromInk(s, _noteLogicalPage);
|
||||||
|
if (pen == null) return null;
|
||||||
|
return EditorStroke.fromPenStroke(pen, id: s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan every notebook sidecar already in the vault and collect the `legacyId`
|
||||||
|
/// values, so the migration can recognize already-migrated documents/notes on
|
||||||
|
/// a re-run regardless of any folder-name de-duplication. A fresh vault yields
|
||||||
|
/// an empty set.
|
||||||
|
Future<Set<String>> _migratedLegacyIds() async {
|
||||||
|
final root = _vault.vaultRoot;
|
||||||
|
final ids = <String>{};
|
||||||
|
if (root == null || root.isEmpty) return ids;
|
||||||
|
final dir = Directory(root);
|
||||||
|
if (!await dir.exists()) return ids;
|
||||||
|
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! Directory) continue;
|
||||||
|
if (p.basename(entity.path).startsWith('.')) continue;
|
||||||
|
await for (final file in entity.list(followLinks: false)) {
|
||||||
|
if (file is! File) continue;
|
||||||
|
if (!file.path.endsWith(kVaultSidecarSuffix)) continue;
|
||||||
|
// Skip .bak/.tmp variants (they don't end in the suffix anyway).
|
||||||
|
final sidecar = await SidecarStore.read(file);
|
||||||
|
final id = sidecar?.legacyId;
|
||||||
|
if (id != null) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure an annotations-only notebook folder exists for a legacy document
|
||||||
|
/// whose source file is GONE. Returns the synthetic source path the sidecar
|
||||||
|
/// keys off (`<folder>/<filename>`), so the sidecar lands at
|
||||||
|
/// `<folder>/<filename>.badnote.json` — identical to the file-backed case but
|
||||||
|
/// with no copied file. Idempotent on re-run.
|
||||||
|
Future<String> _ensureNotebookForMissingSource(String filename) async {
|
||||||
|
final root = _vault.vaultRoot!;
|
||||||
|
final baseName = _sanitize(p.basenameWithoutExtension(filename));
|
||||||
|
final folder = Directory(p.join(root, baseName.isEmpty ? 'Untitled' : baseName));
|
||||||
|
final syntheticSource = p.join(folder.path, filename);
|
||||||
|
if (await File('$syntheticSource$kVaultSidecarSuffix').exists()) {
|
||||||
|
return syntheticSource; // already migrated
|
||||||
|
}
|
||||||
|
await folder.create(recursive: true);
|
||||||
|
return syntheticSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of VaultService._sanitizeFolderName (kept private there) so the
|
||||||
|
/// missing-source notebook folder lands at the same name the source-backed
|
||||||
|
/// path would have used.
|
||||||
|
static String _sanitize(String name) => name
|
||||||
|
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim()
|
||||||
|
.replaceAll(RegExp(r'[. ]+$'), '');
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user