Compare commits
73 Commits
eca5141372
...
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 | |||
| 1d70c029b3 | |||
| f48e43f13d | |||
| 5db364d1fc | |||
| 22f27641ca | |||
| 8a0c850f51 | |||
| a2df8ae68f | |||
| 3cfb793803 | |||
| 84d7dc8d24 | |||
| c8579d3e86 | |||
| 458d6448d3 | |||
| eda0ee6006 | |||
| 2edb897000 | |||
| c1a35b3290 | |||
| 4c8cc73106 | |||
| 77b6ee415d | |||
| b1f131814e | |||
| 1e836d6e7d | |||
| b359000991 | |||
| 0cf58fb67a | |||
| 1aa2865d6f | |||
| 3e0b81be92 | |||
| 18ebce6d73 | |||
| eedb52d2f5 | |||
| 852eb389ee | |||
| c31bfd3445 | |||
| c03513da4f | |||
| 6b7cc14836 | |||
| 1a3d1065f9 | |||
| 07b543f3e1 |
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)}';
|
|
||||||
|
|||||||
7
l10n.yaml
Normal file
7
l10n.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Flutter gen-l10n config. Generates AppLocalizations from the ARB files in
|
||||||
|
# lib/l10n. `flutter pub get` / build runs the generator (pubspec `generate: true`).
|
||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
|
output-class: AppLocalizations
|
||||||
|
nullable-getter: false
|
||||||
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();
|
||||||
|
}
|
||||||
115
lib/editor/board/board.dart
Normal file
115
lib/editor/board/board.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// lib/editor/board/board.dart
|
||||||
|
//
|
||||||
|
// Infinite-board model (F7 — 便利贴 + 双链). A board is a set of positioned
|
||||||
|
// cards (sticky notes) in board content coordinates; each card has text that may
|
||||||
|
// contain [[links]] to other cards, so a board derives a LinkGraph for
|
||||||
|
// backlinks. Cards also host ink (via a StrokeHost keyed by the card id) — the
|
||||||
|
// same host-agnostic engine as PDF pages.
|
||||||
|
//
|
||||||
|
// Pure, immutable value model (no widgets/storage); fully unit-tested. The board
|
||||||
|
// canvas + persistence wrap it.
|
||||||
|
|
||||||
|
import 'dart:ui' show Offset, Rect, Size;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../link/link_graph.dart';
|
||||||
|
|
||||||
|
/// One sticky-note card on the board.
|
||||||
|
@immutable
|
||||||
|
class BoardCard {
|
||||||
|
const BoardCard({
|
||||||
|
required this.id,
|
||||||
|
required this.position,
|
||||||
|
required this.size,
|
||||||
|
this.text = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Top-left in board content coordinates.
|
||||||
|
final Offset position;
|
||||||
|
final Size size;
|
||||||
|
|
||||||
|
/// Card body; may contain `[[other-card]]` links.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
Rect get bounds => position & size;
|
||||||
|
|
||||||
|
BoardCard copyWith({Offset? position, Size? size, String? text}) => BoardCard(
|
||||||
|
id: id,
|
||||||
|
position: position ?? this.position,
|
||||||
|
size: size ?? this.size,
|
||||||
|
text: text ?? this.text,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is BoardCard &&
|
||||||
|
other.id == id &&
|
||||||
|
other.position == position &&
|
||||||
|
other.size == size &&
|
||||||
|
other.text == text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(id, position, size, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An immutable infinite board: an ordered list of cards with copy-on-write
|
||||||
|
/// edits. Card ids are unique.
|
||||||
|
@immutable
|
||||||
|
class Board {
|
||||||
|
Board(List<BoardCard> cards) : cards = List<BoardCard>.unmodifiable(cards);
|
||||||
|
|
||||||
|
static final Board empty = Board(const []);
|
||||||
|
|
||||||
|
final List<BoardCard> cards;
|
||||||
|
|
||||||
|
int get length => cards.length;
|
||||||
|
|
||||||
|
BoardCard? cardById(String id) {
|
||||||
|
for (final c in cards) {
|
||||||
|
if (c.id == id) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a card (throws if [card].id already exists).
|
||||||
|
Board add(BoardCard card) {
|
||||||
|
if (cardById(card.id) != null) {
|
||||||
|
throw ArgumentError('duplicate card id: ${card.id}');
|
||||||
|
}
|
||||||
|
return Board([...cards, card]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Board removeById(String id) =>
|
||||||
|
Board([for (final c in cards) if (c.id != id) c]);
|
||||||
|
|
||||||
|
/// Replace card [id] via [update]; no-op if absent.
|
||||||
|
Board updateCard(String id, BoardCard Function(BoardCard) update) =>
|
||||||
|
Board([for (final c in cards) if (c.id == id) update(c) else c]);
|
||||||
|
|
||||||
|
Board moveCard(String id, Offset position) =>
|
||||||
|
updateCard(id, (c) => c.copyWith(position: position));
|
||||||
|
|
||||||
|
Board setText(String id, String text) =>
|
||||||
|
updateCard(id, (c) => c.copyWith(text: text));
|
||||||
|
|
||||||
|
/// Cards whose bounds overlap [viewport] (board broad-phase culling).
|
||||||
|
List<BoardCard> cardsIn(Rect viewport) =>
|
||||||
|
[for (final c in cards) if (c.bounds.overlaps(viewport)) c];
|
||||||
|
|
||||||
|
/// Derive the 双链 graph from card texts (card id → its [[links]]).
|
||||||
|
LinkGraph linkGraph() =>
|
||||||
|
LinkGraph.fromTexts({for (final c in cards) c.id: c.text});
|
||||||
|
|
||||||
|
/// Backlinks to card [id] (ids of cards whose text links to it).
|
||||||
|
Set<String> backlinksOf(String id) => linkGraph().backlinksOf(id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is Board && listEquals(other.cards, cards);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(cards);
|
||||||
|
}
|
||||||
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
|
||||||
|
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
|
||||||
|
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
|
||||||
|
final outline = freehandOutlinePoints(
|
||||||
|
pfPoints: pfPoints,
|
||||||
size: pixelWidth,
|
size: pixelWidth,
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
isHighlighter: isHighlighter,
|
||||||
// configurable [thinning] so real Surface-Pen pressure changes width.
|
hasRealPressure: hasRealPressure,
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
|
||||||
smoothing: kPenSmoothing,
|
|
||||||
streamline: kPenStreamline,
|
|
||||||
// Real stylus pressure → don't simulate; no pressure → let freehand fake
|
|
||||||
// it based on velocity. (perfect_freehand 2.x honors REAL pressure when
|
|
||||||
// simulatePressure is false — 1.0.4 ignored it, which made width
|
|
||||||
// unresponsive to pen force.)
|
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
|
||||||
isComplete: isComplete,
|
isComplete: isComplete,
|
||||||
),
|
thinning: thinning,
|
||||||
|
brush: brushProfileFor(stroke.brush),
|
||||||
);
|
);
|
||||||
|
|
||||||
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) {
|
||||||
|
final draw = arbiter.shouldDraw(
|
||||||
activePointerCount: _activePointers.length,
|
activePointerCount: _activePointers.length,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
fingerDrawingEnabled: _fingerDrawingEnabled,
|
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||||
hwPanActive: _hwPanActive,
|
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;
|
||||||
@@ -32,6 +33,7 @@ import 'package:flutter/physics.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'input_diagnostics.dart';
|
import 'input_diagnostics.dart';
|
||||||
|
import 'pinch_scale_solver.dart';
|
||||||
|
|
||||||
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
||||||
/// is owned exclusively by the drawing `Listener`.
|
/// is owned exclusively by the drawing `Listener`.
|
||||||
@@ -45,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;
|
||||||
|
|
||||||
@@ -105,17 +107,28 @@ 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
|
||||||
|
/// gesture start, or the last pointer-count re-baseline). The absolute target
|
||||||
|
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
|
||||||
|
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
|
||||||
|
///
|
||||||
|
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
|
||||||
|
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
|
||||||
|
/// its un-normalized cumulative value, so the next frame computed
|
||||||
|
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
|
||||||
|
/// (the reported flicker). Normalizing kills that pop at the source.
|
||||||
|
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) {
|
||||||
@@ -163,15 +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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -180,10 +216,20 @@ 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).
|
||||||
|
_rawScaleAtBaseline = details.scale;
|
||||||
InputDiagnostics.instance.recordRebaseline();
|
InputDiagnostics.instance.recordRebaseline();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -219,34 +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
|
scaleStart: _scaleStart!,
|
||||||
// and snap back. A ratio outside the glitch band is a real multi-touch
|
rawScaleAtBaseline: _rawScaleAtBaseline,
|
||||||
// spike → drop the frame; absolute tracking means the next good frame
|
rawScale: details.scale,
|
||||||
// resumes from the true finger span, so the spike never shows.
|
lastAppliedScale: _lastAppliedScale,
|
||||||
final double rawRatio =
|
minScale: widget.minScale,
|
||||||
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
|
maxScale: widget.maxScale,
|
||||||
final bool scaleDrop =
|
maxStepRatio: _kScaleGlitchHi,
|
||||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
);
|
||||||
if (scaleDrop || focalDrop) {
|
if (focalDrop) {
|
||||||
record(1.0, scaleDrop, focalDrop);
|
if (step.reanchor) {
|
||||||
|
_scaleStart = step.appliedScale;
|
||||||
|
_rawScaleAtBaseline = details.scale;
|
||||||
|
_lastAppliedScale = step.appliedScale;
|
||||||
|
}
|
||||||
|
record(_lastAppliedScale, 1.0, step.spiked, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
|
final double targetScale = step.appliedScale;
|
||||||
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
|
if (step.reanchor) {
|
||||||
// scene point that was under the focal at gesture start stays under the
|
_scaleStart = targetScale;
|
||||||
// CURRENT focal (which also yields 2-finger pan for free). Closed form
|
_rawScaleAtBaseline = details.scale;
|
||||||
// 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.
|
|
||||||
final double targetScale = clampDouble(
|
|
||||||
_scaleStart! * details.scale,
|
|
||||||
widget.minScale,
|
|
||||||
widget.maxScale,
|
|
||||||
);
|
|
||||||
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;
|
||||||
@@ -258,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);
|
||||||
@@ -268,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 =
|
||||||
@@ -276,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;
|
||||||
@@ -321,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;
|
||||||
}
|
}
|
||||||
|
|||||||
103
lib/editor/canvas/pinch_scale_solver.dart
Normal file
103
lib/editor/canvas/pinch_scale_solver.dart
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// lib/editor/canvas/pinch_scale_solver.dart
|
||||||
|
//
|
||||||
|
// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the
|
||||||
|
// re-baseline behavior (the subtle part) can be unit-tested without simulating
|
||||||
|
// a flaky multi-pointer gesture.
|
||||||
|
//
|
||||||
|
// The pinch is driven ABSOLUTELY: the scale shown is always
|
||||||
|
// scaleStart * (rawScale / rawScaleAtBaseline)
|
||||||
|
// where `scaleStart` is the matrix scale captured at the current baseline and
|
||||||
|
// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that
|
||||||
|
// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative
|
||||||
|
// scale so it reads 1.0 at the baseline instant.
|
||||||
|
//
|
||||||
|
// Why this matters: a baseline is captured at gesture start AND on every
|
||||||
|
// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At
|
||||||
|
// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is
|
||||||
|
// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the
|
||||||
|
// 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
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
/// Returns the absolute target scale for a pinch frame.
|
||||||
|
///
|
||||||
|
/// [scaleStart] — matrix scale captured at the current baseline.
|
||||||
|
/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that
|
||||||
|
/// baseline (1.0 at gesture start; the live value at a re-baseline).
|
||||||
|
/// [rawScale] — the recognizer's current cumulative `details.scale`.
|
||||||
|
/// Result is clamped to [minScale, maxScale].
|
||||||
|
double absolutePinchScale({
|
||||||
|
required double scaleStart,
|
||||||
|
required double rawScaleAtBaseline,
|
||||||
|
required double rawScale,
|
||||||
|
required double minScale,
|
||||||
|
required double maxScale,
|
||||||
|
}) {
|
||||||
|
final double cumulative =
|
||||||
|
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
|
||||||
|
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);
|
||||||
|
}
|
||||||
50
lib/editor/engine/stroke_bounds.dart
Normal file
50
lib/editor/engine/stroke_bounds.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// lib/editor/engine/stroke_bounds.dart
|
||||||
|
//
|
||||||
|
// Axis-aligned bounds of strokes in normalized content coordinates. Used for
|
||||||
|
// broad-phase culling (don't paint/erase/hit-test strokes whose box is off the
|
||||||
|
// viewport — the infinite board's R1 perf primitive), and as a cheap pre-filter
|
||||||
|
// before the exact per-point eraser test.
|
||||||
|
//
|
||||||
|
// Pure geometry over EditorStroke; no widgets/storage; fully unit-tested.
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// Tight axis-aligned bounds of [stroke] in normalized coords, or null when the
|
||||||
|
/// stroke has no points. A single-point stroke yields a zero-size rect at that
|
||||||
|
/// point.
|
||||||
|
Rect? strokeBounds(EditorStroke stroke) {
|
||||||
|
if (stroke.points.isEmpty) return null;
|
||||||
|
var minX = double.infinity, minY = double.infinity;
|
||||||
|
var maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||||
|
for (final p in stroke.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;
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(minX, minY, maxX, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Union bounds of [strokes], or null when none have points.
|
||||||
|
Rect? strokesBounds(Iterable<EditorStroke> strokes) {
|
||||||
|
Rect? acc;
|
||||||
|
for (final stroke in strokes) {
|
||||||
|
final b = strokeBounds(stroke);
|
||||||
|
if (b == null) continue;
|
||||||
|
acc = acc == null ? b : acc.expandToInclude(b);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [stroke]'s bounds overlap [viewport] (broad-phase visibility test).
|
||||||
|
/// Empty strokes are never visible. Touching edges count as overlapping.
|
||||||
|
bool strokeIntersects(EditorStroke stroke, Rect viewport) {
|
||||||
|
final b = strokeBounds(stroke);
|
||||||
|
if (b == null) return false;
|
||||||
|
return b.left <= viewport.right &&
|
||||||
|
b.right >= viewport.left &&
|
||||||
|
b.top <= viewport.bottom &&
|
||||||
|
b.bottom >= viewport.top;
|
||||||
|
}
|
||||||
@@ -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,26 +51,71 @@ 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
|
||||||
|
// Brush-driven path: every geometry knob (thinning / streamline /
|
||||||
|
// smoothing / caps / taper / simulatePressure) comes from the
|
||||||
|
// BrushProfile so each brush renders distinctly. Pressure was already
|
||||||
|
// pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the
|
||||||
|
// pre-warp is baked into pfPoints — perfect_freehand stays linear here.
|
||||||
|
// simulatePressure is forced true only when the device gave us NO real
|
||||||
|
// pressure, so velocity-thinning still kicks in for mice/trackpads.
|
||||||
|
? _optionsFromBrush(brush,
|
||||||
|
size: size,
|
||||||
|
isComplete: isComplete,
|
||||||
|
hasRealPressure: hasRealPressure)
|
||||||
|
: pf.StrokeOptions(
|
||||||
size: size,
|
size: size,
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
// configurable [thinning] so Surface-Pen pressure changes width.
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
thinning: isHighlighter ? 0.0 : thinning,
|
||||||
smoothing: kPenSmoothing,
|
smoothing: kPenSmoothing,
|
||||||
streamline: kPenStreamline,
|
streamline: kPenStreamline,
|
||||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
// Real stylus pressure -> don't simulate; no pressure -> let
|
||||||
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
// freehand fake it based on velocity (highlighter never simulates).
|
||||||
// 2.x honors real pressure when simulatePressure is false.
|
// perfect_freehand 2.x honors real pressure when simulatePressure
|
||||||
|
// is false.
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||||
isComplete: isComplete,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
||||||
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
||||||
///
|
///
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
52
lib/editor/engine/stroke_host.dart
Normal file
52
lib/editor/engine/stroke_host.dart
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// lib/editor/engine/stroke_host.dart
|
||||||
|
//
|
||||||
|
// A CoordinateSpaceHost (plan principle #2: ONE host-agnostic ink engine). A
|
||||||
|
// host is anything ink attaches to — a PDF page, an infinite board region, or a
|
||||||
|
// (P5) CAS overlay — identified by [hostId], with a [contentSize] that defines
|
||||||
|
// the normalized↔pixel mapping, and a committed [StrokeStore]. The viewport
|
||||||
|
// mounts one AnnotationLayer per host; nothing in the engine knows whether it's
|
||||||
|
// a page or a board.
|
||||||
|
//
|
||||||
|
// Pure (no widgets/pdfrx); ties together StrokeStore (P0) + stroke_bounds
|
||||||
|
// broad-phase culling. Unit-tested.
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect, Size;
|
||||||
|
|
||||||
|
import 'stroke_bounds.dart';
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
import 'stroke_store.dart';
|
||||||
|
|
||||||
|
/// One ink host: identity + content geometry + its committed strokes.
|
||||||
|
class StrokeHost {
|
||||||
|
StrokeHost({
|
||||||
|
required this.hostId,
|
||||||
|
required this.contentSize,
|
||||||
|
StrokeStore? store,
|
||||||
|
}) : store = store ?? StrokeStore();
|
||||||
|
|
||||||
|
/// Stable id (e.g. `"doc:<id>:page:<n>"` or a board-region id) used as the
|
||||||
|
/// ink Picture cache key prefix + the persistence host id.
|
||||||
|
final String hostId;
|
||||||
|
|
||||||
|
/// Content size in logical px at scale 1; normalized [0,1] coords map onto it.
|
||||||
|
final Size contentSize;
|
||||||
|
|
||||||
|
/// Committed strokes for this host (revision-tracked).
|
||||||
|
final StrokeStore store;
|
||||||
|
|
||||||
|
/// Revision of the committed strokes (O(1) repaint gate passthrough).
|
||||||
|
int get revision => store.revision;
|
||||||
|
|
||||||
|
/// Committed strokes whose bounds overlap [viewportNormalized] — broad-phase
|
||||||
|
/// culling for the infinite board (skip off-screen strokes). For a bounded
|
||||||
|
/// PDF page the whole page is usually in view, so callers can skip this.
|
||||||
|
List<EditorStroke> strokesIn(Rect viewportNormalized) {
|
||||||
|
return [
|
||||||
|
for (final s in store.committed)
|
||||||
|
if (strokeIntersects(s, viewportNormalized)) s,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'StrokeHost($hostId, $contentSize, rev=$revision)';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
80
lib/editor/engine/stroke_simplify.dart
Normal file
80
lib/editor/engine/stroke_simplify.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// lib/editor/engine/stroke_simplify.dart
|
||||||
|
//
|
||||||
|
// Ramer–Douglas–Peucker stroke point reduction. A fast Surface-Pen stroke can
|
||||||
|
// land hundreds of nearly-collinear samples; thinning them before persistence
|
||||||
|
// shrinks the DB row + speeds re-rasterization (R1/R10 perf) with no visible
|
||||||
|
// change. Endpoints + perceptually-significant vertices are kept; pressure/tilt
|
||||||
|
// ride along on the retained points.
|
||||||
|
//
|
||||||
|
// Pure geometry over EditorStroke (normalized coords); fully unit-tested. The
|
||||||
|
// commit path can call this before saveHost; the live in-progress stroke is left
|
||||||
|
// untouched so drawing stays crisp.
|
||||||
|
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// Returns [stroke] with its points reduced by RDP at [tolerance] (perpendicular
|
||||||
|
/// distance in normalized units; larger = more aggressive). Strokes with <= 2
|
||||||
|
/// points, or a non-positive tolerance, are returned unchanged.
|
||||||
|
EditorStroke simplifyStroke(EditorStroke stroke, {double tolerance = 0.0008}) {
|
||||||
|
final pts = stroke.points;
|
||||||
|
if (pts.length <= 2 || tolerance <= 0) return stroke;
|
||||||
|
|
||||||
|
final keep = List<bool>.filled(pts.length, false);
|
||||||
|
keep[0] = true;
|
||||||
|
keep[pts.length - 1] = true;
|
||||||
|
_rdp(pts, 0, pts.length - 1, tolerance * tolerance, keep);
|
||||||
|
|
||||||
|
final reduced = <EditorPoint>[
|
||||||
|
for (var i = 0; i < pts.length; i++)
|
||||||
|
if (keep[i]) pts[i],
|
||||||
|
];
|
||||||
|
if (reduced.length == pts.length) return stroke;
|
||||||
|
return stroke.copyWith(points: reduced);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative-friendly recursion over the index range [first, last].
|
||||||
|
void _rdp(
|
||||||
|
List<EditorPoint> pts,
|
||||||
|
int first,
|
||||||
|
int last,
|
||||||
|
double tolSq,
|
||||||
|
List<bool> keep,
|
||||||
|
) {
|
||||||
|
if (last <= first + 1) return;
|
||||||
|
var maxDistSq = 0.0;
|
||||||
|
var index = -1;
|
||||||
|
final ax = pts[first].x, ay = pts[first].y;
|
||||||
|
final bx = pts[last].x, by = pts[last].y;
|
||||||
|
for (var i = first + 1; i < last; i++) {
|
||||||
|
final d = _perpDistSq(pts[i].x, pts[i].y, ax, ay, bx, by);
|
||||||
|
if (d > maxDistSq) {
|
||||||
|
maxDistSq = d;
|
||||||
|
index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxDistSq > tolSq && index != -1) {
|
||||||
|
keep[index] = true;
|
||||||
|
_rdp(pts, first, index, tolSq, keep);
|
||||||
|
_rdp(pts, index, last, tolSq, keep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Squared perpendicular distance of (px,py) from the segment (ax,ay)-(bx,by).
|
||||||
|
/// Degenerate segment (a==b) falls back to squared distance to the point.
|
||||||
|
double _perpDistSq(
|
||||||
|
double px,
|
||||||
|
double py,
|
||||||
|
double ax,
|
||||||
|
double ay,
|
||||||
|
double bx,
|
||||||
|
double by,
|
||||||
|
) {
|
||||||
|
final dx = bx - ax, dy = by - ay;
|
||||||
|
final lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq == 0) {
|
||||||
|
final ex = px - ax, ey = py - ay;
|
||||||
|
return ex * ex + ey * ey;
|
||||||
|
}
|
||||||
|
final cross = (px - ax) * dy - (py - ay) * dx;
|
||||||
|
return (cross * cross) / lenSq;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
112
lib/editor/input/pressure_curve.dart
Normal file
112
lib/editor/input/pressure_curve.dart
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// lib/editor/input/pressure_curve.dart
|
||||||
|
//
|
||||||
|
// Configurable pen-pressure response (F5 — the user's repeated "可配置笔" ask).
|
||||||
|
// Raw normalized stylus pressure [0,1] is pre-shaped here before it reaches
|
||||||
|
// perfect_freehand, giving two user-facing knobs:
|
||||||
|
// - [floor]: a minimum output (the plan's marker "min-width floor" — a fixed-
|
||||||
|
// pressure marker uses floor≈1.0; a pen uses 0.0).
|
||||||
|
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
||||||
|
// 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
|
||||||
|
// tested; PenConfig / the canvas wire it later (the wiring touches the live
|
||||||
|
// draw path and is validated on-device).
|
||||||
|
|
||||||
|
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]`.
|
||||||
|
class PressureCurve {
|
||||||
|
const PressureCurve({
|
||||||
|
this.floor = 0.0,
|
||||||
|
this.gamma = 1.0,
|
||||||
|
this.shape,
|
||||||
|
}) : assert(floor >= 0.0 && floor < 1.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
|
||||||
|
/// fixed-pressure feel (marker).
|
||||||
|
final double floor;
|
||||||
|
|
||||||
|
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
|
||||||
|
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
|
||||||
|
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).
|
||||||
|
static const PressureCurve linear = PressureCurve();
|
||||||
|
|
||||||
|
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
|
||||||
|
double apply(double pressure) {
|
||||||
|
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/editor/input/tool_settings.dart
Normal file
88
lib/editor/input/tool_settings.dart
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// lib/editor/input/tool_settings.dart
|
||||||
|
//
|
||||||
|
// Per-tool settings memory for the tool palette (F1/F5/F11). Each tool keeps its
|
||||||
|
// OWN color + width, so switching pen → highlighter → pen restores the pen's
|
||||||
|
// last color/width instead of bleeding the highlighter's. Immutable value type
|
||||||
|
// with copy-on-write updates; the toolbar holds one of these and persists it.
|
||||||
|
//
|
||||||
|
// Pure (no widgets/storage); fully unit-tested.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
enum EditorToolType { pen, highlighter, eraser }
|
||||||
|
|
||||||
|
/// Color (ARGB int) + width (fraction of page width) for one tool. The eraser
|
||||||
|
/// ignores color but keeps a width (its radius).
|
||||||
|
@immutable
|
||||||
|
class ToolConfig {
|
||||||
|
const ToolConfig({required this.color, required this.width});
|
||||||
|
|
||||||
|
final int color;
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
ToolConfig copyWith({int? color, double? width}) =>
|
||||||
|
ToolConfig(color: color ?? this.color, width: width ?? this.width);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ToolConfig && other.color == color && other.width == width;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(color, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active tool + each tool's remembered [ToolConfig].
|
||||||
|
@immutable
|
||||||
|
class ToolSettings {
|
||||||
|
const ToolSettings({required this.active, required Map<EditorToolType, ToolConfig> configs})
|
||||||
|
: _configs = configs;
|
||||||
|
|
||||||
|
/// Sensible starting state: black thin pen, yellow fat highlighter, medium
|
||||||
|
/// eraser; pen active.
|
||||||
|
factory ToolSettings.defaults() => const ToolSettings(
|
||||||
|
active: EditorToolType.pen,
|
||||||
|
configs: {
|
||||||
|
EditorToolType.pen: ToolConfig(color: 0xFF000000, width: 0.003),
|
||||||
|
EditorToolType.highlighter:
|
||||||
|
ToolConfig(color: 0x80FFEB3B, width: 0.02),
|
||||||
|
EditorToolType.eraser: ToolConfig(color: 0x00000000, width: 0.02),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final EditorToolType active;
|
||||||
|
final Map<EditorToolType, ToolConfig> _configs;
|
||||||
|
|
||||||
|
ToolConfig configFor(EditorToolType tool) =>
|
||||||
|
_configs[tool] ?? const ToolConfig(color: 0xFF000000, width: 0.003);
|
||||||
|
|
||||||
|
ToolConfig get activeConfig => configFor(active);
|
||||||
|
|
||||||
|
/// Switch the active tool (each tool's own color/width is remembered).
|
||||||
|
ToolSettings withActive(EditorToolType tool) =>
|
||||||
|
ToolSettings(active: tool, configs: _configs);
|
||||||
|
|
||||||
|
ToolSettings _withConfig(EditorToolType tool, ToolConfig config) {
|
||||||
|
return ToolSettings(
|
||||||
|
active: active,
|
||||||
|
configs: {..._configs, tool: config},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the ACTIVE tool's color (no-op semantics for the eraser are the
|
||||||
|
/// caller's choice; the value is still stored).
|
||||||
|
ToolSettings withColor(int color) =>
|
||||||
|
_withConfig(active, activeConfig.copyWith(color: color));
|
||||||
|
|
||||||
|
/// Set the ACTIVE tool's width.
|
||||||
|
ToolSettings withWidth(double width) =>
|
||||||
|
_withConfig(active, activeConfig.copyWith(width: width));
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ToolSettings &&
|
||||||
|
other.active == active &&
|
||||||
|
mapEquals(other._configs, _configs);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(active, Object.hashAll(_configs.entries));
|
||||||
|
}
|
||||||
75
lib/editor/layout/double_page_layout.dart
Normal file
75
lib/editor/layout/double_page_layout.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// lib/editor/layout/double_page_layout.dart
|
||||||
|
//
|
||||||
|
// Two-up (spread) layout foundation for book-like reading (F2/F3 — the user's
|
||||||
|
// explicit "two-page spread" ask). Pages are grouped into spread ROWS; each row
|
||||||
|
// then stacks vertically exactly like continuous-single, so windowing reuses
|
||||||
|
// PageStackMetrics on the row heights.
|
||||||
|
//
|
||||||
|
// Pure geometry (no widgets / pdfrx): the row-mounting widget is device-gated;
|
||||||
|
// the pairing + row-height math is here, fully unit-tested. Building it ahead of
|
||||||
|
// its phase is zero-rework-risk (it is not rendering and the P0.5 perf gate
|
||||||
|
// can't invalidate pure geometry).
|
||||||
|
|
||||||
|
import 'page_viewport.dart';
|
||||||
|
import '../pdf/pdf_document_source.dart';
|
||||||
|
|
||||||
|
/// Groups page indices into two-up spread rows. With [coverAlone], page 0 sits
|
||||||
|
/// on its own row (a book cover / title page) and the rest pair 1-2, 3-4, …;
|
||||||
|
/// otherwise pages pair from 0. A trailing odd page occupies a single-page row.
|
||||||
|
List<List<int>> pairIntoRows(int pageCount, {bool coverAlone = false}) {
|
||||||
|
assert(pageCount >= 0);
|
||||||
|
final rows = <List<int>>[];
|
||||||
|
var i = 0;
|
||||||
|
if (coverAlone && pageCount > 0) {
|
||||||
|
rows.add(<int>[0]);
|
||||||
|
i = 1;
|
||||||
|
}
|
||||||
|
while (i < pageCount) {
|
||||||
|
if (i + 1 < pageCount) {
|
||||||
|
rows.add(<int>[i, i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
rows.add(<int>[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Height of each spread row when each page is fit to HALF of [columnWidth]
|
||||||
|
/// (two pages share the column). A row's height is the tallest of its pages so
|
||||||
|
/// both pages sit on a common baseline. Non-positive page widths contribute 0.
|
||||||
|
List<double> spreadRowHeights(
|
||||||
|
PageDocumentSource source,
|
||||||
|
double columnWidth,
|
||||||
|
List<List<int>> rows,
|
||||||
|
) {
|
||||||
|
assert(columnWidth >= 0);
|
||||||
|
final half = columnWidth / 2;
|
||||||
|
return rows.map((row) {
|
||||||
|
var tallest = 0.0;
|
||||||
|
for (final pageIndex in row) {
|
||||||
|
final size = source.pageSize(pageIndex);
|
||||||
|
if (size.width <= 0) continue;
|
||||||
|
final fit = half * (size.height / size.width);
|
||||||
|
if (fit > tallest) tallest = fit;
|
||||||
|
}
|
||||||
|
return tallest;
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continuous-DOUBLE stacking metrics: pair pages into spread rows, then stack
|
||||||
|
/// the rows vertically. [PageStackMetrics.visibleRange] over the result yields
|
||||||
|
/// the visible ROW range; map rows back to pages via the [pairIntoRows] result.
|
||||||
|
PageStackMetrics spreadStackMetrics(
|
||||||
|
PageDocumentSource source,
|
||||||
|
double columnWidth, {
|
||||||
|
bool coverAlone = false,
|
||||||
|
double gap = 0.0,
|
||||||
|
}) {
|
||||||
|
final rows = pairIntoRows(source.pageCount, coverAlone: coverAlone);
|
||||||
|
return PageStackMetrics(
|
||||||
|
pageHeights: spreadRowHeights(source, columnWidth, rows),
|
||||||
|
gap: gap,
|
||||||
|
);
|
||||||
|
}
|
||||||
196
lib/editor/layout/page_viewport.dart
Normal file
196
lib/editor/layout/page_viewport.dart
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// lib/editor/layout/page_viewport.dart
|
||||||
|
//
|
||||||
|
// Continuous-single layout geometry: the PURE windowing math that decides which
|
||||||
|
// pages are mounted for a given scroll position (P0.5 step 10). Pages stack
|
||||||
|
// vertically; only the pages intersecting the viewport ± a cache band are
|
||||||
|
// mounted (windowed lazy hosting → 60fps on a 300-page doc, R1).
|
||||||
|
//
|
||||||
|
// This file is intentionally widget-free and pdfrx-free: the actual page
|
||||||
|
// mounting (a re-laid-out PdfPageView + the AnnotationLayer per page) and the
|
||||||
|
// zoom-settle DPI refresh are device-gated and live in the viewport WIDGET +
|
||||||
|
// pdf/page_tile.dart. The geometry is separated so it is unit-testable without
|
||||||
|
// a GPU or a real document.
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// An inclusive range of page indices to mount. [isEmpty] when nothing
|
||||||
|
/// intersects (e.g. an empty document or a scroll position past the end with no
|
||||||
|
/// cache band reaching back).
|
||||||
|
class PageWindow {
|
||||||
|
const PageWindow(this.first, this.last);
|
||||||
|
|
||||||
|
/// Sentinel empty window.
|
||||||
|
static const PageWindow empty = PageWindow(0, -1);
|
||||||
|
|
||||||
|
final int first;
|
||||||
|
final int last;
|
||||||
|
|
||||||
|
bool get isEmpty => last < first;
|
||||||
|
int get count => isEmpty ? 0 : (last - first + 1);
|
||||||
|
|
||||||
|
bool contains(int index) => !isEmpty && index >= first && index <= last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is PageWindow && other.first == first && other.last == last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(first, last);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => isEmpty ? 'PageWindow.empty' : 'PageWindow($first..$last)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical stacking metrics for continuous-single layout.
|
||||||
|
///
|
||||||
|
/// Page `i` occupies the half-open band `[offsetOf(i), offsetOf(i) + heightOf(i))`
|
||||||
|
/// in content (scale-1) coordinates, with [gap] inserted between consecutive
|
||||||
|
/// pages. Cumulative tops are precomputed so [visibleRange] is O(log n) per
|
||||||
|
/// scroll frame.
|
||||||
|
class PageStackMetrics {
|
||||||
|
PageStackMetrics({required List<double> pageHeights, this.gap = 0.0})
|
||||||
|
: assert(gap >= 0),
|
||||||
|
_heights = List<double>.unmodifiable(pageHeights),
|
||||||
|
_tops = _cumulativeTops(pageHeights, gap);
|
||||||
|
|
||||||
|
final List<double> _heights;
|
||||||
|
|
||||||
|
/// Top edge of each page in content coordinates (length == pageCount).
|
||||||
|
final List<double> _tops;
|
||||||
|
|
||||||
|
/// Gap between consecutive pages in content units.
|
||||||
|
final double gap;
|
||||||
|
|
||||||
|
int get pageCount => _heights.length;
|
||||||
|
|
||||||
|
/// Total scrollable content height (0 when there are no pages).
|
||||||
|
double get totalExtent {
|
||||||
|
if (_heights.isEmpty) return 0;
|
||||||
|
return _tops.last + _heights.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
double heightOf(int index) => _heights[index];
|
||||||
|
|
||||||
|
/// Top edge (content coordinate) of page [index].
|
||||||
|
double offsetOf(int index) => _tops[index];
|
||||||
|
|
||||||
|
static List<double> _cumulativeTops(List<double> heights, double gap) {
|
||||||
|
final tops = List<double>.filled(heights.length, 0);
|
||||||
|
var acc = 0.0;
|
||||||
|
for (var i = 0; i < heights.length; i++) {
|
||||||
|
tops[i] = acc;
|
||||||
|
acc += heights[i] + gap;
|
||||||
|
}
|
||||||
|
return List<double>.unmodifiable(tops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inclusive page range intersecting the viewport
|
||||||
|
/// `[scrollOffset, scrollOffset + viewportExtent)` grown by [cacheExtent] on
|
||||||
|
/// each side. Pages whose band overlaps the grown window (even partially) are
|
||||||
|
/// included; the result is clamped to `[0, pageCount-1]`.
|
||||||
|
///
|
||||||
|
/// Returns [PageWindow.empty] for an empty document or a window that does not
|
||||||
|
/// reach any page.
|
||||||
|
PageWindow visibleRange(
|
||||||
|
double scrollOffset,
|
||||||
|
double viewportExtent, {
|
||||||
|
double cacheExtent = 0.0,
|
||||||
|
}) {
|
||||||
|
if (_heights.isEmpty) return PageWindow.empty;
|
||||||
|
assert(viewportExtent >= 0);
|
||||||
|
assert(cacheExtent >= 0);
|
||||||
|
|
||||||
|
final double windowTop = scrollOffset - cacheExtent;
|
||||||
|
final double windowBottom = scrollOffset + viewportExtent + cacheExtent;
|
||||||
|
|
||||||
|
// No overlap with the content at all.
|
||||||
|
if (windowBottom <= 0 || windowTop >= totalExtent) {
|
||||||
|
return PageWindow.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int first = _firstIntersecting(windowTop);
|
||||||
|
final int last = _lastIntersecting(windowBottom);
|
||||||
|
if (last < first) return PageWindow.empty;
|
||||||
|
return PageWindow(first, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum scroll offset so the last page bottom rests at the viewport
|
||||||
|
/// bottom (never negative — a document shorter than the viewport can't
|
||||||
|
/// scroll).
|
||||||
|
double maxScrollExtent(double viewportExtent) {
|
||||||
|
final max = totalExtent - viewportExtent;
|
||||||
|
return max > 0 ? max : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamps [scrollOffset] into the legal `[0, maxScrollExtent]` range.
|
||||||
|
double clampScroll(double scrollOffset, double viewportExtent) {
|
||||||
|
final max = maxScrollExtent(viewportExtent);
|
||||||
|
if (scrollOffset < 0) return 0.0;
|
||||||
|
return scrollOffset > max ? max : scrollOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "current" page for a scroll position: the page covering the LARGEST
|
||||||
|
/// portion of the viewport `[scrollOffset, scrollOffset + viewportExtent)`.
|
||||||
|
/// Drives the page-number indicator + thumbnail-grid highlight (F4). Returns
|
||||||
|
/// 0 for an empty document.
|
||||||
|
int dominantPageAt(double scrollOffset, double viewportExtent) {
|
||||||
|
if (_heights.isEmpty) return 0;
|
||||||
|
final window = visibleRange(scrollOffset, viewportExtent);
|
||||||
|
if (window.isEmpty) {
|
||||||
|
// Past the end / before the start → clamp to nearest real page.
|
||||||
|
return scrollOffset <= 0 ? 0 : pageCount - 1;
|
||||||
|
}
|
||||||
|
final viewTop = scrollOffset;
|
||||||
|
final viewBottom = scrollOffset + viewportExtent;
|
||||||
|
var best = window.first;
|
||||||
|
var bestOverlap = -1.0;
|
||||||
|
for (var i = window.first; i <= window.last; i++) {
|
||||||
|
final top = _tops[i];
|
||||||
|
final bottom = top + _heights[i];
|
||||||
|
final overlap =
|
||||||
|
math.min(bottom, viewBottom) - math.max(top, viewTop);
|
||||||
|
if (overlap > bestOverlap) {
|
||||||
|
bestOverlap = overlap;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowest index whose band bottom is strictly after [y] (i.e. the first page
|
||||||
|
/// that the window's top edge does not sit fully below).
|
||||||
|
int _firstIntersecting(double y) {
|
||||||
|
// Find the first page whose bottom edge (top + height) > y.
|
||||||
|
var lo = 0;
|
||||||
|
var hi = _heights.length - 1;
|
||||||
|
var result = _heights.length - 1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
final mid = (lo + hi) >> 1;
|
||||||
|
final bottom = _tops[mid] + _heights[mid];
|
||||||
|
if (bottom > y) {
|
||||||
|
result = mid;
|
||||||
|
hi = mid - 1;
|
||||||
|
} else {
|
||||||
|
lo = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.max(0, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest index whose top edge is strictly before [y].
|
||||||
|
int _lastIntersecting(double y) {
|
||||||
|
var lo = 0;
|
||||||
|
var hi = _heights.length - 1;
|
||||||
|
var result = 0;
|
||||||
|
while (lo <= hi) {
|
||||||
|
final mid = (lo + hi) >> 1;
|
||||||
|
if (_tops[mid] < y) {
|
||||||
|
result = mid;
|
||||||
|
lo = mid + 1;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return math.min(_heights.length - 1, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/editor/layout/viewport_fit.dart
Normal file
39
lib/editor/layout/viewport_fit.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// lib/editor/layout/viewport_fit.dart
|
||||||
|
//
|
||||||
|
// Pure fit/centering math for the viewport's initial transform + the reader
|
||||||
|
// "fit width / fit page" actions (F3). Today this lives as untested ad-hoc
|
||||||
|
// arithmetic in pen_editor_screen._centerPage; extracting it here makes it
|
||||||
|
// testable and shared by the new viewport widget.
|
||||||
|
//
|
||||||
|
// Content coordinates are scale-1 logical px; the returned scale + offset place
|
||||||
|
// content inside the viewport. No widgets beyond dart:ui Size/Offset.
|
||||||
|
|
||||||
|
import 'dart:ui' show Offset, Size;
|
||||||
|
|
||||||
|
/// Scale so content width fills the viewport width (fit-to-width, the
|
||||||
|
/// continuous-single default). 0 for non-positive content width.
|
||||||
|
double fitWidthScale(Size content, double viewportWidth) {
|
||||||
|
if (content.width <= 0) return 0;
|
||||||
|
return viewportWidth / content.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale so the whole content fits inside the viewport (letterboxed) —
|
||||||
|
/// min(widthFit, heightFit). 0 for non-positive content extents.
|
||||||
|
double fitPageScale(Size content, Size viewport) {
|
||||||
|
if (content.width <= 0 || content.height <= 0) return 0;
|
||||||
|
final w = viewport.width / content.width;
|
||||||
|
final h = viewport.height / content.height;
|
||||||
|
return w < h ? w : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-left translation that centers [content] scaled by [scale] within
|
||||||
|
/// [viewport]. When the scaled content is larger than the viewport on an axis
|
||||||
|
/// the offset is negative (content overflows equally on both sides).
|
||||||
|
Offset centerOffset(Size content, Size viewport, double scale) {
|
||||||
|
final scaledW = content.width * scale;
|
||||||
|
final scaledH = content.height * scale;
|
||||||
|
return Offset(
|
||||||
|
(viewport.width - scaledW) / 2.0,
|
||||||
|
(viewport.height - scaledH) / 2.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
85
lib/editor/link/link_graph.dart
Normal file
85
lib/editor/link/link_graph.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// lib/editor/link/link_graph.dart
|
||||||
|
//
|
||||||
|
// Pure bidirectional-link (双链) core for the sticky-note / board system (F7).
|
||||||
|
// Notes reference each other with `[[target]]` wiki-style links; this builds the
|
||||||
|
// forward + backlink indices so a note can show "what links here" (backlinks).
|
||||||
|
//
|
||||||
|
// Deliberately pure + widget-free + storage-free: nodes are (id, text) pairs and
|
||||||
|
// links are matched by the bracketed TARGET string (a note title or id). The
|
||||||
|
// board UI + persistence wrap this; the graph logic is fully unit-testable.
|
||||||
|
|
||||||
|
/// Matches `[[target]]` spans. The target is everything up to the first `]`,
|
||||||
|
/// trimmed; empty targets (`[[]]`) are ignored by [parseLinkTargets].
|
||||||
|
final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]');
|
||||||
|
|
||||||
|
/// Extracts the ordered, de-duplicated list of `[[link]]` targets in [text].
|
||||||
|
/// Targets are trimmed; blank targets are skipped. Order is first-occurrence.
|
||||||
|
List<String> parseLinkTargets(String text) {
|
||||||
|
final seen = <String>{};
|
||||||
|
final result = <String>[];
|
||||||
|
for (final match in _linkPattern.allMatches(text)) {
|
||||||
|
final target = (match.group(1) ?? '').trim();
|
||||||
|
if (target.isEmpty) continue;
|
||||||
|
if (seen.add(target)) result.add(target);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An immutable forward + backward link index over a set of nodes.
|
||||||
|
///
|
||||||
|
/// Build with [LinkGraph.fromTexts] (id → raw text, links parsed from the text)
|
||||||
|
/// or [LinkGraph.fromLinks] (id → explicit target list). Links are matched by
|
||||||
|
/// the bracketed target string; a target that is not itself a node id is still
|
||||||
|
/// recorded (a dangling link) so [danglingTargets] can surface broken links.
|
||||||
|
class LinkGraph {
|
||||||
|
LinkGraph._(this._forward, this._backward, this._nodeIds);
|
||||||
|
|
||||||
|
/// Build from raw note texts, parsing `[[targets]]` out of each.
|
||||||
|
factory LinkGraph.fromTexts(Map<String, String> textById) {
|
||||||
|
return LinkGraph.fromLinks({
|
||||||
|
for (final entry in textById.entries)
|
||||||
|
entry.key: parseLinkTargets(entry.value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build from explicit per-node target lists.
|
||||||
|
factory LinkGraph.fromLinks(Map<String, List<String>> targetsById) {
|
||||||
|
final forward = <String, Set<String>>{};
|
||||||
|
final backward = <String, Set<String>>{};
|
||||||
|
final nodeIds = targetsById.keys.toSet();
|
||||||
|
|
||||||
|
for (final entry in targetsById.entries) {
|
||||||
|
final from = entry.key;
|
||||||
|
final targets = forward.putIfAbsent(from, () => <String>{});
|
||||||
|
for (final to in entry.value) {
|
||||||
|
if (to == from) continue; // ignore self-links
|
||||||
|
targets.add(to);
|
||||||
|
backward.putIfAbsent(to, () => <String>{}).add(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LinkGraph._(forward, backward, nodeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Set<String>> _forward;
|
||||||
|
final Map<String, Set<String>> _backward;
|
||||||
|
final Set<String> _nodeIds;
|
||||||
|
|
||||||
|
/// Targets that [id] links TO (outbound). Empty when [id] has no links.
|
||||||
|
Set<String> linksFrom(String id) =>
|
||||||
|
Set.unmodifiable(_forward[id] ?? const <String>{});
|
||||||
|
|
||||||
|
/// Node ids that link TO [id] (inbound / backlinks). Empty when nothing
|
||||||
|
/// references [id].
|
||||||
|
Set<String> backlinksOf(String id) =>
|
||||||
|
Set.unmodifiable(_backward[id] ?? const <String>{});
|
||||||
|
|
||||||
|
/// All link targets that are not themselves known node ids (broken links).
|
||||||
|
Set<String> danglingTargets() {
|
||||||
|
final targets = <String>{};
|
||||||
|
for (final set in _forward.values) {
|
||||||
|
targets.addAll(set);
|
||||||
|
}
|
||||||
|
targets.removeAll(_nodeIds);
|
||||||
|
return Set.unmodifiable(targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
121
lib/editor/notebook/page_map.dart
Normal file
121
lib/editor/notebook/page_map.dart
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// lib/editor/notebook/page_map.dart
|
||||||
|
//
|
||||||
|
// The one-notebook-per-PDF logical page model (F6). A notebook is an ordered
|
||||||
|
// list of LOGICAL pages; each is either a SOURCE page (shows PDF page N, vector
|
||||||
|
// preserved) or a BLANK page inserted between/after PDF pages (SpeedyNote-style
|
||||||
|
// binding the user asked for). Ink hosts attach to logical pages, so inserting
|
||||||
|
// or reordering pages must NOT renumber the PDF underlay — the source index is
|
||||||
|
// carried on each page.
|
||||||
|
//
|
||||||
|
// Pure, immutable value type (every edit returns a NEW PageMap) so the model is
|
||||||
|
// fully unit-tested; the `notebook_pages` table + the viewport wire it later.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
enum NotebookPageKind { source, blank }
|
||||||
|
|
||||||
|
/// One logical page. [sourcePageIndex] is the 0-based PDF page it renders, or
|
||||||
|
/// null for a [NotebookPageKind.blank] inserted page.
|
||||||
|
@immutable
|
||||||
|
class NotebookPage {
|
||||||
|
const NotebookPage.source(this.sourcePageIndex)
|
||||||
|
: kind = NotebookPageKind.source;
|
||||||
|
|
||||||
|
const NotebookPage.blank()
|
||||||
|
: kind = NotebookPageKind.blank,
|
||||||
|
sourcePageIndex = null;
|
||||||
|
|
||||||
|
final NotebookPageKind kind;
|
||||||
|
final int? sourcePageIndex;
|
||||||
|
|
||||||
|
bool get isBlank => kind == NotebookPageKind.blank;
|
||||||
|
bool get isSource => kind == NotebookPageKind.source;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is NotebookPage &&
|
||||||
|
other.kind == kind &&
|
||||||
|
other.sourcePageIndex == sourcePageIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(kind, sourcePageIndex);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
isBlank ? 'NotebookPage.blank' : 'NotebookPage.source($sourcePageIndex)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered logical page list with copy-on-write edits.
|
||||||
|
@immutable
|
||||||
|
class PageMap {
|
||||||
|
const PageMap(this.pages);
|
||||||
|
|
||||||
|
/// Identity map: one logical SOURCE page per PDF page, in order.
|
||||||
|
factory PageMap.fromSource(int sourcePageCount) {
|
||||||
|
assert(sourcePageCount >= 0);
|
||||||
|
return PageMap(
|
||||||
|
List<NotebookPage>.unmodifiable(
|
||||||
|
List<NotebookPage>.generate(
|
||||||
|
sourcePageCount,
|
||||||
|
(i) => NotebookPage.source(i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<NotebookPage> pages;
|
||||||
|
|
||||||
|
int get length => pages.length;
|
||||||
|
bool get isEmpty => pages.isEmpty;
|
||||||
|
|
||||||
|
NotebookPage operator [](int index) => pages[index];
|
||||||
|
|
||||||
|
/// Source PDF page rendered at logical [index], or null when it's a blank.
|
||||||
|
int? sourceIndexAt(int index) => pages[index].sourcePageIndex;
|
||||||
|
|
||||||
|
int get blankCount => pages.where((p) => p.isBlank).length;
|
||||||
|
int get sourceCount => pages.where((p) => p.isSource).length;
|
||||||
|
|
||||||
|
/// Insert a blank page at logical [index] (0..length). Throws [RangeError]
|
||||||
|
/// for an out-of-range index.
|
||||||
|
PageMap insertBlankAt(int index) {
|
||||||
|
RangeError.checkValueInInterval(index, 0, length, 'index');
|
||||||
|
final next = List<NotebookPage>.of(pages)
|
||||||
|
..insert(index, const NotebookPage.blank());
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a blank page immediately after logical [index] (-1 prepends).
|
||||||
|
PageMap insertBlankAfter(int index) {
|
||||||
|
RangeError.checkValueInInterval(index, -1, length - 1, 'index');
|
||||||
|
return insertBlankAt(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the logical page at [index].
|
||||||
|
PageMap removeAt(int index) {
|
||||||
|
RangeError.checkValidIndex(index, pages, 'index');
|
||||||
|
final next = List<NotebookPage>.of(pages)..removeAt(index);
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the page at [from] to position [to] (drag-reorder).
|
||||||
|
PageMap move(int from, int to) {
|
||||||
|
RangeError.checkValidIndex(from, pages, 'from');
|
||||||
|
RangeError.checkValueInInterval(to, 0, length - 1, 'to');
|
||||||
|
if (from == to) return this;
|
||||||
|
final next = List<NotebookPage>.of(pages);
|
||||||
|
final page = next.removeAt(from);
|
||||||
|
next.insert(to, page);
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is PageMap && listEquals(other.pages, pages);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(pages);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PageMap($pages)';
|
||||||
|
}
|
||||||
137
lib/editor/pdf/page_tile_cache.dart
Normal file
137
lib/editor/pdf/page_tile_cache.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// lib/editor/pdf/page_tile_cache.dart
|
||||||
|
//
|
||||||
|
// Bounded LRU cache of rasterized PAGE tiles (ui.Image), DPI-bucketed.
|
||||||
|
//
|
||||||
|
// This is the "heavy" cache (a single A4 page at 3× DPI is ~18 MB) and is
|
||||||
|
// DELIBERATELY SEPARATE from the resolution-independent ink Picture cache
|
||||||
|
// (render/ink_picture_cache.dart): ink is vector and valid at any zoom, but a
|
||||||
|
// page bitmap is only crisp at the DPI it was rasterized for, so its key
|
||||||
|
// carries a DPI bucket (R11 / MF2). On zoom-settle the page_tile renderer
|
||||||
|
// re-rasterizes at the new bucket and put()s it here; matrix-upscale of a lower
|
||||||
|
// bucket is the accepted transient until the new tile lands.
|
||||||
|
//
|
||||||
|
// Tiles are rendered ASYNCHRONOUSLY (pdfrx PdfPage.render / a re-laid-out
|
||||||
|
// PdfPageView), so the cache is get()/put() — NOT getOrBuild — and the caller
|
||||||
|
// owns the async render. Evicted images are disposed via a post-frame callback
|
||||||
|
// so Flutter's raster thread is never asked to free a ui.Image it may still be
|
||||||
|
// sampling this frame.
|
||||||
|
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Identity of a cached page tile: which host (page) and which DPI bucket.
|
||||||
|
///
|
||||||
|
/// The DPI bucket (an integer, e.g. round(scale × base-DPI) snapped to a step)
|
||||||
|
/// keeps the key space small so a smooth pinch doesn't spawn a distinct tile
|
||||||
|
/// per frame — only per bucket.
|
||||||
|
@immutable
|
||||||
|
class TileKey {
|
||||||
|
const TileKey(this.hostId, this.dpiBucket);
|
||||||
|
|
||||||
|
final String hostId;
|
||||||
|
final int dpiBucket;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is TileKey &&
|
||||||
|
other.hostId == hostId &&
|
||||||
|
other.dpiBucket == dpiBucket;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(hostId, dpiBucket);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'TileKey($hostId @dpi$dpiBucket)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded LRU cache of page-tile [ui.Image]s keyed by [TileKey].
|
||||||
|
///
|
||||||
|
/// Capacity is a TILE COUNT (not bytes); size the window to the device memory
|
||||||
|
/// budget — full-DPI tiles for visible ±1 pages, off-window pages downgraded to
|
||||||
|
/// a 1× tier elsewhere (see the plan's R10 resolution). Eviction disposes the
|
||||||
|
/// image post-frame.
|
||||||
|
class PageTileCache {
|
||||||
|
PageTileCache({int maxTiles = 6})
|
||||||
|
: assert(maxTiles > 0),
|
||||||
|
_maxTiles = maxTiles;
|
||||||
|
|
||||||
|
final int _maxTiles;
|
||||||
|
|
||||||
|
// Insertion-ordered; accessed entries are moved to the back so the front is
|
||||||
|
// always the least-recently-used.
|
||||||
|
final LinkedHashMap<TileKey, ui.Image> _cache =
|
||||||
|
LinkedHashMap<TileKey, ui.Image>();
|
||||||
|
|
||||||
|
/// Number of tiles currently retained.
|
||||||
|
int get length => _cache.length;
|
||||||
|
|
||||||
|
/// The keys currently retained, most-recently-used LAST.
|
||||||
|
Iterable<TileKey> get keys => _cache.keys;
|
||||||
|
|
||||||
|
/// Returns the cached image for [key] (promoting it to most-recently-used),
|
||||||
|
/// or null on a miss. The caller renders + [put]s on a miss.
|
||||||
|
ui.Image? get(TileKey key) {
|
||||||
|
final image = _cache.remove(key);
|
||||||
|
if (image == null) return null;
|
||||||
|
_cache[key] = image; // promote to MRU
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts [image] for [key], evicting the least-recently-used tiles beyond
|
||||||
|
/// the cap. If a DIFFERENT image was already stored for [key], the old one is
|
||||||
|
/// disposed (post-frame). Re-putting the identical image is a no-op promote.
|
||||||
|
void put(TileKey key, ui.Image image) {
|
||||||
|
final existing = _cache.remove(key);
|
||||||
|
if (existing != null && !identical(existing, image)) {
|
||||||
|
_disposeDeferred(existing);
|
||||||
|
}
|
||||||
|
_cache[key] = image;
|
||||||
|
|
||||||
|
while (_cache.length > _maxTiles) {
|
||||||
|
final lruKey = _cache.keys.first;
|
||||||
|
_disposeDeferred(_cache.remove(lruKey)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts every tile whose host is NOT in [liveHostIds] (e.g. pages that
|
||||||
|
/// scrolled out of the mounted window). Disposed post-frame.
|
||||||
|
void evictHostsExcept(Set<String> liveHostIds) {
|
||||||
|
final doomed = _cache.keys
|
||||||
|
.where((k) => !liveHostIds.contains(k.hostId))
|
||||||
|
.toList(growable: false);
|
||||||
|
for (final key in doomed) {
|
||||||
|
_disposeDeferred(_cache.remove(key)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes all retained tiles (post-frame). Call from the owner's dispose.
|
||||||
|
void dispose() {
|
||||||
|
final images = List<ui.Image>.from(_cache.values);
|
||||||
|
_cache.clear();
|
||||||
|
for (final image in images) {
|
||||||
|
_disposeDeferred(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _disposeDeferred(ui.Image image) {
|
||||||
|
// Defer to after the current frame so the raster thread is done with it.
|
||||||
|
// If no binding/frame is scheduled (e.g. a unit test that never pumps),
|
||||||
|
// fall back to disposing on the next microtask so images aren't leaked.
|
||||||
|
final binding = WidgetsBinding.instance;
|
||||||
|
binding.addPostFrameCallback((_) => image.dispose());
|
||||||
|
binding.scheduleFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps a continuous render scale to a coarse DPI bucket so a smooth pinch
|
||||||
|
/// re-uses tiles instead of spawning one per frame. [step] is the bucket
|
||||||
|
/// granularity in the same units as [scale] (e.g. 0.5). The result is capped at
|
||||||
|
/// [maxBucket] to bound retained-tile memory (the plan's ~3× cap, R11).
|
||||||
|
int dpiBucketFor(double scale, {double step = 0.5, int maxBucket = 6}) {
|
||||||
|
if (!scale.isFinite || scale <= 0) return 1;
|
||||||
|
final bucket = (scale / step).ceil();
|
||||||
|
if (bucket < 1) return 1;
|
||||||
|
return bucket > maxBucket ? maxBucket : bucket;
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
lib/editor/pdf/pdf_document_source.dart
Normal file
45
lib/editor/pdf/pdf_document_source.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// lib/editor/pdf/pdf_document_source.dart
|
||||||
|
//
|
||||||
|
// A minimal abstraction over a paginated document (a pdfrx PdfDocument in
|
||||||
|
// production) consumed by the layout + render layer. Keeping the layout math
|
||||||
|
// behind this seam lets continuous-single windowing (layout/page_viewport.dart)
|
||||||
|
// and tiling be unit-tested against a fake source — no pdfium, no GPU, no real
|
||||||
|
// PDF (the production pdfrx adapter is a thin device-side wrapper added with the
|
||||||
|
// viewport widget / page_tile renderer, which are device-gated).
|
||||||
|
|
||||||
|
import 'dart:ui' show Size;
|
||||||
|
|
||||||
|
import '../layout/page_viewport.dart';
|
||||||
|
|
||||||
|
/// Read-only page geometry for a paginated document.
|
||||||
|
abstract class PageDocumentSource {
|
||||||
|
/// Number of pages (>= 0).
|
||||||
|
int get pageCount;
|
||||||
|
|
||||||
|
/// Intrinsic size of page [index] in PDF points (width/height > 0).
|
||||||
|
Size pageSize(int index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds continuous-single stacking metrics by fitting every page to a single
|
||||||
|
/// [columnWidth] (fit-to-width, the continuous-single mode): each page's
|
||||||
|
/// laid-out height is `columnWidth × (pageHeight / pageWidth)`, preserving its
|
||||||
|
/// aspect ratio. [gap] is inserted between pages (content units).
|
||||||
|
///
|
||||||
|
/// Pages reporting a non-positive width are treated as zero-height (defensive;
|
||||||
|
/// real pages always have a positive width) so a malformed page can't throw.
|
||||||
|
///
|
||||||
|
/// Returns the metrics needed by [PageStackMetrics.visibleRange]; pair this with
|
||||||
|
/// the device-gated page-mounting widget.
|
||||||
|
PageStackMetrics pageStackMetricsForWidth(
|
||||||
|
PageDocumentSource source,
|
||||||
|
double columnWidth, {
|
||||||
|
double gap = 0.0,
|
||||||
|
}) {
|
||||||
|
assert(columnWidth >= 0);
|
||||||
|
final heights = List<double>.generate(source.pageCount, (i) {
|
||||||
|
final size = source.pageSize(i);
|
||||||
|
if (size.width <= 0) return 0.0;
|
||||||
|
return columnWidth * (size.height / size.width);
|
||||||
|
});
|
||||||
|
return PageStackMetrics(pageHeights: heights, gap: gap);
|
||||||
|
}
|
||||||
42
lib/editor/pdf/pdfrx_page_document_source.dart
Normal file
42
lib/editor/pdf/pdfrx_page_document_source.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// lib/editor/pdf/pdfrx_page_document_source.dart
|
||||||
|
//
|
||||||
|
// Production [PageDocumentSource] backed by a pdfrx PdfDocument — the thin
|
||||||
|
// device-side adapter that feeds real page geometry into the (pure, tested)
|
||||||
|
// continuous-single layout math.
|
||||||
|
//
|
||||||
|
// SOURCE-PIN (plan SF4 / P0.5 step 12): this file references the exact pdfrx
|
||||||
|
// 2.4.4 page-geometry API (`PdfDocument.pages`, `PdfPage.width`, `PdfPage.height`).
|
||||||
|
// Because it lives under lib/editor/, the Oracle's `flutter analyze lib/editor`
|
||||||
|
// type-checks it against the installed pdfrx on every run — so a version bump
|
||||||
|
// that renames/retypes these members FAILS analysis instead of silently
|
||||||
|
// drifting. (Static pin only: exercising it needs pdfium at runtime, which is
|
||||||
|
// the device-gated path.)
|
||||||
|
|
||||||
|
import 'dart:ui' show Size;
|
||||||
|
|
||||||
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
|
import 'pdf_document_source.dart';
|
||||||
|
|
||||||
|
/// Snapshots page sizes (PDF points) from an open pdfrx [PdfDocument] so the
|
||||||
|
/// layout layer can read them synchronously (pdfrx loads pages asynchronously;
|
||||||
|
/// once open, `document.pages[i].width/height` are available).
|
||||||
|
class PdfrxPageDocumentSource implements PageDocumentSource {
|
||||||
|
const PdfrxPageDocumentSource.fromSizes(this._sizes);
|
||||||
|
|
||||||
|
/// Snapshot the intrinsic size of every page from an open [document].
|
||||||
|
factory PdfrxPageDocumentSource.fromDocument(PdfDocument document) {
|
||||||
|
final sizes = document.pages
|
||||||
|
.map((page) => Size(page.width, page.height))
|
||||||
|
.toList(growable: false);
|
||||||
|
return PdfrxPageDocumentSource.fromSizes(sizes);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<Size> _sizes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get pageCount => _sizes.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size pageSize(int index) => _sizes[index];
|
||||||
|
}
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
80
lib/editor/search/search_ranking.dart
Normal file
80
lib/editor/search/search_ranking.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// lib/editor/search/search_ranking.dart
|
||||||
|
//
|
||||||
|
// Pure ranking + aggregation for full-text search (F8). Given many sources (PDF
|
||||||
|
// text pages, typed boxes, OCR'd handwriting) keyed by a ref string, score each
|
||||||
|
// for a query and return the best hits with display snippets. This is the
|
||||||
|
// search_indexer's ranking core; the FTS pre-filter/index lives in the DB.
|
||||||
|
//
|
||||||
|
// Pure (no storage/widgets); builds on search_text (normalize/match) +
|
||||||
|
// search_snippet (excerpt). Fully unit-tested.
|
||||||
|
|
||||||
|
import 'search_snippet.dart';
|
||||||
|
import 'search_text.dart';
|
||||||
|
|
||||||
|
/// A ranked search result: WHERE it is ([ref]), the display [snippet], and the
|
||||||
|
/// [score] (higher = better).
|
||||||
|
class SearchHit {
|
||||||
|
const SearchHit({required this.ref, required this.snippet, required this.score});
|
||||||
|
|
||||||
|
final String ref;
|
||||||
|
final Snippet snippet;
|
||||||
|
final double score;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'SearchHit($ref, score=${score.toStringAsFixed(3)})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score [source] for [query]: more (normalized) occurrences rank higher, and an
|
||||||
|
/// earlier first match breaks ties. 0 when there is no match.
|
||||||
|
double scoreText(String source, String query) {
|
||||||
|
final q = normalizeForIndex(query);
|
||||||
|
if (q.isEmpty) return 0;
|
||||||
|
final s = normalizeForIndex(source);
|
||||||
|
if (s.isEmpty) return 0;
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
var from = 0;
|
||||||
|
var firstPos = -1;
|
||||||
|
while (true) {
|
||||||
|
final idx = s.indexOf(q, from);
|
||||||
|
if (idx < 0) break;
|
||||||
|
if (firstPos < 0) firstPos = idx;
|
||||||
|
count++;
|
||||||
|
from = idx + q.length;
|
||||||
|
}
|
||||||
|
if (count == 0) return 0;
|
||||||
|
|
||||||
|
// Earliness in (0,1]: a match at position 0 scores 1.0.
|
||||||
|
final earliness = 1.0 - (firstPos / s.length);
|
||||||
|
return count + earliness;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build + rank hits across [sources] (ref → raw text) for [query]: drop
|
||||||
|
/// non-matches, attach a display snippet of the ORIGINAL text, sort best-first.
|
||||||
|
/// Ties (equal score) keep input order (stable).
|
||||||
|
List<SearchHit> rankHits(
|
||||||
|
Map<String, String> sources,
|
||||||
|
String query, {
|
||||||
|
int window = 80,
|
||||||
|
}) {
|
||||||
|
final indexed = <({int order, SearchHit hit})>[];
|
||||||
|
var order = 0;
|
||||||
|
for (final entry in sources.entries) {
|
||||||
|
final i = order++;
|
||||||
|
final score = scoreText(entry.value, query);
|
||||||
|
if (score <= 0) continue;
|
||||||
|
final snippet = snippetFor(entry.value, query, window: window);
|
||||||
|
if (snippet == null) continue; // matched normalized but not raw (rare)
|
||||||
|
indexed.add((
|
||||||
|
order: i,
|
||||||
|
hit: SearchHit(ref: entry.key, snippet: snippet, score: score),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Descending score; ties keep input order (Dart's sort isn't stable, so the
|
||||||
|
// input index is an explicit tiebreaker).
|
||||||
|
indexed.sort((a, b) {
|
||||||
|
final byScore = b.hit.score.compareTo(a.hit.score);
|
||||||
|
return byScore != 0 ? byScore : a.order.compareTo(b.order);
|
||||||
|
});
|
||||||
|
return [for (final e in indexed) e.hit];
|
||||||
|
}
|
||||||
73
lib/editor/search/search_snippet.dart
Normal file
73
lib/editor/search/search_snippet.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// lib/editor/search/search_snippet.dart
|
||||||
|
//
|
||||||
|
// Pure snippet extraction for library-wide full-text search (F8 — the user's #1
|
||||||
|
// named differentiator). Given a source string (a PDF text page, a typed text
|
||||||
|
// box, or OCR'd handwriting) and a query, produce a windowed excerpt centered on
|
||||||
|
// the first match with the match offset preserved, so the results list can show
|
||||||
|
// "…context **match** context…" and jump to the hit.
|
||||||
|
//
|
||||||
|
// The FTS index / ranking lives in the DB (search_indexer); THIS is the pure,
|
||||||
|
// storage-free excerpt math, fully unit-tested.
|
||||||
|
|
||||||
|
/// A windowed excerpt around a query match.
|
||||||
|
class Snippet {
|
||||||
|
const Snippet({
|
||||||
|
required this.text,
|
||||||
|
required this.matchStart,
|
||||||
|
required this.matchLength,
|
||||||
|
required this.truncatedStart,
|
||||||
|
required this.truncatedEnd,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The excerpt (a substring of the source).
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
/// Offset of the match WITHIN [text].
|
||||||
|
final int matchStart;
|
||||||
|
|
||||||
|
/// Length of the matched run.
|
||||||
|
final int matchLength;
|
||||||
|
|
||||||
|
/// True when [text] begins before the source start was reached (show a
|
||||||
|
/// leading ellipsis).
|
||||||
|
final bool truncatedStart;
|
||||||
|
|
||||||
|
/// True when [text] ends before the source end (show a trailing ellipsis).
|
||||||
|
final bool truncatedEnd;
|
||||||
|
|
||||||
|
/// Convenience: the matched substring.
|
||||||
|
String get match => text.substring(matchStart, matchStart + matchLength);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'${truncatedStart ? '…' : ''}$text${truncatedEnd ? '…' : ''}'
|
||||||
|
' [match @$matchStart+$matchLength]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First case-insensitive match of [query] in [source], as a snippet of up to
|
||||||
|
/// roughly [window] characters centered on the match. Returns null when [query]
|
||||||
|
/// is empty or absent. The full match is always included even if longer than
|
||||||
|
/// [window].
|
||||||
|
Snippet? snippetFor(String source, String query, {int window = 80}) {
|
||||||
|
if (query.isEmpty || source.isEmpty) return null;
|
||||||
|
assert(window >= 0);
|
||||||
|
|
||||||
|
final matchIndex = source.toLowerCase().indexOf(query.toLowerCase());
|
||||||
|
if (matchIndex < 0) return null;
|
||||||
|
|
||||||
|
final matchLen = query.length;
|
||||||
|
final contextEach = ((window - matchLen) ~/ 2).clamp(0, window);
|
||||||
|
|
||||||
|
var start = matchIndex - contextEach;
|
||||||
|
if (start < 0) start = 0;
|
||||||
|
var end = matchIndex + matchLen + contextEach;
|
||||||
|
if (end > source.length) end = source.length;
|
||||||
|
|
||||||
|
return Snippet(
|
||||||
|
text: source.substring(start, end),
|
||||||
|
matchStart: matchIndex - start,
|
||||||
|
matchLength: matchLen,
|
||||||
|
truncatedStart: start > 0,
|
||||||
|
truncatedEnd: end < source.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
30
lib/editor/search/search_text.dart
Normal file
30
lib/editor/search/search_text.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// lib/editor/search/search_text.dart
|
||||||
|
//
|
||||||
|
// Pure text normalization + matching for full-text search (F8). PDF text layers
|
||||||
|
// and OCR output are full of hard line breaks and irregular whitespace, so a
|
||||||
|
// query like "hello world" won't substring-match raw extracted text that reads
|
||||||
|
// "hello\nworld". Normalizing both sides (lowercase + collapse every whitespace
|
||||||
|
// run to a single space + trim) fixes that.
|
||||||
|
//
|
||||||
|
// CJK NOTE: this user writes Chinese. We deliberately do NOT word-tokenize —
|
||||||
|
// Chinese has no inter-word spaces, so a whitespace/punctuation tokenizer would
|
||||||
|
// mangle it. Substring matching over normalized text is correct for both Latin
|
||||||
|
// and CJK; word/段 segmentation belongs in the DB FTS tokenizer (trigram /
|
||||||
|
// unicode61), not here.
|
||||||
|
|
||||||
|
/// Matches any run of Unicode whitespace (spaces, tabs, newlines, NBSP, …).
|
||||||
|
final RegExp _whitespaceRun = RegExp(r'\s+');
|
||||||
|
|
||||||
|
/// Normalize [text] for indexing/matching: lowercase, collapse whitespace runs
|
||||||
|
/// (incl. the hard newlines PDF/OCR insert mid-sentence) to single spaces, trim.
|
||||||
|
String normalizeForIndex(String text) {
|
||||||
|
return text.toLowerCase().replaceAll(_whitespaceRun, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [source] contains [query] after both are normalized — so a match can
|
||||||
|
/// span the line breaks present in the raw text. Empty query never matches.
|
||||||
|
bool matchesNormalized(String source, String query) {
|
||||||
|
final q = normalizeForIndex(query);
|
||||||
|
if (q.isEmpty) return false;
|
||||||
|
return normalizeForIndex(source).contains(q);
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
254
lib/l10n/app_en.arb
Normal file
254
lib/l10n/app_en.arb
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "en",
|
||||||
|
"appTitle": "BadNote",
|
||||||
|
"settings": "Settings",
|
||||||
|
"search": "Search",
|
||||||
|
"importPdf": "Import PDF",
|
||||||
|
"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)",
|
||||||
|
"newNote": "New Note",
|
||||||
|
"open": "Open",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteNoteTitle": "Delete note?",
|
||||||
|
"deleteNote": "Delete note",
|
||||||
|
"openInSplitView": "Open in Split View",
|
||||||
|
"splitViewSubtitle": "PDF reference + scratchpad",
|
||||||
|
"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...",
|
||||||
|
"processingPresentation": "Processing presentation...",
|
||||||
|
"couldNotOpenPresentation": "Could not open presentation.",
|
||||||
|
"toolPen": "Pen",
|
||||||
|
"toolHighlighter": "Highlighter",
|
||||||
|
"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",
|
||||||
|
"actionRedo": "Redo",
|
||||||
|
"fingerDrawingOn": "Finger drawing ON",
|
||||||
|
"fingerDrawingOff": "Finger drawing OFF (pen only)",
|
||||||
|
"pages": "Pages",
|
||||||
|
"penSettings": "Pen settings",
|
||||||
|
"inputDiagnostic": "Input diagnostic (writes a log file)",
|
||||||
|
"back": "Back",
|
||||||
|
"previousPage": "Previous 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": {
|
||||||
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
},
|
||||||
|
"pdfNoPages": "PDF has no pages.",
|
||||||
|
"pageOfPages": "{current} / {total}",
|
||||||
|
"@pageOfPages": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": { "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"
|
||||||
|
}
|
||||||
1250
lib/l10n/app_localizations.dart
Normal file
1250
lib/l10n/app_localizations.dart
Normal file
File diff suppressed because it is too large
Load Diff
623
lib/l10n/app_localizations_en.dart
Normal file
623
lib/l10n/app_localizations_en.dart
Normal file
@@ -0,0 +1,623 @@
|
|||||||
|
// ignore: unused_import
|
||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for English (`en`).
|
||||||
|
class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => 'Search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importPdf => 'Import PDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get penCanvasBeta => 'Pen Canvas (beta)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newNote => 'New Note';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get open => 'Open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancel => 'Cancel';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get delete => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteNoteTitle => 'Delete note?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteNote => 'Delete note';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openInSplitView => 'Open in Split View';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get splitViewSubtitle => 'PDF reference + scratchpad';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get processingPptx => 'Processing PPTX...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingPresentation => 'Processing presentation...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get couldNotOpenPresentation => 'Could not open presentation.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolPen => 'Pen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolHighlighter => 'Highlighter';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get actionUndo => 'Undo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionRedo => 'Redo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fingerDrawingOn => 'Finger drawing ON';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fingerDrawingOff => 'Finger drawing OFF (pen only)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pages => 'Pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get penSettings => 'Pen settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputDiagnostic => 'Input diagnostic (writes a log file)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get back => 'Back';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previousPage => 'Previous page';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String failedToOpenPdf(String error) {
|
||||||
|
return 'Failed to open PDF:\n$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pdfNoPages => 'PDF has no pages.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pageOfPages(int current, int 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';
|
||||||
|
}
|
||||||
615
lib/l10n/app_localizations_zh.dart
Normal file
615
lib/l10n/app_localizations_zh.dart
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
// ignore: unused_import
|
||||||
|
import 'package:intl/intl.dart' as intl;
|
||||||
|
import 'app_localizations.dart';
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
/// The translations for Chinese (`zh`).
|
||||||
|
class AppLocalizationsZh extends AppLocalizations {
|
||||||
|
AppLocalizationsZh([String locale = 'zh']) : super(locale);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appTitle => 'BadNote';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => '设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get search => '搜索';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importPdf => '导入 PDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get penCanvasBeta => '手写画布(测试版)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newNote => '新建笔记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get open => '打开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancel => '取消';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get delete => '删除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteNoteTitle => '删除笔记?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteNote => '删除笔记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openInSplitView => '分屏打开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get splitViewSubtitle => 'PDF 参考 + 草稿纸';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get processingPptx => '正在处理 PPTX…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get processingPresentation => '正在处理演示文稿…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get couldNotOpenPresentation => '无法打开演示文稿。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolPen => '钢笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolHighlighter => '荧光笔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String get actionUndo => '撤销';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionRedo => '重做';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fingerDrawingOn => '手指书写:开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fingerDrawingOff => '手指书写:关(仅手写笔)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pages => '页面';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get penSettings => '手写笔设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inputDiagnostic => '输入诊断(写入日志文件)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get back => '返回';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previousPage => '上一页';
|
||||||
|
|
||||||
|
@override
|
||||||
|
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
|
||||||
|
String failedToOpenPdf(String error) {
|
||||||
|
return '打开 PDF 失败:\n$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pdfNoPages => 'PDF 没有任何页面。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pageOfPages(int current, int 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 => '拖动移动';
|
||||||
|
}
|
||||||
224
lib/l10n/app_zh.arb
Normal file
224
lib/l10n/app_zh.arb
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "zh",
|
||||||
|
"appTitle": "BadNote",
|
||||||
|
"settings": "设置",
|
||||||
|
"search": "搜索",
|
||||||
|
"importPdf": "导入 PDF",
|
||||||
|
"importPpt": "导入 PPT",
|
||||||
|
"importFile": "导入文件",
|
||||||
|
"createNotebook": "新建笔记本",
|
||||||
|
"newNotebookTitle": "新建笔记本",
|
||||||
|
"notebookTitleHint": "笔记本标题",
|
||||||
|
"create": "创建",
|
||||||
|
"untitledNote": "未命名",
|
||||||
|
"noNotesYetHint": "还没有手写笔记——点按 + 新建",
|
||||||
|
"noDocumentsYet": "暂无文档——点按“导入文件”",
|
||||||
|
"processingImport": "正在导入…",
|
||||||
|
"importFailed": "无法导入该文件:{error}",
|
||||||
|
"convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF,或安装 LibreOffice。",
|
||||||
|
"unsupportedFileType": "不支持的文件类型:{ext}",
|
||||||
|
"penCanvasBeta": "手写画布(测试版)",
|
||||||
|
"newNote": "新建笔记",
|
||||||
|
"open": "打开",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleteNoteTitle": "删除笔记?",
|
||||||
|
"deleteNote": "删除笔记",
|
||||||
|
"openInSplitView": "分屏打开",
|
||||||
|
"splitViewSubtitle": "PDF 参考 + 草稿纸",
|
||||||
|
"removeDocument": "移除文档",
|
||||||
|
"ok": "确定",
|
||||||
|
"pickColor": "选择颜色",
|
||||||
|
"clearSettingsTitle": "清除所有本地设置?",
|
||||||
|
"clear": "清除",
|
||||||
|
"settingsReset": "设置已恢复默认",
|
||||||
|
"themeSystem": "跟随系统",
|
||||||
|
"themeLight": "浅色",
|
||||||
|
"themeDark": "深色",
|
||||||
|
"seedColorDesc": "Material 3 主题种子色",
|
||||||
|
"searchHint": "搜索笔记和文档…",
|
||||||
|
"searchError": "搜索出错:{error}",
|
||||||
|
"noResultsFor": "没有“{query}”的结果",
|
||||||
|
"typeToSearch": "输入以搜索你的笔记和文档",
|
||||||
|
"sectionNotes": "笔记",
|
||||||
|
"sectionDocuments": "文档",
|
||||||
|
"pageLabel": "第 {page} 页",
|
||||||
|
"processingPptx": "正在处理 PPTX…",
|
||||||
|
"processingPresentation": "正在处理演示文稿…",
|
||||||
|
"couldNotOpenPresentation": "无法打开演示文稿。",
|
||||||
|
"toolPen": "钢笔",
|
||||||
|
"toolHighlighter": "荧光笔",
|
||||||
|
"toolEraser": "橡皮擦",
|
||||||
|
"brushPicker": "笔刷",
|
||||||
|
"brushFountainPen": "钢笔",
|
||||||
|
"brushBallpoint": "圆珠笔",
|
||||||
|
"brushPencil": "铅笔",
|
||||||
|
"brushHighlighter": "荧光笔",
|
||||||
|
"toolSelect": "选择",
|
||||||
|
"toolShape": "形状",
|
||||||
|
"shapePicker": "形状",
|
||||||
|
"shapeLine": "直线",
|
||||||
|
"shapeRectangle": "矩形",
|
||||||
|
"shapeEllipse": "椭圆",
|
||||||
|
"shapeArrow": "箭头",
|
||||||
|
"actionDeleteSelection": "删除所选",
|
||||||
|
"actionUndo": "撤销",
|
||||||
|
"actionRedo": "重做",
|
||||||
|
"fingerDrawingOn": "手指书写:开",
|
||||||
|
"fingerDrawingOff": "手指书写:关(仅手写笔)",
|
||||||
|
"pages": "页面",
|
||||||
|
"penSettings": "手写笔设置",
|
||||||
|
"inputDiagnostic": "输入诊断(写入日志文件)",
|
||||||
|
"back": "返回",
|
||||||
|
"previousPage": "上一页",
|
||||||
|
"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}",
|
||||||
|
"pdfNoPages": "PDF 没有任何页面。",
|
||||||
|
"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": "拖动移动"
|
||||||
|
}
|
||||||
183
lib/main.dart
183
lib/main.dart
@@ -1,20 +1,26 @@
|
|||||||
import 'package:dynamic_color/dynamic_color.dart';
|
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_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 '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();
|
||||||
@@ -25,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);
|
||||||
@@ -52,19 +84,138 @@ 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),
|
||||||
home: const HomeScreen(),
|
// i18n: follows the OS language (en / zh) via the system locale.
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
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.
|
|
||||||
final docIds = <String>{
|
|
||||||
for (final hit in docHits)
|
|
||||||
if (hit['document_id'] is String) hit['document_id'] as String,
|
|
||||||
};
|
|
||||||
final docEntries = await Future.wait(
|
|
||||||
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
|
|
||||||
);
|
|
||||||
final docsById = <String, Document>{
|
|
||||||
for (final entry in docEntries)
|
|
||||||
if (entry.value != null) entry.key: entry.value!,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (final hit in docHits) {
|
|
||||||
final documentId = hit['document_id'];
|
|
||||||
if (documentId is! String) continue;
|
|
||||||
final doc = docsById[documentId];
|
|
||||||
if (doc == null) continue;
|
|
||||||
|
|
||||||
final pageNumber = hit['page_number'];
|
|
||||||
final content = hit['content'];
|
|
||||||
results.add(
|
results.add(
|
||||||
DocumentSearchHit(
|
NoteSearchHit(
|
||||||
documentId: documentId,
|
note: Note(
|
||||||
filename: doc.filename,
|
id: entry.openPath,
|
||||||
filePath: doc.filePath,
|
title: entry.title,
|
||||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
createdAt: now,
|
||||||
snippet: content is String ? content : '',
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
snippet: snippet,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
results.add(
|
||||||
|
DocumentSearchHit(
|
||||||
|
documentId: entry.id,
|
||||||
|
filename: entry.title,
|
||||||
|
filePath: entry.openPath,
|
||||||
|
// The scan-based index matches whole-notebook text, not per-page, so
|
||||||
|
// the document opens at its first page.
|
||||||
|
pageNumber: 0,
|
||||||
|
snippet: snippet,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,84 +1,88 @@
|
|||||||
|
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 '../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 '../editor/pdf/spike_launcher.dart';
|
import '../providers/search_provider.dart';
|
||||||
import '../services/pdf_service.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
|
import '../editor/canvas/office_document_screen.dart';
|
||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
import 'note_editor_screen.dart';
|
import '../services/vault_service.dart';
|
||||||
import 'pdf_annotator_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import 'ppt_annotator_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) {
|
||||||
final notesAsync = ref.watch(noteListProvider);
|
final notesAsync = ref.watch(noteListProvider);
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('BadNote'),
|
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
|
||||||
centerTitle: true,
|
centerTitle: !embeddedInShell,
|
||||||
actions: [
|
actions: [
|
||||||
|
if (!embeddedInShell) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
tooltip: 'Settings',
|
tooltip: l.settings,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(
|
Navigator.of(context).push(
|
||||||
context,
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
tooltip: 'Import PDF',
|
|
||||||
onPressed: () => _importPdf(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
tooltip: 'Import PPT',
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
tooltip: 'Search',
|
tooltip: l.search,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(
|
Navigator.of(context).push(
|
||||||
context,
|
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
||||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// New pen-first canvas editor (beta).
|
],
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.draw_outlined),
|
icon: const Icon(Icons.file_open),
|
||||||
tooltip: 'Pen Canvas (beta)',
|
tooltip: l.importFile,
|
||||||
onPressed: () => openM1Spike(context),
|
onPressed: () => _importFile(context, ref),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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()),
|
||||||
@@ -86,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(
|
||||||
@@ -95,16 +101,37 @@ 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(
|
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.notebooksSection,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
_ContainerTile(container: containers[index]),
|
||||||
|
childCount: containers.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
l.notesSection,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -128,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(
|
||||||
@@ -139,12 +166,11 @@ 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,
|
||||||
),
|
),
|
||||||
@@ -152,6 +178,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (documents.isNotEmpty)
|
if (documents.isNotEmpty)
|
||||||
|
// continue existing document list below — marker for patch
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) =>
|
(context, index) =>
|
||||||
@@ -160,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(
|
||||||
@@ -169,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(
|
||||||
@@ -189,45 +215,171 @@ 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 (title == null) return; // cancelled
|
||||||
|
final l = context.mounted ? AppLocalizations.of(context) : null;
|
||||||
|
final resolved = title.trim().isEmpty
|
||||||
|
? (l?.untitledNote ?? 'Untitled')
|
||||||
|
: title.trim();
|
||||||
|
final container = await ref
|
||||||
|
.read(notebookContainerListProvider.notifier)
|
||||||
|
.createContainer(resolved);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _importPdf(BuildContext context) async {
|
|
||||||
final pdfService = PdfService();
|
|
||||||
final filePath = await pdfService.pickPdfFile();
|
|
||||||
if (filePath != null && context.mounted) {
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
|
builder: (_) => NotebookScreen(
|
||||||
|
folderPath: container.folderPath,
|
||||||
|
title: container.title,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _importPptx(BuildContext context) async {
|
/// Ask for a notebook title. Returns the entered string (possibly empty →
|
||||||
final pptxService = PptxService();
|
/// caller defaults it), or null if the user cancelled.
|
||||||
final filePath = await pptxService.openPptxFile();
|
Future<String?> _promptNotebookTitle(BuildContext context) {
|
||||||
if (filePath == null || !context.mounted) return;
|
final l = AppLocalizations.of(context);
|
||||||
|
final controller = TextEditingController(text: l.untitledNote);
|
||||||
if (context.mounted) {
|
return showDialog<String>(
|
||||||
ScaffoldMessenger.of(
|
context: context,
|
||||||
context,
|
builder: (ctx) => AlertDialog(
|
||||||
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final slideImages = await pptxService.convertToImages(filePath);
|
/// Single top-level "Import file" action (sibling of "Create notebook"):
|
||||||
final extractedText = await pptxService.extractText(filePath);
|
/// 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;
|
||||||
|
|
||||||
if (context.mounted) {
|
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(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => PptAnnotatorScreen(
|
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,
|
filePath: filePath,
|
||||||
slideImagePaths: slideImages,
|
slideImagePaths: slideImages,
|
||||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
@@ -235,7 +387,6 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -249,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,
|
||||||
),
|
),
|
||||||
@@ -265,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: const Text('New Note'),
|
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: const Text('Import PDF'),
|
label: Text(AppLocalizations.of(context).importFile),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _importPptx(context),
|
|
||||||
icon: const Icon(Icons.slideshow),
|
|
||||||
label: const Text('Import PPT'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -285,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});
|
||||||
@@ -300,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;
|
||||||
@@ -332,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)
|
||||||
@@ -373,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),
|
||||||
@@ -423,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);
|
||||||
@@ -471,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;
|
||||||
@@ -497,22 +683,10 @@ 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)
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.vertical_split,
|
|
||||||
color: _hovering
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: subtleColor.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
tooltip: 'Open in Split View',
|
|
||||||
onPressed: () => _openSplitView(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
color: _hovering
|
color: _hovering
|
||||||
@@ -522,8 +696,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
tooltip: 'Remove document',
|
tooltip: 'Remove document',
|
||||||
onPressed: () => _confirmDelete(context),
|
onPressed: () => _confirmDelete(context),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
// [L2] Routing bug fix: route by docType
|
// [L2] Routing bug fix: route by docType
|
||||||
onTap: () => _openDocument(context),
|
onTap: () => _openDocument(context),
|
||||||
onLongPress: () => _showDocumentMenu(context),
|
onLongPress: () => _showDocumentMenu(context),
|
||||||
@@ -532,22 +704,34 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [L2] Route by docType: pdf → PdfAnnotatorScreen, 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: (_) => PdfAnnotatorScreen(filePath: 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();
|
||||||
@@ -556,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,
|
||||||
@@ -572,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(
|
||||||
@@ -604,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(
|
||||||
@@ -636,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) {
|
||||||
@@ -652,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,31 +106,53 @@ 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();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_repo = repo;
|
||||||
|
final pad = repo.scratchpadFor(widget.scratchLinkId);
|
||||||
setState(() {
|
setState(() {
|
||||||
_strokes = strokes;
|
if (pad != null) {
|
||||||
for (final s in strokes) {
|
// 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);
|
_undoManager.addStroke(s);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleSave() {
|
void _scheduleSave() {
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
@@ -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)
|
||||||
|
inkStrokeFromPen(r, _worldSize,
|
||||||
|
id: _uuid.v4(), createdAt: DateTime.now()),
|
||||||
|
];
|
||||||
|
_undoManager.removeStroke(original, replacements: inkReplacements);
|
||||||
_strokes = List.from(_undoManager.currentStrokes);
|
_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(
|
||||||
@@ -306,42 +347,17 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 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.
|
||||||
|
Widget _buildBrushPalette(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: [
|
children: [
|
||||||
GestureDetector(
|
BrushPickerButton(
|
||||||
onLongPress: () {
|
selected: _penBrush,
|
||||||
// Long-press on PDF to create page link marker
|
active: _tool == CanvasTool.pen,
|
||||||
_onPdfLongPress(_currentPage + 1);
|
tooltip: 'Brush',
|
||||||
},
|
labelFor: brushLabelEn,
|
||||||
child: SfPdfViewer.file(
|
// The scratchpad keeps a single shared color (no per-brush color
|
||||||
File(widget.filePath),
|
// memory in this surface); show it for every brush.
|
||||||
controller: _pdfController,
|
colorFor: (_) => _color,
|
||||||
canShowScrollHead: true,
|
onSelected: (b) => setState(() {
|
||||||
canShowScrollStatus: true,
|
_penBrush = b;
|
||||||
onPageChanged: (PdfPageChangedDetails details) {
|
_tool = CanvasTool.pen;
|
||||||
setState(() {
|
}),
|
||||||
_currentPage = details.newPageNumber - 1;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
|
||||||
setState(() {
|
|
||||||
_pageCount = details.document.pages.count;
|
|
||||||
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.brush_outlined,
|
||||||
|
selected: _tool == CanvasTool.highlighter,
|
||||||
|
tooltip: 'Highlighter',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||||
),
|
),
|
||||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
ToolButton(
|
||||||
if (_pageLinks.isNotEmpty)
|
icon: Icons.cleaning_services_outlined,
|
||||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
selected: _tool == CanvasTool.eraser,
|
||||||
|
tooltip: 'Eraser',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.undo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Undo',
|
||||||
|
onPressed: _undoManager.canUndo ? _undo : null,
|
||||||
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.redo,
|
||||||
|
selected: false,
|
||||||
|
tooltip: 'Redo',
|
||||||
|
onPressed: _undoManager.canRedo ? _redo : null,
|
||||||
|
),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPageLinkChips() {
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
return Wrap(
|
final selected =
|
||||||
spacing: 4,
|
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||||
runSpacing: 4,
|
|
||||||
children: _pageLinks.map((link) {
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onPageLinkTap(link),
|
onTap: () => setState(() {
|
||||||
onLongPress: () => _deletePageLink(link),
|
_color = c;
|
||||||
child: Chip(
|
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
}),
|
||||||
label: Text(
|
child: AnimatedContainer(
|
||||||
'p${link.pageNumber}',
|
duration: const Duration(milliseconds: 150),
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.blue.shade600,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}
|
||||||
|
|
||||||
|
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() {
|
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(
|
return Container(
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
child: InteractiveViewer(
|
child: PenCanvas(
|
||||||
constrained: false,
|
pageSize: _worldSize,
|
||||||
minScale: 0.25,
|
strokes: penStrokesFromInk(_strokes, _worldSize),
|
||||||
|
transformationController: _scratchTransform,
|
||||||
|
tool: _activeTool,
|
||||||
|
brush: _penBrush,
|
||||||
|
color: _color,
|
||||||
|
strokeWidth: _strokeWidth,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
|
minScale: 0.1,
|
||||||
maxScale: 8.0,
|
maxScale: 8.0,
|
||||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
|
||||||
child: SizedBox(
|
|
||||||
width: _canvasWidth,
|
|
||||||
height: _canvasHeight,
|
|
||||||
child: InkCanvas(
|
|
||||||
strokes: _strokes,
|
|
||||||
onStrokeComplete: _onStrokeComplete,
|
onStrokeComplete: _onStrokeComplete,
|
||||||
onErase: _onErase,
|
onEraseStroke: _onErase,
|
||||||
tool: _currentTool,
|
pageWidget: const ColoredBox(color: Colors.white),
|
||||||
color: _currentColor,
|
|
||||||
strokeWidth: _currentStrokeWidth,
|
|
||||||
pressureCurve: PressureCurve(type: _pressureCurveType),
|
|
||||||
stabilizationLevel: _stabilizationLevel,
|
|
||||||
filled: _filled,
|
|
||||||
interactionMode: InteractionMode.draw,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A marker linking a scratchpad position to a specific PDF page.
|
|
||||||
class _PageLink {
|
|
||||||
final int pageNumber;
|
|
||||||
final Offset position;
|
|
||||||
|
|
||||||
const _PageLink({required this.pageNumber, required this.position});
|
|
||||||
}
|
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user