Compare commits
47 Commits
22f27641ca
...
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 |
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
|
||||
http_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
|
||||
shell: powershell
|
||||
|
||||
15
README.md
15
README.md
@@ -1,17 +1,20 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo)
|
||||
- PDF and PPT import with page-level annotation
|
||||
- Unified shell: Library · Sticky board · Search · Settings
|
||||
- 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
|
||||
- **Local OCR** — pluggable, fully on-device. An embedded ONNX recognition
|
||||
backend (cross-platform, CPU/iGPU) with a graceful fallback to the platform's
|
||||
built-in OCR (Windows). See [Local OCR](#local-ocr).
|
||||
- Always-on diagnostics + one-click diagnostic pack export (Settings)
|
||||
- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
|
||||
- **Local OCR** — ONNX when bundled, else Windows WinRT
|
||||
- WebDAV vault sync (NAS)
|
||||
|
||||
## 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
|
||||
//
|
||||
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
|
||||
// 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.
|
||||
// Coordinate gate previously used spike_editor_pane (retired).
|
||||
// Re-run on Surface via docs/plans/surface-diagnostic-checklist.md
|
||||
// once a PenEditorScreen-based harness is restored.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_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() {
|
||||
// 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();
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
late File pdfFile;
|
||||
|
||||
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;
|
||||
testWidgets('coordinate_assertion retired with spike pane', (tester) async {
|
||||
}, skip: true);
|
||||
}
|
||||
|
||||
@@ -1,245 +1,16 @@
|
||||
// integration_test/perf_scroll_bench.dart
|
||||
//
|
||||
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
|
||||
//
|
||||
// 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).
|
||||
// Spike-based bench retired with own-canvas architecture.
|
||||
// See docs/plans/surface-diagnostic-checklist.md for device gates.
|
||||
// Replacement bench will target PenEditorScreen + PageTileCache.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_test/flutter_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() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
// Report raw frame timings to the device lab / driver too.
|
||||
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
|
||||
pdfrxFlutterInitialize();
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
|
||||
final pdf = File(_kPdfPath);
|
||||
if (!pdf.existsSync()) {
|
||||
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 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);
|
||||
});
|
||||
testWidgets('perf_scroll_bench retired — use Surface diagnostic checklist',
|
||||
(tester) async {
|
||||
// ignore: avoid_print
|
||||
print('SKIP: spike_editor_pane deleted; run Surface checklist instead.');
|
||||
}, skip: 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();
|
||||
}
|
||||
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:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/stroke_geometry.dart'
|
||||
show kDefaultPenThinning, kPenSmoothing, kPenStreamline;
|
||||
show freehandOutlinePoints, kDefaultPenThinning;
|
||||
import 'pen_stroke.dart';
|
||||
|
||||
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
|
||||
@@ -39,22 +40,19 @@ Path buildStrokePath(
|
||||
)
|
||||
.toList();
|
||||
|
||||
final outline = pf.getStroke(
|
||||
pfPoints,
|
||||
options: pf.StrokeOptions(
|
||||
size: pixelWidth,
|
||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||
// configurable [thinning] so real Surface-Pen pressure changes width.
|
||||
thinning: isHighlighter ? 0.0 : thinning,
|
||||
smoothing: kPenSmoothing,
|
||||
streamline: kPenStreamline,
|
||||
// Real stylus pressure → don't simulate; no pressure → let freehand fake
|
||||
// it based on velocity. (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,
|
||||
),
|
||||
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
|
||||
// this PDF-overlay path and the note/slide path can never diverge (R7), and
|
||||
// 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,
|
||||
isHighlighter: isHighlighter,
|
||||
hasRealPressure: hasRealPressure,
|
||||
isComplete: isComplete,
|
||||
thinning: thinning,
|
||||
brush: brushProfileFor(stroke.brush),
|
||||
);
|
||||
|
||||
final path = Path();
|
||||
@@ -67,6 +65,34 @@ Path buildStrokePath(
|
||||
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
|
||||
/// list identity or page size changes (kept behind a RepaintBoundary).
|
||||
class StaticInkPainter extends CustomPainter {
|
||||
@@ -88,13 +114,9 @@ class StaticInkPainter extends CustomPainter {
|
||||
final path =
|
||||
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
|
||||
if (path.getBounds().isEmpty) continue;
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Color(stroke.color)
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true,
|
||||
);
|
||||
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
|
||||
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
|
||||
canvas.drawPath(path, paintForStroke(stroke));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +223,52 @@ class EraserPreviewPainter extends CustomPainter {
|
||||
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
|
||||
/// RepaintBoundary so committed strokes don't repaint on every move.
|
||||
class LiveInkPainter extends CustomPainter {
|
||||
@@ -224,13 +292,7 @@ class LiveInkPainter extends CustomPainter {
|
||||
final path =
|
||||
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
|
||||
if (path.getBounds().isEmpty) return;
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Color(s.color)
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true,
|
||||
);
|
||||
canvas.drawPath(path, paintForStroke(s));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../diagnostics/frame_sampler.dart';
|
||||
import '../input/diagnostic_logger.dart';
|
||||
|
||||
class InputDiagnostics extends ChangeNotifier {
|
||||
@@ -15,7 +16,8 @@ class InputDiagnostics extends ChangeNotifier {
|
||||
static final InputDiagnostics instance = InputDiagnostics._();
|
||||
|
||||
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 rebaselines = 0; // pointer-count re-baselines
|
||||
int pointerCountMax = 0;
|
||||
@@ -41,13 +43,16 @@ class InputDiagnostics extends ChangeNotifier {
|
||||
required double focalJumpPx,
|
||||
required bool scaleDrop,
|
||||
required bool focalDrop,
|
||||
bool softClamped = false,
|
||||
double? liveScale,
|
||||
}) {
|
||||
frames++;
|
||||
if (scaleDrop) scaleDropped++;
|
||||
if (softClamped) this.softClamped++;
|
||||
if (focalDrop) focalDropped++;
|
||||
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
|
||||
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
|
||||
final double resulting = currentScale * appliedChange;
|
||||
final double resulting = currentScale;
|
||||
if (resulting < scaleMin) scaleMin = resulting;
|
||||
if (resulting > scaleMax) scaleMax = resulting;
|
||||
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
|
||||
@@ -55,19 +60,31 @@ class InputDiagnostics extends ChangeNotifier {
|
||||
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
|
||||
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
|
||||
|
||||
final liveBit = liveScale == null
|
||||
? ''
|
||||
: ' live=${liveScale.toStringAsFixed(3)}';
|
||||
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
|
||||
'ch=${appliedChange.toStringAsFixed(3)} '
|
||||
'cur=${currentScale.toStringAsFixed(3)} '
|
||||
'cur=${currentScale.toStringAsFixed(3)}$liveBit '
|
||||
'fj=${focalJumpPx.toStringAsFixed(0)}'
|
||||
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
|
||||
'${softClamped ? " SCLAMP" : ""}'
|
||||
'${scaleDrop ? " SDROP" : ""}'
|
||||
'${focalDrop ? " FDROP" : ""}';
|
||||
_trace.add(line);
|
||||
if (_trace.length > 24) _trace.removeAt(0);
|
||||
FrameSampler.instance.recordZoom(
|
||||
rawScale: rawScale,
|
||||
scaleDrop: scaleDrop || softClamped,
|
||||
focalDrop: focalDrop,
|
||||
focalJumpPx: focalJumpPx,
|
||||
);
|
||||
DiagnosticLogger.instance.log('ZOOM $line');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0;
|
||||
frames = scaleDropped = softClamped = focalDropped = rebaselines =
|
||||
pointerCountMax = 0;
|
||||
rawScaleMin = scaleMin = double.infinity;
|
||||
rawScaleMax = scaleMax = 0;
|
||||
maxFocalJumpPx = 0;
|
||||
@@ -76,15 +93,15 @@ class InputDiagnostics extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-';
|
||||
|
||||
String summary() {
|
||||
if (frames == 0) return 'zoom: (pinch to record)';
|
||||
return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped '
|
||||
'rebase=$rebaselines pMax=$pointerCountMax\n'
|
||||
' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} '
|
||||
'scale=${_f(scaleMin)}..${_f(scaleMax)}\n'
|
||||
final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
|
||||
final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
|
||||
final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
|
||||
final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
|
||||
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 '
|
||||
'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/material.dart';
|
||||
|
||||
import '../engine/brush.dart';
|
||||
import '../engine/pen_physics.dart';
|
||||
import '../engine/stroke_eraser.dart';
|
||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
import '../engine/stroke_model.dart';
|
||||
import '../engine/stroke_predictor.dart';
|
||||
import '../engine/stroke_store.dart';
|
||||
import '../input/input_arbiter.dart' as arbiter;
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pressure_curve.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/live_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_stroke.dart';
|
||||
|
||||
/// The active tool on the pen canvas.
|
||||
enum CanvasTool { pen, highlighter, eraser }
|
||||
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
|
||||
/// 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 {
|
||||
const PenCanvas({
|
||||
@@ -46,15 +73,26 @@ class PenCanvas extends StatefulWidget {
|
||||
required this.strokes,
|
||||
required this.transformationController,
|
||||
required this.tool,
|
||||
this.brush = BrushKind.fountainPen,
|
||||
this.shapeKind = ShapeKind.line,
|
||||
required this.color,
|
||||
required this.strokeWidth,
|
||||
required this.onStrokeComplete,
|
||||
required this.onEraseStroke,
|
||||
this.selectedStrokeIndex,
|
||||
this.onSelectStroke,
|
||||
this.onMoveStroke,
|
||||
this.allowFingerDrawing = false,
|
||||
this.minScale = 0.5,
|
||||
this.maxScale = 8.0,
|
||||
this.scaleEnabled = true,
|
||||
this.panEnabled = true,
|
||||
this.onPenDebug,
|
||||
this.thinning = kDefaultPenThinning,
|
||||
this.pressureGamma = kNaturalPressureGamma,
|
||||
this.pressureFloor = kNaturalPressureFloor,
|
||||
this.eraserRadius = kDefaultEraserRadius,
|
||||
this.eraserWholeStroke = false,
|
||||
this.sideButtonAction = PenButtonAction.eraser,
|
||||
this.eraserEndAction = PenButtonAction.eraser,
|
||||
this.onPenButtonAction,
|
||||
@@ -79,6 +117,17 @@ class PenCanvas extends StatefulWidget {
|
||||
final TransformationController transformationController;
|
||||
|
||||
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;
|
||||
|
||||
/// 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)
|
||||
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
|
||||
/// seen (palm rejection).
|
||||
final bool allowFingerDrawing;
|
||||
@@ -100,9 +165,40 @@ class PenCanvas extends StatefulWidget {
|
||||
final double minScale;
|
||||
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`.
|
||||
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
|
||||
/// the native pen plugin's flags on Windows).
|
||||
final PenButtonAction sideButtonAction;
|
||||
@@ -127,10 +223,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
/// In-progress stroke points (normalized).
|
||||
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.
|
||||
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
|
||||
/// inverted stylus), detected on hover/down.
|
||||
bool _eraserActive = false;
|
||||
@@ -168,17 +282,21 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
bool get _isEraserMode =>
|
||||
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.
|
||||
double get _pageAspect => widget.pageSize.width <= 0
|
||||
? 1.0
|
||||
: 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
|
||||
// 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
|
||||
@@ -187,10 +305,49 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
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
|
||||
/// 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) {
|
||||
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;
|
||||
if (range > 0.0001) {
|
||||
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
|
||||
@@ -257,7 +414,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
if (action == _lastHwAction) return;
|
||||
_lastHwAction = action;
|
||||
if (action == PenButtonAction.undo ||
|
||||
action == PenButtonAction.toggleTool) {
|
||||
action == PenButtonAction.toggleTool ||
|
||||
action == PenButtonAction.select) {
|
||||
widget.onPenButtonAction?.call(action);
|
||||
}
|
||||
}
|
||||
@@ -278,19 +436,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
||||
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
||||
/// the tests can never disagree on the rule.
|
||||
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
|
||||
activePointerCount: _activePointers.length,
|
||||
kind: kind,
|
||||
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||
hwPanActive: _hwPanActive,
|
||||
);
|
||||
bool _shouldDraw(PointerDeviceKind kind) {
|
||||
final draw = arbiter.shouldDraw(
|
||||
activePointerCount: _activePointers.length,
|
||||
kind: kind,
|
||||
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||
hwPanActive: _hwPanActive,
|
||||
);
|
||||
PenEventRing.instance.recordArbiter(
|
||||
activeCount: _activePointers.length,
|
||||
deviceKind: kind.name,
|
||||
draw: draw,
|
||||
fingerDrawing: _fingerDrawingEnabled,
|
||||
);
|
||||
return draw;
|
||||
}
|
||||
|
||||
// --- Coordinate mapping ---------------------------------------------------
|
||||
|
||||
/// Map a global pointer position into normalized page coords using the
|
||||
/// shared transform (inverse) and this widget's geometry.
|
||||
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
|
||||
{double? tilt}) {
|
||||
{double? tilt, Duration? timeStamp}) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null) return null;
|
||||
final local = box.globalToLocal(globalPosition);
|
||||
@@ -300,7 +467,23 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
|
||||
final nx = scene.dx / widget.pageSize.width;
|
||||
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 -----------------------------------------------------
|
||||
@@ -308,9 +491,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
void _startStroke(PointerDownEvent event) {
|
||||
_drawPointer = event.pointer;
|
||||
_livePoints.clear();
|
||||
_realPointCount = 0;
|
||||
_predictor.reset();
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_lastTipNorm = null;
|
||||
_lastTipTime = null;
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event));
|
||||
if (p != null) _livePoints.add(p);
|
||||
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
_eraserCursor.value = p;
|
||||
@@ -321,12 +510,33 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
if (_liveStroke != null) setState(() => _liveStroke = null);
|
||||
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();
|
||||
}
|
||||
|
||||
void _extendStroke(PointerMoveEvent event) {
|
||||
final p = _toNormalized(event.position, _normalizedPressure(event),
|
||||
tilt: _tiltFor(event));
|
||||
tilt: _tiltFor(event), timeStamp: event.timeStamp);
|
||||
if (p == null) return;
|
||||
|
||||
if (_eraserActive || widget.tool == CanvasTool.eraser) {
|
||||
@@ -334,29 +544,128 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
_eraseAt(p);
|
||||
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);
|
||||
_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();
|
||||
}
|
||||
|
||||
void _endStroke() {
|
||||
if (_drawPointer == null) return;
|
||||
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
|
||||
if (!wasEraser && _livePoints.isNotEmpty) {
|
||||
final tool = widget.tool;
|
||||
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(
|
||||
PenStroke(
|
||||
points: List.of(_livePoints),
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
),
|
||||
);
|
||||
}
|
||||
_drawPointer = null;
|
||||
_shapeStart = null;
|
||||
_selectLast = null;
|
||||
_selectDragging = false;
|
||||
_livePoints.clear();
|
||||
_realPointCount = 0;
|
||||
_predictor.reset();
|
||||
_eraserCursor.value = null; // hide the preview when the pen lifts
|
||||
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).
|
||||
void _cancelStroke() {
|
||||
_drawPointer = null;
|
||||
@@ -372,6 +681,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -391,13 +701,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// stays round on screen (the page rect is not square).
|
||||
void _eraseAt(PenPoint? p) {
|
||||
if (p == null) return;
|
||||
final radius = _eraserRadius; // normalized (page-width fraction)
|
||||
final radius = widget.eraserRadius; // normalized (page-width fraction)
|
||||
final aspect = _pageAspect;
|
||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||
final stroke = widget.strokes[i];
|
||||
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||
final pieces =
|
||||
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
|
||||
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
|
||||
// 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).
|
||||
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
|
||||
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
|
||||
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
||||
// 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
|
||||
// when the parent handed us a new list identity).
|
||||
@@ -519,7 +832,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
minScale: widget.minScale,
|
||||
maxScale: widget.maxScale,
|
||||
panEnabled: panEnabled,
|
||||
scaleEnabled: true,
|
||||
scaleEnabled: widget.scaleEnabled,
|
||||
child: SizedBox(
|
||||
width: widget.pageSize.width,
|
||||
height: widget.pageSize.height,
|
||||
@@ -559,14 +872,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
painter: EraserPreviewPainter(
|
||||
strokes: widget.strokes,
|
||||
cursor: _eraserCursor,
|
||||
radius: _eraserRadius,
|
||||
radius: widget.eraserRadius,
|
||||
aspect: _pageAspect,
|
||||
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(
|
||||
child: RepaintBoundary(
|
||||
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
|
||||
// rotation — so that code was provably a no-op here.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart' show clampDouble;
|
||||
@@ -32,6 +33,7 @@ import 'package:flutter/physics.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'input_diagnostics.dart';
|
||||
import 'pinch_scale_solver.dart';
|
||||
|
||||
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
|
||||
/// 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
|
||||
/// 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.
|
||||
const double _kScaleGlitchHi = 1.4;
|
||||
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
|
||||
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
|
||||
const double _kScaleGlitchHi = 1.18;
|
||||
|
||||
/// 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,
|
||||
/// and the frame is dropped (position-jump guard).
|
||||
const double _kFocalGlitchPx = 250.0;
|
||||
const double _kFocalGlitchPx = 64.0;
|
||||
|
||||
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.
|
||||
int _lastPointerCount = 0;
|
||||
|
||||
/// The recognizer's cumulative `details.scale` and the absolute scale we last
|
||||
/// APPLIED, both as of the previous accepted frame. The pinch is driven
|
||||
/// 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;
|
||||
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
|
||||
/// value; we never read the live matrix back into the per-frame scale change.
|
||||
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) -----------
|
||||
|
||||
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
|
||||
@@ -163,15 +176,38 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
_scaleAnimation?.removeListener(_handleScaleAnimation);
|
||||
_scaleAnimation = null;
|
||||
}
|
||||
_pendingScaleUpdate = null;
|
||||
_scaleFlushScheduled = false;
|
||||
_gestureType = null;
|
||||
_lastPointerCount = details.pointerCount;
|
||||
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
_lastRawScale = 1.0;
|
||||
_lastAppliedScale = _scaleStart!;
|
||||
_rawScaleAtBaseline = 1.0;
|
||||
}
|
||||
|
||||
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();
|
||||
_scaleAnimationFocalPoint = details.localFocalPoint;
|
||||
|
||||
@@ -180,10 +216,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
// The transitional frame itself is skipped.
|
||||
if (details.pointerCount != _lastPointerCount) {
|
||||
_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);
|
||||
_lastRawScale = details.scale;
|
||||
_lastAppliedScale = _scaleStart!;
|
||||
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
|
||||
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
|
||||
_rawScaleAtBaseline = details.scale;
|
||||
InputDiagnostics.instance.recordRebaseline();
|
||||
return;
|
||||
}
|
||||
@@ -204,14 +250,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
final bool focalDrop =
|
||||
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(
|
||||
rawScale: details.scale,
|
||||
pointerCount: details.pointerCount,
|
||||
currentScale: scale,
|
||||
currentScale: currentScale,
|
||||
appliedChange: appliedChange,
|
||||
focalJumpPx: focalJumpPx,
|
||||
scaleDrop: scaleDrop,
|
||||
scaleDrop: false,
|
||||
softClamped: softClamped,
|
||||
focalDrop: focalDropped,
|
||||
);
|
||||
}
|
||||
@@ -219,34 +271,32 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
switch (_gestureType!) {
|
||||
case _GestureType.scale:
|
||||
assert(_scaleStart != null);
|
||||
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
|
||||
// scale — the clean, monotonic signal (verified against device logs).
|
||||
// Crucially we do NOT divide by the live matrix scale here: feeding
|
||||
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
|
||||
// and snap back. A ratio outside the glitch band is a real multi-touch
|
||||
// spike → drop the frame; absolute tracking means the next good frame
|
||||
// resumes from the true finger span, so the spike never shows.
|
||||
final double rawRatio =
|
||||
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
|
||||
final bool scaleDrop =
|
||||
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
||||
if (scaleDrop || focalDrop) {
|
||||
record(1.0, scaleDrop, focalDrop);
|
||||
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
|
||||
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
|
||||
final SoftPinchStep step = softClampedPinchStep(
|
||||
scaleStart: _scaleStart!,
|
||||
rawScaleAtBaseline: _rawScaleAtBaseline,
|
||||
rawScale: details.scale,
|
||||
lastAppliedScale: _lastAppliedScale,
|
||||
minScale: widget.minScale,
|
||||
maxScale: widget.maxScale,
|
||||
maxStepRatio: _kScaleGlitchHi,
|
||||
);
|
||||
if (focalDrop) {
|
||||
if (step.reanchor) {
|
||||
_scaleStart = step.appliedScale;
|
||||
_rawScaleAtBaseline = details.scale;
|
||||
_lastAppliedScale = step.appliedScale;
|
||||
}
|
||||
record(_lastAppliedScale, 1.0, step.spiked, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
|
||||
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
|
||||
// scene point that was under the focal at gesture start stays under the
|
||||
// CURRENT focal (which also yields 2-finger pan for free). Closed form
|
||||
// for a pure scale+translate matrix — no inversion, no live read-back —
|
||||
// so an interleaved/transient matrix write can't survive into the next
|
||||
// frame: every frame is fully re-derived from clean inputs.
|
||||
final double targetScale = clampDouble(
|
||||
_scaleStart! * details.scale,
|
||||
widget.minScale,
|
||||
widget.maxScale,
|
||||
);
|
||||
final double targetScale = step.appliedScale;
|
||||
if (step.reanchor) {
|
||||
_scaleStart = targetScale;
|
||||
_rawScaleAtBaseline = details.scale;
|
||||
}
|
||||
final Offset focal = details.localFocalPoint;
|
||||
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
|
||||
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
|
||||
@@ -258,9 +308,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
|
||||
final double applied =
|
||||
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
|
||||
_lastRawScale = details.scale;
|
||||
_lastAppliedScale = targetScale;
|
||||
record(applied, false, false);
|
||||
record(targetScale, applied, step.spiked, false);
|
||||
|
||||
case _GestureType.pan:
|
||||
assert(_referenceFocalPoint != null);
|
||||
@@ -268,7 +317,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
if (details.scale != 1.0) return;
|
||||
if (focalDrop) {
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
record(1.0, false, true);
|
||||
record(scale, 1.0, false, true);
|
||||
return;
|
||||
}
|
||||
final Offset translationChange =
|
||||
@@ -276,11 +325,17 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
_transformer.value =
|
||||
_matrixTranslate(_transformer.value, translationChange);
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
record(1.0, false, false);
|
||||
record(scale, 1.0, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onScaleEnd(ScaleEndDetails details) {
|
||||
final pending = _pendingScaleUpdate;
|
||||
_pendingScaleUpdate = null;
|
||||
_scaleFlushScheduled = false;
|
||||
if (pending != null && _scaleStart != null) {
|
||||
_applyScaleUpdate(pending);
|
||||
}
|
||||
_scaleStart = null;
|
||||
_referenceFocalPoint = null;
|
||||
_lastPointerCount = 0;
|
||||
@@ -321,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
_animation!.addListener(_handleInertiaAnimation);
|
||||
_controller.forward();
|
||||
case _GestureType.scale:
|
||||
if (details.scaleVelocity.abs() < 0.1) return;
|
||||
final double scale = _transformer.value.getMaxScaleOnAxis();
|
||||
final FrictionSimulation frictionSimulation = FrictionSimulation(
|
||||
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();
|
||||
// No scale fling: Windows touch often reports noisy scaleVelocity that
|
||||
// animates past the intended zoom and feels like a "jump" after pinch.
|
||||
return;
|
||||
case null:
|
||||
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 '../engine/brush.dart';
|
||||
|
||||
/// A single captured sample of a stroke.
|
||||
///
|
||||
/// [x]/[y] are normalized to the page rectangle ([0,1]).
|
||||
@@ -35,6 +37,7 @@ class PenStroke {
|
||||
required this.color,
|
||||
required this.width,
|
||||
required this.kind,
|
||||
this.brush = BrushKind.fountainPen,
|
||||
});
|
||||
|
||||
/// Normalized points (see [PenPoint]).
|
||||
@@ -48,4 +51,12 @@ class PenStroke {
|
||||
final double width;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
|
||||
import 'brush.dart';
|
||||
import 'stroke_model.dart';
|
||||
|
||||
/// 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)
|
||||
/// 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
|
||||
/// per-point lag from freehand's 0.5 default to 0.32: at 0.5 a quick flick lags
|
||||
/// so far behind the pen that a short fast stroke collapsed toward its start and
|
||||
/// rendered as a dot ("写字识别成单击") and the pen felt sluggish; 0.32 tracks the
|
||||
/// real path closely (crisper, lower-latency feel) while still damping digitizer
|
||||
/// jitter. [kPenSmoothing] keeps freehand's 0.5 corner rounding.
|
||||
const double kPenStreamline = 0.32;
|
||||
/// per-point lag from freehand's 0.5 default to 0.28: paired with
|
||||
/// [StrokePredictor] lookahead this tracks the Surface Pen more tightly while
|
||||
/// still damping digitizer jitter. [kPenSmoothing] keeps freehand's 0.5 corner
|
||||
/// rounding.
|
||||
const double kPenStreamline = 0.28;
|
||||
const double kPenSmoothing = 0.5;
|
||||
|
||||
/// THE single perfect_freehand outline recipe — the raw outline points for a
|
||||
@@ -51,23 +51,68 @@ List<Offset> freehandOutlinePoints({
|
||||
required bool hasRealPressure,
|
||||
required bool isComplete,
|
||||
double thinning = kDefaultPenThinning,
|
||||
BrushProfile? brush,
|
||||
}) {
|
||||
if (pfPoints.isEmpty) return const <Offset>[];
|
||||
return pf.getStroke(
|
||||
pfPoints,
|
||||
options: pf.StrokeOptions(
|
||||
size: size,
|
||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
||||
thinning: isHighlighter ? 0.0 : thinning,
|
||||
smoothing: kPenSmoothing,
|
||||
streamline: kPenStreamline,
|
||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
||||
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
||||
// 2.x honors real pressure when simulatePressure is false.
|
||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||
isComplete: isComplete,
|
||||
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,
|
||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
||||
thinning: isHighlighter ? 0.0 : thinning,
|
||||
smoothing: kPenSmoothing,
|
||||
streamline: kPenStreamline,
|
||||
// Real stylus pressure -> don't simulate; no pressure -> let
|
||||
// freehand fake it based on velocity (highlighter never simulates).
|
||||
// perfect_freehand 2.x honors real pressure when simulatePressure
|
||||
// is false.
|
||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||
isComplete: isComplete,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
|
||||
///
|
||||
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
|
||||
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
|
||||
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
|
||||
pf.StrokeOptions _optionsFromBrush(
|
||||
BrushProfile brush, {
|
||||
required double size,
|
||||
required bool isComplete,
|
||||
required bool hasRealPressure,
|
||||
}) {
|
||||
return pf.StrokeOptions(
|
||||
size: size,
|
||||
thinning: brush.pfThinning,
|
||||
smoothing: brush.pfSmoothing,
|
||||
streamline: brush.pfStreamline,
|
||||
// Honor REAL pressure (already gamma-pre-warped at capture). Only fall back
|
||||
// to velocity simulation when the device reported no usable pressure.
|
||||
simulatePressure: brush.simulatePressure || !hasRealPressure,
|
||||
start: pf.StrokeEndOptions.start(
|
||||
cap: brush.capStart,
|
||||
taperEnabled: brush.taper,
|
||||
),
|
||||
end: pf.StrokeEndOptions.end(
|
||||
cap: brush.capEnd,
|
||||
taperEnabled: brush.taper,
|
||||
),
|
||||
isComplete: isComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,6 +146,11 @@ Path buildStrokeOutline(
|
||||
)
|
||||
.toList();
|
||||
|
||||
// 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(
|
||||
pfPoints: pfPoints,
|
||||
size: stroke.width * pageSize.width,
|
||||
@@ -108,6 +158,7 @@ Path buildStrokeOutline(
|
||||
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
||||
isComplete: isComplete,
|
||||
thinning: thinning,
|
||||
brush: brush,
|
||||
);
|
||||
|
||||
if (outline.isEmpty) return path;
|
||||
@@ -118,3 +169,36 @@ Path buildStrokeOutline(
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the
|
||||
/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to
|
||||
/// pressure).
|
||||
double _avgPressure(EditorStroke stroke) {
|
||||
if (stroke.points.isEmpty) return 0.5;
|
||||
var sum = 0.0;
|
||||
for (final p in stroke.points) {
|
||||
sum += p.pressure ?? 0.5;
|
||||
}
|
||||
return sum / stroke.points.length;
|
||||
}
|
||||
|
||||
/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved
|
||||
/// opacity (multiplied into the color's alpha) and blend mode applied — closes
|
||||
/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so
|
||||
/// the EditorStroke render path composites exactly like the PenStroke one.
|
||||
///
|
||||
/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap
|
||||
/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply]
|
||||
/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred.
|
||||
Paint paintForEditorStroke(EditorStroke stroke) {
|
||||
final resolved = resolveStrokePaint(
|
||||
stroke.brush,
|
||||
stroke.color,
|
||||
pressureAvg: _avgPressure(stroke),
|
||||
);
|
||||
return Paint()
|
||||
..color = resolved.color
|
||||
..blendMode = resolved.blendMode
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
|
||||
// * 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:uuid/uuid.dart';
|
||||
|
||||
@@ -21,6 +27,7 @@ import '../../models/ink_stroke.dart';
|
||||
import '../../models/pen_tool.dart';
|
||||
import '../../models/pointer_device_kind.dart';
|
||||
import '../canvas/pen_stroke.dart';
|
||||
import 'brush.dart';
|
||||
|
||||
part 'stroke_model.freezed.dart';
|
||||
part 'stroke_model.g.dart';
|
||||
@@ -74,6 +81,19 @@ abstract class EditorStroke with _$EditorStroke {
|
||||
@Default(false) bool filled,
|
||||
String? textContent,
|
||||
@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;
|
||||
|
||||
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
||||
@@ -86,6 +106,7 @@ abstract class EditorStroke with _$EditorStroke {
|
||||
bool filled = false,
|
||||
String? textContent,
|
||||
double fontSize = 14.0,
|
||||
BrushKind brush = BrushKind.fountainPen,
|
||||
}) =>
|
||||
EditorStroke(
|
||||
id: id ?? _uuid.v4(),
|
||||
@@ -96,6 +117,7 @@ abstract class EditorStroke with _$EditorStroke {
|
||||
filled: filled,
|
||||
textContent: textContent,
|
||||
fontSize: fontSize,
|
||||
brush: brush,
|
||||
);
|
||||
|
||||
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -118,6 +140,7 @@ abstract class EditorStroke with _$EditorStroke {
|
||||
},
|
||||
color: stroke.color,
|
||||
width: stroke.width,
|
||||
brush: stroke.brush,
|
||||
);
|
||||
|
||||
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
||||
@@ -141,6 +164,11 @@ abstract class EditorStroke with _$EditorStroke {
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
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
|
||||
|
||||
@@ -302,7 +302,19 @@ mixin _$EditorStroke {
|
||||
double get width => throw _privateConstructorUsedError;
|
||||
bool get filled => 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.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@@ -330,6 +342,11 @@ abstract class $EditorStrokeCopyWith<$Res> {
|
||||
bool filled,
|
||||
String? textContent,
|
||||
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? textContent = freezed,
|
||||
Object? fontSize = null,
|
||||
Object? brush = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -391,6 +409,10 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
||||
? _value.fontSize
|
||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
brush: null == brush
|
||||
? _value.brush
|
||||
: brush // ignore: cast_nullable_to_non_nullable
|
||||
as BrushKind,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
@@ -415,6 +437,11 @@ abstract class _$$EditorStrokeImplCopyWith<$Res>
|
||||
bool filled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
@JsonKey(
|
||||
defaultValue: BrushKind.fountainPen,
|
||||
unknownEnumValue: BrushKind.fountainPen,
|
||||
)
|
||||
BrushKind brush,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -440,6 +467,7 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
||||
Object? filled = null,
|
||||
Object? textContent = freezed,
|
||||
Object? fontSize = null,
|
||||
Object? brush = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$EditorStrokeImpl(
|
||||
@@ -475,6 +503,10 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
|
||||
? _value.fontSize
|
||||
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||
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.textContent,
|
||||
this.fontSize = 14.0,
|
||||
@JsonKey(
|
||||
defaultValue: BrushKind.fountainPen,
|
||||
unknownEnumValue: BrushKind.fountainPen,
|
||||
)
|
||||
this.brush = BrushKind.fountainPen,
|
||||
}) : _points = points,
|
||||
super._();
|
||||
|
||||
@@ -525,10 +562,23 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
||||
@override
|
||||
@JsonKey()
|
||||
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
|
||||
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
|
||||
@@ -545,7 +595,8 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
||||
(identical(other.textContent, textContent) ||
|
||||
other.textContent == textContent) &&
|
||||
(identical(other.fontSize, fontSize) ||
|
||||
other.fontSize == fontSize));
|
||||
other.fontSize == fontSize) &&
|
||||
(identical(other.brush, brush) || other.brush == brush));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -560,6 +611,7 @@ class _$EditorStrokeImpl extends _EditorStroke {
|
||||
filled,
|
||||
textContent,
|
||||
fontSize,
|
||||
brush,
|
||||
);
|
||||
|
||||
/// Create a copy of EditorStroke
|
||||
@@ -586,6 +638,11 @@ abstract class _EditorStroke extends EditorStroke {
|
||||
final bool filled,
|
||||
final String? textContent,
|
||||
final double fontSize,
|
||||
@JsonKey(
|
||||
defaultValue: BrushKind.fountainPen,
|
||||
unknownEnumValue: BrushKind.fountainPen,
|
||||
)
|
||||
final BrushKind brush,
|
||||
}) = _$EditorStrokeImpl;
|
||||
_EditorStroke._() : super._();
|
||||
|
||||
@@ -607,7 +664,19 @@ abstract class _EditorStroke extends EditorStroke {
|
||||
@override
|
||||
String? get textContent;
|
||||
@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
|
||||
/// 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,
|
||||
textContent: json['textContent'] as String?,
|
||||
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
|
||||
brush:
|
||||
$enumDecodeNullable(
|
||||
_$BrushKindEnumMap,
|
||||
json['brush'],
|
||||
unknownValue: BrushKind.fountainPen,
|
||||
) ??
|
||||
BrushKind.fountainPen,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
||||
@@ -64,6 +71,7 @@ Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
||||
'filled': instance.filled,
|
||||
'textContent': instance.textContent,
|
||||
'fontSize': instance.fontSize,
|
||||
'brush': _$BrushKindEnumMap[instance.brush]!,
|
||||
};
|
||||
|
||||
const _$EditorToolEnumMap = {
|
||||
@@ -71,3 +79,10 @@ const _$EditorToolEnumMap = {
|
||||
EditorTool.highlighter: 'highlighter',
|
||||
EditorTool.eraser: 'eraser',
|
||||
};
|
||||
|
||||
const _$BrushKindEnumMap = {
|
||||
BrushKind.fountainPen: 'fountainPen',
|
||||
BrushKind.ballpoint: 'ballpoint',
|
||||
BrushKind.highlighter: 'highlighter',
|
||||
BrushKind.pencil: 'pencil',
|
||||
};
|
||||
|
||||
56
lib/editor/engine/stroke_predictor.dart
Normal file
56
lib/editor/engine/stroke_predictor.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// Lightweight stroke prediction — extrapolates the next point from recent
|
||||
// velocity so the live stroke tip leads the digitizer slightly (lower perceived
|
||||
// latency). Not a full ink-stroke-modeler; intentionally small and testable.
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
class PredictedPoint {
|
||||
const PredictedPoint(this.offset, this.pressure);
|
||||
final Offset offset;
|
||||
final double pressure;
|
||||
}
|
||||
|
||||
class StrokePredictor {
|
||||
StrokePredictor({this.lookaheadMs = 8});
|
||||
|
||||
/// How far ahead to project, in milliseconds of recent velocity.
|
||||
final double lookaheadMs;
|
||||
|
||||
Offset? _prev;
|
||||
double? _prevPressure;
|
||||
DateTime? _prevAt;
|
||||
Offset _velocity = Offset.zero;
|
||||
|
||||
void reset() {
|
||||
_prev = null;
|
||||
_prevPressure = null;
|
||||
_prevAt = null;
|
||||
_velocity = Offset.zero;
|
||||
}
|
||||
|
||||
/// Feed a real sample; returns an optional predicted tip ahead of [point].
|
||||
PredictedPoint? observe(Offset point, double pressure, {DateTime? at}) {
|
||||
final now = at ?? DateTime.now();
|
||||
if (_prev != null && _prevAt != null) {
|
||||
final dtMs = now.difference(_prevAt!).inMicroseconds / 1000.0;
|
||||
if (dtMs > 0.5 && dtMs < 80) {
|
||||
final raw = (point - _prev!) * (1000.0 / dtMs);
|
||||
// EMA blend to avoid jerky predictions.
|
||||
_velocity = Offset(
|
||||
_velocity.dx * 0.55 + raw.dx * 0.45,
|
||||
_velocity.dy * 0.55 + raw.dy * 0.45,
|
||||
);
|
||||
}
|
||||
}
|
||||
_prev = point;
|
||||
_prevPressure = pressure;
|
||||
_prevAt = now;
|
||||
|
||||
if (_velocity.distance < 40) return null; // idle / slow — no predict
|
||||
final tip = point + _velocity * (lookaheadMs / 1000.0);
|
||||
return PredictedPoint(tip, pressure);
|
||||
}
|
||||
|
||||
/// Last known pressure (for predicted tip).
|
||||
double get lastPressure => _prevPressure ?? 0.5;
|
||||
}
|
||||
@@ -1,90 +1,38 @@
|
||||
// lib/editor/input/diagnostic_logger.dart
|
||||
//
|
||||
// On-device input diagnostics. When the user manually enables the diagnostic
|
||||
// (the toolbar toggle), every native pen event (raw button/flag/tilt fields)
|
||||
// and every zoom frame is emitted through the standard `dart:developer` log
|
||||
// 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).
|
||||
// Compatibility facade over [BadNoteLog] + [PenEventRing]. The PDF editor's
|
||||
// toolbar toggle still calls start/stop; globally, [BadNoteLog.start] runs at
|
||||
// app launch so packaged builds always have a session file.
|
||||
|
||||
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 {
|
||||
DiagnosticLogger._();
|
||||
static final DiagnosticLogger instance = DiagnosticLogger._();
|
||||
|
||||
final List<String> _buffer = <String>[];
|
||||
File? _file;
|
||||
Timer? _timer;
|
||||
int _epochMs = 0;
|
||||
bool _verbose = false;
|
||||
bool get isActive => _verbose || BadNoteLog.instance.path != null;
|
||||
|
||||
bool _active = false;
|
||||
bool get isActive => _active;
|
||||
/// Absolute path of the structured session log (preferred), else null.
|
||||
String? get path => BadNoteLog.instance.path;
|
||||
|
||||
/// Absolute path of the current log file (shown in the overlay), or null.
|
||||
String? path;
|
||||
|
||||
/// Begin a session. Enables the `dart:developer` log channel immediately and
|
||||
/// opens the fallback file (best-effort). Safe to call repeatedly.
|
||||
/// Begin a verbose input session (also ensures the global log is running).
|
||||
Future<void> start() async {
|
||||
if (_active) return;
|
||||
_active = true; // developer.log works even if the file can't be opened
|
||||
_epochMs = DateTime.now().millisecondsSinceEpoch;
|
||||
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.
|
||||
}
|
||||
_verbose = true;
|
||||
await BadNoteLog.instance.start();
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_on');
|
||||
}
|
||||
|
||||
/// Emit one diagnostic line through the standard log channel and the file.
|
||||
void log(String line) {
|
||||
if (!_active) return;
|
||||
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();
|
||||
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!_active) return;
|
||||
_active = false;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await _flush();
|
||||
if (!_verbose) return;
|
||||
_verbose = false;
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_off');
|
||||
await BadNoteLog.instance.flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
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.
|
||||
enum PenButtonAction {
|
||||
@@ -13,6 +18,12 @@ enum PenButtonAction {
|
||||
undo,
|
||||
toggleTool,
|
||||
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.
|
||||
@@ -20,20 +31,24 @@ enum PenButtonAction {
|
||||
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
||||
class PenConfig {
|
||||
const PenConfig({
|
||||
this.sideButton = PenButtonAction.eraser,
|
||||
this.sideButton = PenButtonAction.selectText,
|
||||
this.eraserEnd = PenButtonAction.eraser,
|
||||
this.pressureGamma = 1.0,
|
||||
this.pressureGamma = kNaturalPressureGamma,
|
||||
this.palmRejectionMs = 150.0,
|
||||
this.fingerDrawing = false,
|
||||
this.penWidth = 0.004,
|
||||
this.highlighterWidth = 0.02,
|
||||
this.pressureSensitivity = kDefaultPenThinning,
|
||||
this.eraserRadius = kDefaultEraserRadius,
|
||||
this.eraserWholeStroke = false,
|
||||
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
|
||||
'pressureGamma must be in [0.3, 3.0]'),
|
||||
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
|
||||
'palmRejectionMs must be in [0, 500]'),
|
||||
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.
|
||||
final PenButtonAction sideButton;
|
||||
@@ -66,6 +81,16 @@ class PenConfig {
|
||||
/// export golden are unchanged.
|
||||
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({
|
||||
PenButtonAction? sideButton,
|
||||
PenButtonAction? eraserEnd,
|
||||
@@ -75,6 +100,8 @@ class PenConfig {
|
||||
double? penWidth,
|
||||
double? highlighterWidth,
|
||||
double? pressureSensitivity,
|
||||
double? eraserRadius,
|
||||
bool? eraserWholeStroke,
|
||||
}) {
|
||||
return PenConfig(
|
||||
sideButton: sideButton ?? this.sideButton,
|
||||
@@ -85,6 +112,8 @@ class PenConfig {
|
||||
penWidth: penWidth ?? this.penWidth,
|
||||
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
|
||||
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
|
||||
eraserRadius: eraserRadius ?? this.eraserRadius,
|
||||
eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +126,8 @@ class PenConfig {
|
||||
'penWidth': penWidth,
|
||||
'highlighterWidth': highlighterWidth,
|
||||
'pressureSensitivity': pressureSensitivity,
|
||||
'eraserRadius': eraserRadius,
|
||||
'eraserWholeStroke': eraserWholeStroke,
|
||||
};
|
||||
|
||||
factory PenConfig.fromJson(Map<String, dynamic> json) {
|
||||
@@ -107,13 +138,17 @@ class PenConfig {
|
||||
eraserEnd:
|
||||
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
|
||||
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,
|
||||
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
|
||||
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
|
||||
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
|
||||
pressureSensitivity:
|
||||
(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 &&
|
||||
penWidth == other.penWidth &&
|
||||
highlighterWidth == other.highlighterWidth &&
|
||||
pressureSensitivity == other.pressureSensitivity;
|
||||
pressureSensitivity == other.pressureSensitivity &&
|
||||
eraserRadius == other.eraserRadius &&
|
||||
eraserWholeStroke == other.eraserWholeStroke;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
@@ -141,6 +178,8 @@ class PenConfig {
|
||||
penWidth,
|
||||
highlighterWidth,
|
||||
pressureSensitivity,
|
||||
eraserRadius,
|
||||
eraserWholeStroke,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +200,9 @@ class PenConfigController extends ChangeNotifier {
|
||||
/// The SharedPreferences key under which [PenConfig] JSON is stored.
|
||||
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;
|
||||
PenConfig _value;
|
||||
|
||||
@@ -184,6 +226,18 @@ class PenConfigController extends ChangeNotifier {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -236,6 +290,18 @@ class PenConfigController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
|
||||
notifyListeners();
|
||||
|
||||
@@ -2,27 +2,16 @@
|
||||
//
|
||||
// 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
|
||||
// the pen's barrel button, eraser/inverted end, and tilt (it does not map
|
||||
// POINTER_PEN_FLAG_* into `PointerEvent.buttons`/`invertedStylus`/`tilt`). The
|
||||
// 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.
|
||||
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
|
||||
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
|
||||
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../diagnostics/badnote_log.dart';
|
||||
import '../../diagnostics/pen_event_ring.dart';
|
||||
import 'diagnostic_logger.dart';
|
||||
|
||||
/// Latest hardware pen state delivered by the native observer.
|
||||
@@ -33,22 +22,20 @@ class PenHardwareState {
|
||||
this.eraser = false,
|
||||
this.tiltX = 0.0,
|
||||
this.tiltY = 0.0,
|
||||
this.pressure = 0.0,
|
||||
this.pressureValid = false,
|
||||
});
|
||||
|
||||
/// Side barrel button held.
|
||||
final bool barrel;
|
||||
|
||||
/// Pen flipped to the inverted (eraser) end.
|
||||
final bool inverted;
|
||||
|
||||
/// Hardware eraser flag set.
|
||||
final bool eraser;
|
||||
|
||||
/// Tilt in degrees along X / Y ([-90, 90]); 0 = perpendicular.
|
||||
final double tiltX;
|
||||
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 {
|
||||
final t = tiltX * tiltX + tiltY * tiltY;
|
||||
return t <= 0 ? 0.0 : _sqrt(t);
|
||||
@@ -57,12 +44,10 @@ class PenHardwareState {
|
||||
static const empty = PenHardwareState();
|
||||
}
|
||||
|
||||
// Avoids importing dart:math for a single call.
|
||||
double _sqrt(double v) {
|
||||
if (v <= 0) return 0;
|
||||
var x = v;
|
||||
var last = 0.0;
|
||||
// Newton's method; converges fast for the small (<=~127) magnitudes here.
|
||||
for (var i = 0; i < 12 && x != last; i++) {
|
||||
last = x;
|
||||
x = 0.5 * (x + v / x);
|
||||
@@ -70,33 +55,34 @@ double _sqrt(double v) {
|
||||
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 {
|
||||
PenInputService._();
|
||||
|
||||
/// Process-wide singleton (one physical pen).
|
||||
static final PenInputService instance = PenInputService._();
|
||||
|
||||
/// Must match the native `EventChannel` name in `pen_channel.cpp`.
|
||||
static const EventChannel _channel = EventChannel('badnote/pen');
|
||||
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
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;
|
||||
|
||||
/// 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 _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 _diagPen = 0;
|
||||
int _diagMouse = 0;
|
||||
@@ -106,47 +92,42 @@ class PenInputService {
|
||||
int _orPenMask = 0;
|
||||
int _btnChangeLast = 0;
|
||||
int _tiltAbsMax = 0;
|
||||
double _pressureMaxSeen = 0;
|
||||
|
||||
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
|
||||
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
|
||||
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
|
||||
' 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)';
|
||||
|
||||
/// Begins listening to the native channel. Idempotent; safe on any platform
|
||||
/// (no-ops where the channel has no handler).
|
||||
void start() {
|
||||
if (_sub != null) return;
|
||||
try {
|
||||
_sub = _channel.receiveBroadcastStream().listen(
|
||||
_onEvent,
|
||||
onError: (Object _) {
|
||||
// No native handler (e.g. Linux/macOS) or transient error — ignore
|
||||
// and keep the empty fallback state.
|
||||
},
|
||||
onError: (Object _) {},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (_) {
|
||||
// receiveBroadcastStream can throw synchronously if the platform side is
|
||||
// unavailable; degrade silently.
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _onEvent(dynamic event) {
|
||||
if (event is! Map) return;
|
||||
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(
|
||||
barrel: flags & 0x1 != 0,
|
||||
inverted: flags & 0x2 != 0,
|
||||
eraser: flags & 0x4 != 0,
|
||||
tiltX: (event['tiltX'] 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;
|
||||
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
|
||||
@@ -157,31 +138,60 @@ class PenInputService {
|
||||
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
|
||||
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
|
||||
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
|
||||
_pressureMaxSeen =
|
||||
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
|
||||
if (pressureValid && pressure > _pressureMaxSeen) {
|
||||
_pressureMaxSeen = pressure;
|
||||
}
|
||||
_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 rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
|
||||
final rawMask = (event['rawPenMask'] 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) {
|
||||
_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(
|
||||
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
|
||||
'penFlags=0x${rawPen.toRadixString(16)} '
|
||||
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
|
||||
'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)}',
|
||||
);
|
||||
}
|
||||
_notifyListeners();
|
||||
}
|
||||
|
||||
String _lastPenLogKey = '';
|
||||
|
||||
/// Stops listening and resets state.
|
||||
void stop() {
|
||||
_sub?.cancel();
|
||||
_sub = null;
|
||||
|
||||
213
lib/editor/input/pen_slots.dart
Normal file
213
lib/editor/input/pen_slots.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../engine/brush.dart';
|
||||
|
||||
/// OneNote-style independent pen slot: brush + color + thickness together.
|
||||
///
|
||||
/// Selecting a slot restores all three; color dots / thickness controls edit
|
||||
/// only the active slot.
|
||||
class PenSlot {
|
||||
const PenSlot({
|
||||
required this.id,
|
||||
required this.brush,
|
||||
required this.color,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
|
||||
final BrushKind brush;
|
||||
|
||||
final Color color;
|
||||
|
||||
/// Stroke width as a fraction of page width.
|
||||
final double width;
|
||||
|
||||
PenSlot copyWith({
|
||||
String? id,
|
||||
BrushKind? brush,
|
||||
Color? color,
|
||||
double? width,
|
||||
}) {
|
||||
return PenSlot(
|
||||
id: id ?? this.id,
|
||||
brush: brush ?? this.brush,
|
||||
color: color ?? this.color,
|
||||
width: width ?? this.width,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'brush': brush.name,
|
||||
'color': color.toARGB32(),
|
||||
'width': width,
|
||||
};
|
||||
|
||||
factory PenSlot.fromJson(Map<String, dynamic> json) {
|
||||
final brushName = json['brush'] as String? ?? '';
|
||||
return PenSlot(
|
||||
id: json['id'] as String? ?? 'slot_0',
|
||||
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
|
||||
color: Color(json['color'] as int? ?? 0xFF000000),
|
||||
width: (json['width'] as num?)?.toDouble() ?? 0.006,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PenSlot &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
brush == other.brush &&
|
||||
color.toARGB32() == other.color.toARGB32() &&
|
||||
width == other.width;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
|
||||
}
|
||||
|
||||
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
|
||||
List<PenSlot> kDefaultPenSlots() => const [
|
||||
PenSlot(
|
||||
id: 'slot_0',
|
||||
brush: BrushKind.fountainPen,
|
||||
color: Colors.black,
|
||||
width: 0.006,
|
||||
),
|
||||
PenSlot(
|
||||
id: 'slot_1',
|
||||
brush: BrushKind.ballpoint,
|
||||
color: Colors.blue,
|
||||
width: 0.0022,
|
||||
),
|
||||
PenSlot(
|
||||
id: 'slot_2',
|
||||
brush: BrushKind.pencil,
|
||||
color: Colors.green,
|
||||
width: 0.003,
|
||||
),
|
||||
];
|
||||
|
||||
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
|
||||
const double kThicknessSmall = 0.0022;
|
||||
const double kThicknessMedium = 0.006;
|
||||
const double kThicknessLarge = 0.012;
|
||||
|
||||
/// Allowed range for slot stroke width (page-width fraction).
|
||||
const double kPenSlotWidthMin = 0.001;
|
||||
const double kPenSlotWidthMax = 0.05;
|
||||
|
||||
/// Manages independent [PenSlot]s with SharedPreferences persistence.
|
||||
///
|
||||
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
|
||||
class PenSlotsController extends ChangeNotifier {
|
||||
PenSlotsController._(this._prefs, this._slots, this._activeId);
|
||||
|
||||
/// SharedPreferences key for the slots JSON blob.
|
||||
static const prefsKey = 'pen_slots_v1';
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
List<PenSlot> _slots;
|
||||
String _activeId;
|
||||
|
||||
List<PenSlot> get slots => List.unmodifiable(_slots);
|
||||
|
||||
String get activeId => _activeId;
|
||||
|
||||
PenSlot get active {
|
||||
for (final s in _slots) {
|
||||
if (s.id == _activeId) return s;
|
||||
}
|
||||
return _slots.first;
|
||||
}
|
||||
|
||||
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
|
||||
/// JSON.
|
||||
static Future<PenSlotsController> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(prefsKey);
|
||||
var slots = kDefaultPenSlots();
|
||||
var activeId = slots.first.id;
|
||||
if (raw != null) {
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final list = map['slots'] as List<dynamic>?;
|
||||
if (list != null && list.isNotEmpty) {
|
||||
slots = [
|
||||
for (final e in list)
|
||||
PenSlot.fromJson(e as Map<String, dynamic>),
|
||||
];
|
||||
}
|
||||
final storedActive = map['activeId'] as String?;
|
||||
if (storedActive != null &&
|
||||
slots.any((s) => s.id == storedActive)) {
|
||||
activeId = storedActive;
|
||||
} else {
|
||||
activeId = slots.first.id;
|
||||
}
|
||||
} catch (_) {
|
||||
slots = kDefaultPenSlots();
|
||||
activeId = slots.first.id;
|
||||
}
|
||||
}
|
||||
return PenSlotsController._(prefs, slots, activeId);
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
await _prefs.setString(
|
||||
prefsKey,
|
||||
jsonEncode({
|
||||
'activeId': _activeId,
|
||||
'slots': [for (final s in _slots) s.toJson()],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
int _indexOfActive() {
|
||||
final i = _slots.indexWhere((s) => s.id == _activeId);
|
||||
return i >= 0 ? i : 0;
|
||||
}
|
||||
|
||||
void _replaceActive(PenSlot next) {
|
||||
final i = _indexOfActive();
|
||||
_slots = [..._slots]..[i] = next;
|
||||
}
|
||||
|
||||
/// Selects [id] as the active slot (restores brush + color + width).
|
||||
Future<void> select(String id) async {
|
||||
if (!_slots.any((s) => s.id == id)) return;
|
||||
if (_activeId == id) return;
|
||||
_activeId = id;
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Sets the active slot's color.
|
||||
Future<void> setActiveColor(Color c) async {
|
||||
_replaceActive(active.copyWith(color: c));
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Sets the active slot's stroke width (clamped).
|
||||
Future<void> setActiveWidth(double w) async {
|
||||
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
|
||||
_replaceActive(active.copyWith(width: clamped));
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
/// Sets the active slot's brush kind.
|
||||
Future<void> setActiveBrush(BrushKind b) async {
|
||||
if (b == BrushKind.highlighter) return;
|
||||
_replaceActive(active.copyWith(brush: b));
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
}
|
||||
@@ -8,33 +8,105 @@
|
||||
// - [gamma]: the response exponent — γ<1 makes light touches register more
|
||||
// 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})
|
||||
: assert(floor >= 0.0 && floor < 1.0),
|
||||
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.
|
||||
/// >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 shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||
final double shaped;
|
||||
if (shape == PressureCurveShape.logarithmic) {
|
||||
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
|
||||
math.log(1.0 + kLogarithmicPressureK);
|
||||
} else {
|
||||
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
|
||||
}
|
||||
return floor + (1.0 - floor) * shaped;
|
||||
}
|
||||
}
|
||||
|
||||
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
103
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
// lib/editor/notebook/ink_stroke_adapter.dart
|
||||
//
|
||||
// Bridge between the legacy note/ppt storage model (`InkStroke`, ABSOLUTE pixel
|
||||
// coordinates, `PenTool`) and the pen-first canvas model (`PenStroke`,
|
||||
// NORMALIZED [0,1] coordinates, `PenStrokeKind`). The pen-first canvas is the
|
||||
// single performant inking engine, so notes and slides are rebuilt on top of it
|
||||
// and persisted back as `InkStroke` via this adapter.
|
||||
//
|
||||
// Coordinates are normalized against a logical page rectangle: ink absolute
|
||||
// (x,y) -> pen (x/pageW, y/pageH) and back. Stroke width is likewise expressed
|
||||
// as a fraction of the page width on the pen side and as absolute pixels on the
|
||||
// ink side. Only freehand pen/highlighter strokes round-trip; shape/text
|
||||
// `PenTool`s have no pen-canvas representation and are dropped (the pen-first
|
||||
// note is handwriting-first — see the rebuild roadmap).
|
||||
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import '../../models/ink_point.dart';
|
||||
import '../../models/ink_stroke.dart';
|
||||
import '../../models/pen_tool.dart';
|
||||
import '../canvas/pen_stroke.dart';
|
||||
import '../engine/brush.dart';
|
||||
|
||||
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
|
||||
/// Strokes are normalized against this so they stay pinned under zoom/pan.
|
||||
const Size kNoteLogicalPage = Size(1000, 1414);
|
||||
|
||||
/// True when [tool] is a freehand mark the pen canvas can render
|
||||
/// (pen/marker/highlighter). Shapes and text are not representable.
|
||||
bool isFreehandTool(PenTool tool) =>
|
||||
tool == PenTool.pen ||
|
||||
tool == PenTool.marker ||
|
||||
tool == PenTool.highlighter;
|
||||
|
||||
/// Maps an ink [PenTool] to the pen-canvas stroke kind.
|
||||
PenStrokeKind penKindFromTool(PenTool tool) =>
|
||||
tool == PenTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen;
|
||||
|
||||
/// Maps a pen-canvas stroke kind back to a [PenTool].
|
||||
PenTool toolFromPenKind(PenStrokeKind kind) =>
|
||||
kind == PenStrokeKind.highlighter ? PenTool.highlighter : PenTool.pen;
|
||||
|
||||
/// Convert a stored [InkStroke] (absolute px on [page]) to a [PenStroke]
|
||||
/// (normalized). Returns null for non-freehand strokes (shapes/text), which the
|
||||
/// pen canvas cannot draw.
|
||||
PenStroke? penStrokeFromInk(InkStroke s, Size page) {
|
||||
if (!isFreehandTool(s.tool)) return null;
|
||||
if (s.points.isEmpty) return null;
|
||||
final w = page.width <= 0 ? 1.0 : page.width;
|
||||
final h = page.height <= 0 ? 1.0 : page.height;
|
||||
return PenStroke(
|
||||
points: [
|
||||
for (final p in s.points)
|
||||
PenPoint(p.x / w, p.y / h, p.pressure, tilt: p.tilt),
|
||||
],
|
||||
color: s.color,
|
||||
width: s.strokeWidth / w,
|
||||
kind: penKindFromTool(s.tool),
|
||||
// Brush isn't persisted yet (TODO(brush-persist)); derive from the tool so
|
||||
// a loaded highlighter renders with the flat highlighter brush and pens
|
||||
// fall back to the fountainPen default.
|
||||
brush: s.tool == PenTool.highlighter
|
||||
? BrushKind.highlighter
|
||||
: BrushKind.fountainPen,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert a freshly drawn [PenStroke] (normalized) back to an [InkStroke]
|
||||
/// (absolute px on [page]) for persistence. [id] and [createdAt] come from the
|
||||
/// caller (uuid + clock) so this stays pure/deterministic.
|
||||
InkStroke inkStrokeFromPen(
|
||||
PenStroke s,
|
||||
Size page, {
|
||||
required String id,
|
||||
required DateTime createdAt,
|
||||
}) {
|
||||
final w = page.width <= 0 ? 1.0 : page.width;
|
||||
final h = page.height <= 0 ? 1.0 : page.height;
|
||||
return InkStroke(
|
||||
id: id,
|
||||
points: [
|
||||
for (final p in s.points)
|
||||
InkPoint(
|
||||
x: p.x * w,
|
||||
y: p.y * h,
|
||||
pressure: p.pressure ?? 0.5,
|
||||
tilt: p.tilt ?? 0.0,
|
||||
timestamp: 0,
|
||||
),
|
||||
],
|
||||
tool: toolFromPenKind(s.kind),
|
||||
color: s.color,
|
||||
strokeWidth: s.width * w,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert a list of stored ink strokes to pen strokes, dropping the ones the
|
||||
/// canvas cannot represent (shapes/text). Order is preserved.
|
||||
List<PenStroke> penStrokesFromInk(Iterable<InkStroke> strokes, Size page) =>
|
||||
[for (final s in strokes) penStrokeFromInk(s, page)]
|
||||
.whereType<PenStroke>()
|
||||
.toList();
|
||||
61
lib/editor/pdf/page_tile_layer.dart
Normal file
61
lib/editor/pdf/page_tile_layer.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// Double-buffer helper on top of [PageTileCache] to kill zoom white-flash:
|
||||
// keep painting the last good tile while a higher-DPI raster is in flight.
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'page_tile_cache.dart';
|
||||
|
||||
/// Holds the "last good" page image for the currently visible page so a zoom
|
||||
/// settle never exposes an empty frame (plan W2 / R11).
|
||||
class PageTileLayer extends ChangeNotifier {
|
||||
PageTileLayer({PageTileCache? cache}) : _cache = cache ?? PageTileCache();
|
||||
|
||||
final PageTileCache _cache;
|
||||
ui.Image? _lastGood;
|
||||
TileKey? _lastKey;
|
||||
|
||||
PageTileCache get cache => _cache;
|
||||
ui.Image? get lastGood => _lastGood;
|
||||
TileKey? get lastKey => _lastKey;
|
||||
|
||||
/// Snap continuous zoom to a coarse DPI bucket (avoids a tile per frame).
|
||||
static int dpiBucketFor(double zoom, {double baseDpi = 96, double step = 0.5}) {
|
||||
final raw = zoom / step;
|
||||
final snapped = raw.round().clamp(1, 16);
|
||||
return (snapped * step * baseDpi).round();
|
||||
}
|
||||
|
||||
/// Promote [image] as the last-good tile for [key].
|
||||
void put(TileKey key, ui.Image image) {
|
||||
_cache.put(key, image);
|
||||
_lastGood = image;
|
||||
_lastKey = key;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Prefer exact bucket; else fall back to last-good so zoom never blanks.
|
||||
ui.Image? resolve(TileKey key) {
|
||||
final hit = _cache.get(key);
|
||||
if (hit != null) {
|
||||
_lastGood = hit;
|
||||
_lastKey = key;
|
||||
return hit;
|
||||
}
|
||||
return _lastGood;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_lastGood = null;
|
||||
_lastKey = null;
|
||||
_cache.dispose();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
36
lib/editor/pdf/slide_export.dart
Normal file
36
lib/editor/pdf/slide_export.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
// lib/editor/pdf/slide_export.dart
|
||||
//
|
||||
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
|
||||
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
|
||||
// just maps each normalized point into the slide image's draw rectangle on the
|
||||
// PDF page — no live-widget-size guessing, which is what made the old PPT
|
||||
// exporter misalign ink (see the removed ppt_annotator_screen comment).
|
||||
|
||||
import 'dart:ui' show Offset, Rect, Size;
|
||||
|
||||
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
|
||||
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
|
||||
/// exported ink lands exactly where it was drawn.
|
||||
Rect slideDrawRect(Size page, Size image) {
|
||||
final iw = image.width <= 0 ? 1.0 : image.width;
|
||||
final ih = image.height <= 0 ? 1.0 : image.height;
|
||||
final scale = (page.width / iw) < (page.height / ih)
|
||||
? page.width / iw
|
||||
: page.height / ih;
|
||||
final drawW = iw * scale;
|
||||
final drawH = ih * scale;
|
||||
final offX = (page.width - drawW) / 2;
|
||||
final offY = (page.height - drawH) / 2;
|
||||
return Rect.fromLTWH(offX, offY, drawW, drawH);
|
||||
}
|
||||
|
||||
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
|
||||
/// inside the slide's [drawRect] on the PDF page.
|
||||
Offset normToSlide(double nx, double ny, Rect drawRect) =>
|
||||
Offset(drawRect.left + nx * drawRect.width,
|
||||
drawRect.top + ny * drawRect.height);
|
||||
|
||||
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
|
||||
/// page width, scaled into [drawRect].
|
||||
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
|
||||
normalizedWidth * drawRect.width;
|
||||
@@ -1,187 +0,0 @@
|
||||
// lib/editor/pdf/spike_app.dart
|
||||
//
|
||||
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
|
||||
// on-screen frame-timing HUD (median build & raster ms over the last ~120
|
||||
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
|
||||
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'spike_editor_pane.dart';
|
||||
|
||||
class SpikeApp extends StatelessWidget {
|
||||
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'BadNote M1 Spike',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
|
||||
home: SpikeHome(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: denseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpikeHome extends StatefulWidget {
|
||||
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
|
||||
@override
|
||||
State<SpikeHome> createState() => _SpikeHomeState();
|
||||
}
|
||||
|
||||
class _SpikeHomeState extends State<SpikeHome> {
|
||||
final GlobalKey<SpikeEditorPaneState> _paneKey =
|
||||
GlobalKey<SpikeEditorPaneState>();
|
||||
final PdfViewerController _controller = PdfViewerController();
|
||||
bool _inkLoad = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
SpikeEditorPane(
|
||||
key: _paneKey,
|
||||
controller: _controller,
|
||||
pdfPath: widget.pdfPath,
|
||||
denseStrokesAsset: widget.denseStrokesAsset,
|
||||
),
|
||||
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
|
||||
],
|
||||
),
|
||||
floatingActionButton: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton.extended(
|
||||
heroTag: 'inkload',
|
||||
onPressed: () async {
|
||||
final next = !_inkLoad;
|
||||
await _paneKey.currentState?.setInkLoad(next);
|
||||
setState(() => _inkLoad = next);
|
||||
},
|
||||
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
|
||||
icon: const Icon(Icons.brush),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On-screen median build/raster frame-time HUD, driven by
|
||||
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
|
||||
/// [_window] frames for both the build (`buildDuration`) and raster
|
||||
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
|
||||
/// MUST #4/#5.
|
||||
class FrameTimingHud extends StatefulWidget {
|
||||
const FrameTimingHud({super.key});
|
||||
|
||||
@override
|
||||
State<FrameTimingHud> createState() => _FrameTimingHudState();
|
||||
}
|
||||
|
||||
class _FrameTimingHudState extends State<FrameTimingHud> {
|
||||
static const int _window = 120;
|
||||
final List<double> _build = <double>[];
|
||||
final List<double> _raster = <double>[];
|
||||
double _medBuild = 0;
|
||||
double _medRaster = 0;
|
||||
double _p95Build = 0;
|
||||
double _p95Raster = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SchedulerBinding.instance.addTimingsCallback(_onTimings);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
for (final t in timings) {
|
||||
_build.add(t.buildDuration.inMicroseconds / 1000.0);
|
||||
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
|
||||
}
|
||||
while (_build.length > _window) {
|
||||
_build.removeAt(0);
|
||||
}
|
||||
while (_raster.length > _window) {
|
||||
_raster.removeAt(0);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_medBuild = _percentile(_build, 50);
|
||||
_medRaster = _percentile(_raster, 50);
|
||||
_p95Build = _percentile(_build, 95);
|
||||
_p95Raster = _percentile(_raster, 95);
|
||||
});
|
||||
}
|
||||
|
||||
static double _percentile(List<double> values, int p) {
|
||||
if (values.isEmpty) return 0;
|
||||
final sorted = List<double>.from(values)..sort();
|
||||
final idx = ((p / 100.0) * (sorted.length - 1)).round();
|
||||
return sorted[idx.clamp(0, sorted.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color budget(double ms) => ms <= 16.6
|
||||
? Colors.greenAccent
|
||||
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('frames: ${_build.length}/$_window'),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'build med '),
|
||||
TextSpan(
|
||||
text: '${_medBuild.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medBuild))),
|
||||
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
Text.rich(TextSpan(children: [
|
||||
const TextSpan(text: 'raster med '),
|
||||
TextSpan(
|
||||
text: '${_medRaster.toStringAsFixed(1)}ms',
|
||||
style: TextStyle(color: budget(_medRaster))),
|
||||
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
|
||||
])),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
// lib/editor/pdf/spike_editor_pane.dart
|
||||
//
|
||||
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
|
||||
// PdfViewer.file and exercises the three things the M1 gate must prove:
|
||||
//
|
||||
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
|
||||
// diagnostic crosshair at normalized (0.5, 0.5) using
|
||||
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
|
||||
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
|
||||
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
|
||||
//
|
||||
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
|
||||
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
|
||||
// touch scrolls and pinch zooms — same overlay, no mode switch.
|
||||
//
|
||||
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
|
||||
// per page (from dense_strokes.json) into the page overlay so the perf
|
||||
// bench can measure BUILD time with a non-trivial ui.Picture per page.
|
||||
//
|
||||
// This file is NOT production code and is excluded from the real editor.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
|
||||
/// Normalized page-space point the diagnostic marker is painted at. The M1
|
||||
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
|
||||
const Offset kMarkerNormalized = Offset(0.5, 0.5);
|
||||
|
||||
/// A single captured pen sample in normalized page space, tagged with its page.
|
||||
class _PenSample {
|
||||
const _PenSample(this.pageIndex, this.normalized);
|
||||
final int pageIndex;
|
||||
final Offset normalized;
|
||||
}
|
||||
|
||||
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
|
||||
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
|
||||
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
|
||||
class SpikeEditorPane extends StatefulWidget {
|
||||
const SpikeEditorPane({
|
||||
super.key,
|
||||
required this.pdfPath,
|
||||
this.denseStrokesAsset,
|
||||
this.strokesPerPage = 300,
|
||||
this.strokeCountKey = '2000',
|
||||
this.onViewerReady,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final String pdfPath;
|
||||
final String? denseStrokesAsset;
|
||||
final int strokesPerPage;
|
||||
|
||||
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
|
||||
final String strokeCountKey;
|
||||
|
||||
/// Forwarded from pdfrx once the document is laid out and interactive.
|
||||
final void Function(PdfDocument document, PdfViewerController controller)?
|
||||
onViewerReady;
|
||||
|
||||
/// Optional externally-owned controller (tests drive zoom through this).
|
||||
final PdfViewerController? controller;
|
||||
|
||||
@override
|
||||
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
|
||||
}
|
||||
|
||||
class SpikeEditorPaneState extends State<SpikeEditorPane> {
|
||||
late final PdfViewerController _controller =
|
||||
widget.controller ?? PdfViewerController();
|
||||
|
||||
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
|
||||
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
|
||||
List<_PenSample>? _activeStroke;
|
||||
|
||||
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
|
||||
/// list of normalized polylines (one stroke = list of points).
|
||||
List<List<Offset>>? _syntheticStrokes;
|
||||
bool _inkLoadEnabled = false;
|
||||
bool _loadingSynthetic = false;
|
||||
|
||||
bool get inkLoadEnabled => _inkLoadEnabled;
|
||||
|
||||
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
|
||||
/// enable. Public so the perf bench can drive it programmatically.
|
||||
Future<void> setInkLoad(bool enabled) async {
|
||||
if (enabled && _syntheticStrokes == null) {
|
||||
await _loadSyntheticStrokes();
|
||||
}
|
||||
if (mounted) setState(() => _inkLoadEnabled = enabled);
|
||||
}
|
||||
|
||||
Future<void> _loadSyntheticStrokes() async {
|
||||
final asset = widget.denseStrokesAsset;
|
||||
if (asset == null || _loadingSynthetic) return;
|
||||
_loadingSynthetic = true;
|
||||
try {
|
||||
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
|
||||
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
|
||||
final raw = await File(asset).readAsString();
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final strokesJson =
|
||||
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
|
||||
final result = <List<Offset>>[];
|
||||
for (final s in strokesJson) {
|
||||
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
|
||||
final poly = <Offset>[];
|
||||
for (final p in points) {
|
||||
final pt = p as Map<String, dynamic>;
|
||||
poly.add(Offset(
|
||||
(pt['x'] as num).toDouble(),
|
||||
(pt['y'] as num).toDouble(),
|
||||
));
|
||||
}
|
||||
if (poly.length >= 2) result.add(poly);
|
||||
}
|
||||
_syntheticStrokes = result;
|
||||
} finally {
|
||||
_loadingSynthetic = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pen capture (viewer-level) ---------------------------------------
|
||||
|
||||
void _onPenEvent(PointerEvent event) {
|
||||
// Convert global → document → which page + normalized page coords.
|
||||
final doc = _controller.globalToDocument(event.position);
|
||||
if (doc == null) return;
|
||||
final hit = _documentToPage(doc);
|
||||
if (hit == null) return;
|
||||
|
||||
if (event is PointerDownEvent) {
|
||||
_activeStroke = <_PenSample>[hit];
|
||||
_penStrokes.add(_activeStroke!);
|
||||
setState(() {});
|
||||
} else if (event is PointerMoveEvent) {
|
||||
_activeStroke?.add(hit);
|
||||
setState(() {});
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_activeStroke = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
|
||||
/// controller's page layout rects (document coordinates). Returns null if the
|
||||
/// point is outside every page box.
|
||||
_PenSample? _documentToPage(Offset doc) {
|
||||
if (!_controller.isReady) return null;
|
||||
final rects = _controller.layout.pageLayouts;
|
||||
for (var i = 0; i < rects.length; i++) {
|
||||
final r = rects[i];
|
||||
if (r.contains(doc)) {
|
||||
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
|
||||
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
|
||||
return _PenSample(i, Offset(nx, ny));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
PdfViewer.file(
|
||||
widget.pdfPath,
|
||||
controller: _controller,
|
||||
params: PdfViewerParams(
|
||||
onViewerReady: widget.onViewerReady,
|
||||
// (1) Per-page overlay: diagnostic center marker + optional synthetic
|
||||
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
|
||||
// normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
||||
final pageIndex = page.pageNumber - 1;
|
||||
return [
|
||||
SizedBox.fromSize(
|
||||
size: pageRectInViewer.size,
|
||||
child: CustomPaint(
|
||||
painter: _SpikeInkPainter(
|
||||
synthetic:
|
||||
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
|
||||
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
|
||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
||||
return [
|
||||
Positioned.fill(
|
||||
child: PenCaptureRegion(
|
||||
onPenEvent: _onPenEvent,
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
size: size,
|
||||
painter: _LivePenPainter(
|
||||
strokes: _penStrokes,
|
||||
controller: _controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Deterministic per-page slice of the synthetic stroke pool so each page
|
||||
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
|
||||
List<List<Offset>> _strokesForPage(int pageIndex) {
|
||||
final pool = _syntheticStrokes;
|
||||
if (pool == null || pool.isEmpty) return const [];
|
||||
final n = widget.strokesPerPage.clamp(0, pool.length);
|
||||
final start = (pageIndex * n) % pool.length;
|
||||
final out = <List<Offset>>[];
|
||||
for (var i = 0; i < n; i++) {
|
||||
out.add(pool[(start + i) % pool.length]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
|
||||
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
|
||||
}
|
||||
|
||||
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
|
||||
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
|
||||
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
|
||||
/// inspects.
|
||||
class _SpikeInkPainter extends CustomPainter {
|
||||
_SpikeInkPainter({this.synthetic});
|
||||
|
||||
final List<List<Offset>>? synthetic;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
canvas.save();
|
||||
// Map normalized [0,1] → zoomed pixels (plan §2.1).
|
||||
canvas.scale(size.width, size.height);
|
||||
|
||||
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
|
||||
final syn = synthetic;
|
||||
if (syn != null && syn.isNotEmpty) {
|
||||
final inkPaint = Paint()
|
||||
..color = const Color(0x5500AAFF)
|
||||
..style = PaintingStyle.stroke
|
||||
// Stroke width is in normalized units post-scale; keep it page-relative
|
||||
// and hairline-ish so 300 strokes are visible but cheap.
|
||||
..strokeWidth = 0.002
|
||||
..strokeCap = StrokeCap.round;
|
||||
for (final poly in syn) {
|
||||
if (poly.length < 2) continue;
|
||||
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
|
||||
for (var i = 1; i < poly.length; i++) {
|
||||
path.lineTo(poly[i].dx, poly[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, inkPaint);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
|
||||
// restore) so its line thickness is constant on screen and its CENTER is at
|
||||
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
|
||||
// this pixel.
|
||||
final center = Offset(
|
||||
size.width * kMarkerNormalized.dx,
|
||||
size.height * kMarkerNormalized.dy,
|
||||
);
|
||||
final markerPaint = Paint()
|
||||
..color = const Color(0xFFFF0066)
|
||||
..strokeWidth = 2.0
|
||||
..style = PaintingStyle.stroke;
|
||||
const arm = 16.0;
|
||||
canvas.drawLine(
|
||||
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
|
||||
canvas.drawLine(
|
||||
center.translate(0, -arm), center.translate(0, arm), markerPaint);
|
||||
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
|
||||
oldDelegate.synthetic != synthetic;
|
||||
}
|
||||
|
||||
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
|
||||
/// in normalized page space, so for each sample we re-project page→document→
|
||||
/// local each paint via the controller (keeps strokes glued to pages under
|
||||
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
|
||||
class _LivePenPainter extends CustomPainter {
|
||||
_LivePenPainter({required this.strokes, required this.controller})
|
||||
: super(repaint: controller);
|
||||
|
||||
final List<List<_PenSample>> strokes;
|
||||
final PdfViewerController controller;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (!controller.isReady) return;
|
||||
final rects = controller.layout.pageLayouts;
|
||||
final paint = Paint()
|
||||
..color = const Color(0xFF1565C0)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
for (final stroke in strokes) {
|
||||
Path? path;
|
||||
for (final s in stroke) {
|
||||
if (s.pageIndex >= rects.length) continue;
|
||||
final r = rects[s.pageIndex];
|
||||
// normalized page → document
|
||||
final docPt = Offset(
|
||||
r.left + s.normalized.dx * r.width,
|
||||
r.top + s.normalized.dy * r.height,
|
||||
);
|
||||
// document → local (viewer) coords
|
||||
final local = controller.documentToLocal(docPt);
|
||||
if (path == null) {
|
||||
path = Path()..moveTo(local.dx, local.dy);
|
||||
} else {
|
||||
path.lineTo(local.dx, local.dy);
|
||||
}
|
||||
}
|
||||
if (path != null) canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// lib/editor/pdf/spike_launcher.dart
|
||||
//
|
||||
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
|
||||
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
|
||||
// real Surface Pen with the user's OWN large PDFs). Remove together with the
|
||||
// rest of lib/editor/pdf/spike_* once M1 is signed off.
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../canvas/pen_editor_screen.dart';
|
||||
|
||||
/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor.
|
||||
///
|
||||
/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which
|
||||
/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old
|
||||
/// spike_* files are left in place but no longer wired to this entry.
|
||||
Future<void> openM1Spike(BuildContext context) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
final path = result?.files.single.path;
|
||||
if (path == null) return;
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenEditorScreen(pdfPath: path),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
|
||||
//
|
||||
// Launch on the Windows tablet (or any desktop with a display):
|
||||
// flutter run -t lib/editor/pdf/spike_main.dart
|
||||
//
|
||||
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
|
||||
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
|
||||
// observable on-device.
|
||||
//
|
||||
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
|
||||
// below before pdfrx init). pdfrx itself is initialized via
|
||||
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'pen_capture_region.dart';
|
||||
import 'spike_app.dart';
|
||||
|
||||
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
|
||||
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
|
||||
|
||||
/// Filesystem path for the synthetic ink load (regenerate via
|
||||
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
|
||||
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
|
||||
|
||||
void main(List<String> args) {
|
||||
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
|
||||
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
|
||||
pdfrxFlutterInitialize();
|
||||
|
||||
// Allow overriding the PDF path as the first CLI arg (otherwise the default
|
||||
// 300-page bench asset relative to the project root / cwd).
|
||||
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
|
||||
|
||||
runApp(
|
||||
SpikeApp(
|
||||
pdfPath: pdfPath,
|
||||
denseStrokesAsset: _kDenseStrokesAsset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
|
||||
/// the relative asset path works on desktop; we also try a couple of fallbacks.
|
||||
String _resolvePdfPath() {
|
||||
final candidates = <String>[
|
||||
_kDefaultPdfRelPath,
|
||||
'${Directory.current.path}/$_kDefaultPdfRelPath',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (File(c).existsSync()) return c;
|
||||
}
|
||||
// Return the primary path anyway; pdfrx will surface a clear load error.
|
||||
return _kDefaultPdfRelPath;
|
||||
}
|
||||
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
58
lib/editor/persistence/sidecar_flush_observer.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
// lib/editor/persistence/sidecar_flush_observer.dart
|
||||
//
|
||||
// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file-
|
||||
// based-storage.md): app-lifecycle flush hardening.
|
||||
//
|
||||
// The per-file SidecarRepository debounces writes by 800 ms. That window is the
|
||||
// data-loss gap on a Windows tablet: if the OS suspends or closes the app
|
||||
// before the timer fires, the last strokes never reach disk. This observer
|
||||
// listens for the app leaving the foreground and DRAINS every open repo's
|
||||
// pending write before the process can be frozen, so "never lose the last
|
||||
// strokes on app close" holds even when the editor's own dispose() doesn't run.
|
||||
//
|
||||
// Registered once in BadNoteApp; it delegates to
|
||||
// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush().
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'sidecar_repository.dart';
|
||||
|
||||
/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when
|
||||
/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`).
|
||||
class SidecarFlushObserver with WidgetsBindingObserver {
|
||||
/// Whether the observer is currently registered with the binding.
|
||||
bool get isAttached => _attached;
|
||||
bool _attached = false;
|
||||
|
||||
/// Register with [WidgetsBinding.instance] so lifecycle changes are observed.
|
||||
void attach() {
|
||||
if (_attached) return;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_attached = true;
|
||||
}
|
||||
|
||||
/// Stop observing lifecycle changes.
|
||||
void detach() {
|
||||
if (!_attached) return;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_attached = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
// Any transition out of the foreground is a potential suspend/kill point:
|
||||
// drain pending sidecar writes now (the editors' own dispose() may never
|
||||
// run when the OS freezes the process).
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
// Fire-and-forget at the framework boundary, but each write is atomic
|
||||
// and awaited inside flushAll, so a half-written sidecar is impossible.
|
||||
SidecarRepositoryRegistry.flushAll();
|
||||
case AppLifecycleState.resumed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
440
lib/editor/persistence/sidecar_repository.dart
Normal file
440
lib/editor/persistence/sidecar_repository.dart
Normal file
@@ -0,0 +1,440 @@
|
||||
// lib/editor/persistence/sidecar_repository.dart
|
||||
//
|
||||
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
|
||||
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
|
||||
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
|
||||
// source file, so annotations travel with the file ("跟着文件走").
|
||||
//
|
||||
// Design:
|
||||
// * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
|
||||
// mutate it through the schedule* methods, which (1) update the in-memory
|
||||
// model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
|
||||
// mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
|
||||
// debounce timer that writes the WHOLE sidecar atomically (§F.1, via
|
||||
// SidecarStore.writeAtomic — temp + rename + .bak).
|
||||
// * The unit of debounce is the whole document sidecar (sidecars are small —
|
||||
// sparse normalized strokes), one atomic write per debounce window.
|
||||
// * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
|
||||
// The sidecar IS the identity.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../models/bookmark.dart';
|
||||
import '../../models/scratch_link.dart';
|
||||
import '../../storage/badnote_sidecar.dart';
|
||||
import '../../storage/sidecar_store.dart';
|
||||
import '../engine/stroke_model.dart';
|
||||
|
||||
/// Suffix appended to a source-file path to form its sidecar path.
|
||||
const String kSidecarSuffix = '.badnote.json';
|
||||
|
||||
/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
|
||||
///
|
||||
/// The 800 ms debounce timer only protects against losing work to a crash that
|
||||
/// happens *between* edits; it does NOT help when the OS suspends or kills the
|
||||
/// app mid-window (the main data-loss window on a Windows tablet). The app's
|
||||
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
|
||||
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
|
||||
/// before the process can be frozen.
|
||||
///
|
||||
/// A repo registers itself in [open] and removes itself in [dispose], so the
|
||||
/// set always reflects exactly the editors holding unsaved sidecar state.
|
||||
class SidecarRepositoryRegistry {
|
||||
SidecarRepositoryRegistry._();
|
||||
|
||||
static final Set<SidecarRepository> _open = <SidecarRepository>{};
|
||||
|
||||
/// The currently open repositories (for tests / inspection).
|
||||
static Set<SidecarRepository> get open => Set.unmodifiable(_open);
|
||||
|
||||
/// Flush every open repository's pending debounced write and await them all.
|
||||
/// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
|
||||
static Future<void> flushAll() async {
|
||||
// Snapshot first: a flush may complete and (in a future) trigger disposal,
|
||||
// which mutates `_open` — iterating a copy avoids concurrent-modification.
|
||||
final repos = List<SidecarRepository>.of(_open);
|
||||
await Future.wait(repos.map((r) => r.flush()));
|
||||
}
|
||||
|
||||
/// The open repository for [sourceFilePath], or null if none is open. Lets a
|
||||
/// background task (e.g. OCR) write through the SAME in-memory sidecar the
|
||||
/// editor holds, instead of racing it with a second open handle.
|
||||
static SidecarRepository? forPath(String sourceFilePath) {
|
||||
for (final r in _open) {
|
||||
if (r.sourceFilePath == sourceFilePath) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static void _register(SidecarRepository repo) => _open.add(repo);
|
||||
|
||||
static void _unregister(SidecarRepository repo) => _open.remove(repo);
|
||||
|
||||
/// Test-only: drop all registrations so one test can't leak repos into the
|
||||
/// next. Does NOT flush or dispose them.
|
||||
static void resetForTest() => _open.clear();
|
||||
}
|
||||
|
||||
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
|
||||
/// path, holds it in memory, and debounces atomic writes back to disk.
|
||||
class SidecarRepository {
|
||||
SidecarRepository._({
|
||||
required this.sourceFilePath,
|
||||
required BadnoteSidecar sidecar,
|
||||
Duration debounce = const Duration(milliseconds: 800),
|
||||
}) : _sidecar = sidecar,
|
||||
_debounce = debounce;
|
||||
|
||||
/// Absolute path to the annotated source file (e.g. the vault PDF copy).
|
||||
final String sourceFilePath;
|
||||
|
||||
/// The sidecar file: `<sourceFilePath>.badnote.json`.
|
||||
File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');
|
||||
|
||||
final Duration _debounce;
|
||||
|
||||
BadnoteSidecar _sidecar;
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
|
||||
/// How many editors currently hold this repo. [open] reuses an existing
|
||||
/// instance and bumps the count; [dispose] only tears down at zero so a
|
||||
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
|
||||
int _retainCount = 1;
|
||||
|
||||
/// Tail of the in-flight write chain. Writes are serialized through this so a
|
||||
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
|
||||
/// same `.tmp`/rename (which would throw on the loser). Each write always
|
||||
/// persists the LATEST snapshot, so collapsing overlapping writes is safe.
|
||||
Future<void> _writeChain = Future<void>.value();
|
||||
|
||||
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
|
||||
/// sidecar if present (falling back to its `.bak`), else starts empty.
|
||||
///
|
||||
/// Reuses an already-open repo for the same path (retain-counted) so a
|
||||
/// scratchpad overlay / split view cannot race the PDF editor with a second
|
||||
/// in-memory snapshot that would overwrite scratchpad ink on flush.
|
||||
static Future<SidecarRepository> open(
|
||||
String sourceFilePath, {
|
||||
String? docType,
|
||||
Duration debounce = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
|
||||
if (existing != null && !existing._disposed) {
|
||||
existing._retainCount++;
|
||||
return existing;
|
||||
}
|
||||
final file = File('$sourceFilePath$kSidecarSuffix');
|
||||
final loaded = await SidecarStore.read(file);
|
||||
var sidecar = loaded ??
|
||||
BadnoteSidecar(
|
||||
sourceFile: _basename(sourceFilePath),
|
||||
docType: docType,
|
||||
pageCount: docType == 'notebook' ? 1 : null,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
// Standalone notebooks always carry an explicit pageCount (min 1). Older
|
||||
// sidecars that omit it are normalized in-memory on open.
|
||||
if (docType == 'notebook' &&
|
||||
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
|
||||
sidecar = BadnoteSidecar(
|
||||
version: sidecar.version,
|
||||
sourceFile: sidecar.sourceFile,
|
||||
docType: sidecar.docType,
|
||||
title: sidecar.title,
|
||||
pageCount: 1,
|
||||
rotation: sidecar.rotation,
|
||||
createdAt: sidecar.createdAt,
|
||||
updatedAt: sidecar.updatedAt,
|
||||
strokes: sidecar.strokes,
|
||||
highlights: sidecar.highlights,
|
||||
texts: sidecar.texts,
|
||||
bookmarks: sidecar.bookmarks,
|
||||
scratchLinks: sidecar.scratchLinks,
|
||||
legacyAnnotations: sidecar.legacyAnnotations,
|
||||
ocrText: sidecar.ocrText,
|
||||
pageText: sidecar.pageText,
|
||||
legacyId: sidecar.legacyId,
|
||||
background: sidecar.background,
|
||||
);
|
||||
}
|
||||
final repo = SidecarRepository._(
|
||||
sourceFilePath: sourceFilePath,
|
||||
sidecar: sidecar,
|
||||
debounce: debounce,
|
||||
);
|
||||
SidecarRepositoryRegistry._register(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
|
||||
|
||||
/// Page index → committed strokes loaded from the sidecar.
|
||||
Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
|
||||
|
||||
/// Page index → highlight rects loaded from the sidecar.
|
||||
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
|
||||
|
||||
/// Page index → typed-text annotations loaded from the sidecar.
|
||||
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
|
||||
|
||||
/// Scratch-link anchors loaded from the sidecar.
|
||||
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
||||
|
||||
/// Bookmarks loaded from the sidecar.
|
||||
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
|
||||
|
||||
/// The current in-memory sidecar (for tests / inspection).
|
||||
BadnoteSidecar get sidecar => _sidecar;
|
||||
|
||||
/// The standalone-notebook title loaded from the sidecar, or null.
|
||||
String? get loadedTitle => _sidecar.title;
|
||||
|
||||
/// The page-background template name loaded from the sidecar, or null
|
||||
/// (missing → blank, decoded by the editor).
|
||||
String? get loadedBackground => _sidecar.background;
|
||||
|
||||
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
|
||||
|
||||
/// Replace the standalone-notebook title and schedule a save. No-op if the
|
||||
/// title is unchanged.
|
||||
void scheduleTitleSave(String title) {
|
||||
if (_sidecar.title == title) return;
|
||||
_replace(title: title);
|
||||
}
|
||||
|
||||
/// Replace the page-background template (a [NoteBackground] enum name) and
|
||||
/// schedule a save. No-op if unchanged.
|
||||
void scheduleBackgroundSave(String background) {
|
||||
if (_sidecar.background == background) return;
|
||||
_replace(background: background);
|
||||
}
|
||||
|
||||
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
|
||||
/// search index). No-op if unchanged.
|
||||
void scheduleOcrTextSave(String? ocrText) {
|
||||
final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
|
||||
if (_sidecar.ocrText == next) return;
|
||||
_replace(ocrText: next, clearOcrText: next == null);
|
||||
}
|
||||
|
||||
/// The OCR text loaded from the sidecar, or null.
|
||||
String? get loadedOcrText => _sidecar.ocrText;
|
||||
|
||||
/// Replace the document-body search text (PDF embedded text layer, or
|
||||
/// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
|
||||
/// save. No-op if unchanged. An empty string is normalized to null.
|
||||
void schedulePageTextSave(String? pageText) {
|
||||
final next = (pageText != null && pageText.isEmpty) ? null : pageText;
|
||||
if (_sidecar.pageText == next) return;
|
||||
_replace(pageText: next, clearPageText: next == null);
|
||||
}
|
||||
|
||||
/// The document-body search text loaded from the sidecar, or null.
|
||||
String? get loadedPageText => _sidecar.pageText;
|
||||
|
||||
/// Replace the committed strokes for [pageIndex] and schedule a save.
|
||||
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
|
||||
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
|
||||
if (strokes.isEmpty) {
|
||||
next.remove(pageIndex);
|
||||
} else {
|
||||
next[pageIndex] = List<EditorStroke>.of(strokes);
|
||||
}
|
||||
_replace(strokes: next);
|
||||
}
|
||||
|
||||
/// Replace the standalone-notebook page count and schedule a save. No-op if
|
||||
/// unchanged. [count] is clamped to at least 1.
|
||||
void schedulePageCountSave(int count) {
|
||||
final next = count < 1 ? 1 : count;
|
||||
if (_sidecar.pageCount == next) return;
|
||||
_replace(pageCount: next);
|
||||
}
|
||||
|
||||
/// Replace the highlight rects for [pageIndex] and schedule a save.
|
||||
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
||||
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
|
||||
if (highlights.isEmpty) {
|
||||
next.remove(pageIndex);
|
||||
} else {
|
||||
next[pageIndex] = List<SidecarHighlight>.of(highlights);
|
||||
}
|
||||
_replace(highlights: next);
|
||||
}
|
||||
|
||||
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
|
||||
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
|
||||
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
|
||||
if (texts.isEmpty) {
|
||||
next.remove(pageIndex);
|
||||
} else {
|
||||
next[pageIndex] = List<SidecarText>.of(texts);
|
||||
}
|
||||
_replace(texts: next);
|
||||
}
|
||||
|
||||
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
|
||||
/// and schedule a save.
|
||||
void scheduleScratchLinkUpsert(ScratchLink link) {
|
||||
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||
final idx = next.indexWhere((s) => s.link.id == link.id);
|
||||
if (idx == -1) {
|
||||
next.add(SidecarScratchLink(link: link));
|
||||
} else {
|
||||
next[idx] = SidecarScratchLink(
|
||||
link: link,
|
||||
scratchpad: next[idx].scratchpad,
|
||||
);
|
||||
}
|
||||
_replace(scratchLinks: next);
|
||||
}
|
||||
|
||||
/// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
|
||||
void scheduleScratchLinkDelete(String linkId) {
|
||||
final next = _sidecar.scratchLinks
|
||||
.where((s) => s.link.id != linkId)
|
||||
.toList(growable: false);
|
||||
_replace(scratchLinks: List<SidecarScratchLink>.of(next));
|
||||
}
|
||||
|
||||
/// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
|
||||
/// No-op if the anchor isn't present.
|
||||
void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
|
||||
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
|
||||
final idx = next.indexWhere((s) => s.link.id == linkId);
|
||||
if (idx == -1) return;
|
||||
next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
|
||||
_replace(scratchLinks: next);
|
||||
}
|
||||
|
||||
/// Add (or update, by id) a bookmark and schedule a save.
|
||||
void scheduleBookmarkUpsert(Bookmark bookmark) {
|
||||
final next = List<Bookmark>.of(_sidecar.bookmarks);
|
||||
final idx = next.indexWhere((b) => b.id == bookmark.id);
|
||||
if (idx == -1) {
|
||||
next.add(bookmark);
|
||||
} else {
|
||||
next[idx] = bookmark;
|
||||
}
|
||||
_replace(bookmarks: next);
|
||||
}
|
||||
|
||||
/// Remove the bookmark by [bookmarkId] and schedule a save.
|
||||
void scheduleBookmarkDelete(String bookmarkId) {
|
||||
final next =
|
||||
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
|
||||
_replace(bookmarks: next);
|
||||
}
|
||||
|
||||
/// Replace the whole bookmark list and schedule a save.
|
||||
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
|
||||
_replace(bookmarks: List<Bookmark>.of(bookmarks));
|
||||
}
|
||||
|
||||
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
|
||||
SidecarScratchpad? scratchpadFor(String linkId) {
|
||||
for (final s in _sidecar.scratchLinks) {
|
||||
if (s.link.id == linkId) return s.scratchpad;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Flush / dispose ────────────────────────────────────────────────────────
|
||||
|
||||
/// Write any pending change immediately and wait for it (and any in-flight
|
||||
/// write) to land. If the debounce timer is still armed, fire one final write
|
||||
/// of the latest snapshot; otherwise just drain whatever write is in flight.
|
||||
Future<void> flush() async {
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
_timer = null;
|
||||
await _write();
|
||||
return;
|
||||
}
|
||||
// No pending edit, but a fire-and-forget timer write may still be running:
|
||||
// await the chain so the bytes are on disk before we return.
|
||||
await _writeChain;
|
||||
}
|
||||
|
||||
/// Cancel pending timers. Call [flush] first to persist pending writes.
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
if (_retainCount > 1) {
|
||||
_retainCount--;
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
SidecarRepositoryRegistry._unregister(this);
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a new sidecar (touching `updatedAt`) from the current one with the
|
||||
/// given fields replaced, then arm the debounce timer. Snapshot is captured
|
||||
/// synchronously here so a later edit can't corrupt an in-flight write.
|
||||
void _replace({
|
||||
String? title,
|
||||
int? pageCount,
|
||||
Map<int, List<EditorStroke>>? strokes,
|
||||
Map<int, List<SidecarHighlight>>? highlights,
|
||||
Map<int, List<SidecarText>>? texts,
|
||||
List<Bookmark>? bookmarks,
|
||||
List<SidecarScratchLink>? scratchLinks,
|
||||
String? ocrText,
|
||||
bool clearOcrText = false,
|
||||
String? pageText,
|
||||
bool clearPageText = false,
|
||||
String? background,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
_sidecar = BadnoteSidecar(
|
||||
version: _sidecar.version,
|
||||
sourceFile: _sidecar.sourceFile,
|
||||
docType: _sidecar.docType,
|
||||
title: title ?? _sidecar.title,
|
||||
pageCount: pageCount ?? _sidecar.pageCount,
|
||||
rotation: _sidecar.rotation,
|
||||
createdAt: _sidecar.createdAt,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
strokes: strokes ?? _sidecar.strokes,
|
||||
highlights: highlights ?? _sidecar.highlights,
|
||||
texts: texts ?? _sidecar.texts,
|
||||
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
||||
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
|
||||
background: background ?? _sidecar.background,
|
||||
);
|
||||
_timer?.cancel();
|
||||
_timer = Timer(_debounce, () {
|
||||
_timer = null;
|
||||
// Fire-and-forget; the next schedule simply re-arms the timer and the
|
||||
// atomic write guarantees no torn file.
|
||||
_write();
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize writes through [_writeChain] so overlapping flushes never race
|
||||
/// on the temp file. Each link writes the latest in-memory snapshot at the
|
||||
/// moment it runs; an error in one write doesn't break the chain for the next.
|
||||
Future<void> _write() {
|
||||
final next = _writeChain.then((_) async {
|
||||
final snapshot = _sidecar;
|
||||
await SidecarStore.writeAtomic(sidecarFile, snapshot);
|
||||
});
|
||||
// Keep the chain alive past a failed write (e.g. transient FS error).
|
||||
_writeChain = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final norm = path.replaceAll('\\', '/');
|
||||
final i = norm.lastIndexOf('/');
|
||||
return i == -1 ? norm : norm.substring(i + 1);
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,7 @@ class LiveInkPainter extends CustomPainter {
|
||||
isComplete: false, thinning: thinning);
|
||||
if (path.getBounds().isEmpty) return;
|
||||
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Color(stroke.color)
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true,
|
||||
);
|
||||
canvas.drawPath(path, paintForEditorStroke(stroke));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -58,13 +58,10 @@ class StaticInkPainter extends CustomPainter {
|
||||
final path = buildStrokeOutline(stroke, pageSize,
|
||||
isComplete: true, thinning: thinning);
|
||||
if (path.getBounds().isEmpty) continue;
|
||||
rec.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = Color(stroke.color)
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true,
|
||||
);
|
||||
// One drawPath per stroke ⇒ highlighter self-overlap never darkens;
|
||||
// cross-stroke overlap darkens via BlendMode.multiply (closes
|
||||
// TODO(brush-opacity); shared resolver with the live + PenCanvas paths).
|
||||
rec.drawPath(path, paintForEditorStroke(stroke));
|
||||
}
|
||||
return recorder.endRecording();
|
||||
});
|
||||
|
||||
10
lib/editor/stroke.dart
Normal file
10
lib/editor/stroke.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
// Canonical stroke model surface.
|
||||
//
|
||||
// Historical baggage had three parallel types (PenStroke / EditorStroke /
|
||||
// InkStroke). New code MUST import from this barrel and prefer [EditorStroke]
|
||||
// for engine/storage. UI adapters convert at the edge.
|
||||
//
|
||||
// Do not add a fourth model.
|
||||
|
||||
export '../engine/stroke_model.dart' show EditorStroke, EditorPoint, EditorTool;
|
||||
export '../canvas/pen_stroke.dart' show PenStroke, PenPoint, PenStrokeKind;
|
||||
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
// lib/editor/ui/page_nav_shortcuts.dart
|
||||
//
|
||||
// Shared keyboard page navigation for PDF / slide / office editors.
|
||||
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class PreviousPageIntent extends Intent {
|
||||
const PreviousPageIntent();
|
||||
}
|
||||
|
||||
class NextPageIntent extends Intent {
|
||||
const NextPageIntent();
|
||||
}
|
||||
|
||||
class FirstPageIntent extends Intent {
|
||||
const FirstPageIntent();
|
||||
}
|
||||
|
||||
class LastPageIntent extends Intent {
|
||||
const LastPageIntent();
|
||||
}
|
||||
|
||||
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
|
||||
Widget pageNavShortcuts({
|
||||
required Widget child,
|
||||
required VoidCallback? onPrevious,
|
||||
required VoidCallback? onNext,
|
||||
VoidCallback? onFirst,
|
||||
VoidCallback? onLast,
|
||||
}) {
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
child: CallbackShortcuts(
|
||||
bindings: <ShortcutActivator, VoidCallback>{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||||
onNext?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||
onNext?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
|
||||
onNext?.call(),
|
||||
if (onFirst != null)
|
||||
const SingleActivator(LogicalKeyboardKey.home): onFirst,
|
||||
if (onLast != null)
|
||||
const SingleActivator(LogicalKeyboardKey.end): onLast,
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../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.
|
||||
///
|
||||
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
|
||||
formatValue: (v) => v.toStringAsFixed(2),
|
||||
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 ─────────────────────────────────────────────────
|
||||
_SectionHeader(
|
||||
@@ -161,6 +206,31 @@ class _PenSettingsSheet extends StatelessWidget {
|
||||
formatValue: (v) => v.toStringAsFixed(4),
|
||||
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;
|
||||
|
||||
static String _label(PenButtonAction action) => switch (action) {
|
||||
PenButtonAction.none => 'None',
|
||||
PenButtonAction.eraser => 'Eraser',
|
||||
PenButtonAction.undo => 'Undo',
|
||||
PenButtonAction.toggleTool => 'Toggle Tool',
|
||||
PenButtonAction.pan => 'Pan',
|
||||
PenButtonAction.none => '无',
|
||||
PenButtonAction.eraser => '橡皮',
|
||||
PenButtonAction.undo => '撤销',
|
||||
PenButtonAction.toggleTool => '切换工具',
|
||||
PenButtonAction.pan => '平移',
|
||||
PenButtonAction.select => '选择(笔迹)',
|
||||
PenButtonAction.selectText => '选择文本',
|
||||
};
|
||||
|
||||
@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:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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 'screens/home_screen.dart';
|
||||
import 'screens/app_shell.dart';
|
||||
import 'screens/vault_setup_screen.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 {
|
||||
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
||||
// binding before runApp so the M1 spike's PenCaptureRegion can gate
|
||||
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
|
||||
// region mounted it behaves exactly like the default binding.
|
||||
// Kind-gated PDF pen capture MUST install before runApp — without it
|
||||
// PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
|
||||
PenCaptureBinding.ensureInitialized();
|
||||
// pdfrx native engine init (required before any PdfViewer is built).
|
||||
pdfrxFlutterInitialize();
|
||||
@@ -25,14 +31,40 @@ Future<void> main() async {
|
||||
// Initialize SharedPreferences
|
||||
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()));
|
||||
}
|
||||
|
||||
class BadNoteApp extends ConsumerWidget {
|
||||
class BadNoteApp extends ConsumerStatefulWidget {
|
||||
const BadNoteApp({super.key});
|
||||
|
||||
@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);
|
||||
|
||||
// Material You: prefer the OS dynamic color (Windows/Android system accent);
|
||||
@@ -52,19 +84,138 @@ class BadNoteApp extends ConsumerWidget {
|
||||
return MaterialApp(
|
||||
title: 'BadNote',
|
||||
themeMode: settings.themeMode,
|
||||
theme: _theme(lightScheme),
|
||||
darkTheme: _theme(darkScheme),
|
||||
home: const HomeScreen(),
|
||||
theme: AppTheme.fromScheme(lightScheme),
|
||||
darkTheme: AppTheme.fromScheme(darkScheme),
|
||||
// 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(
|
||||
colorScheme: scheme,
|
||||
useMaterial3: true,
|
||||
textTheme: GoogleFonts.interTextTheme(
|
||||
ThemeData(brightness: scheme.brightness).textTheme,
|
||||
/// Startup gate: shows [HomeScreen] only once a valid vault root folder has been
|
||||
/// chosen. If none is set — or the saved folder no longer exists — it shows
|
||||
/// [VaultSetupScreen] first (re-prompting on a missing folder rather than
|
||||
/// silently scattering data elsewhere). Phase 0: records the vault path only;
|
||||
/// 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.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
|
||||
abstract class Bookmark with _$Bookmark {
|
||||
const factory Bookmark({
|
||||
required String id,
|
||||
required String documentId,
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
required int pageNumber,
|
||||
@Default('') String label,
|
||||
@Default(0xFF2196F3) int color,
|
||||
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;
|
||||
|
||||
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
|
||||
mixin _$Bookmark {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
String get documentId => throw _privateConstructorUsedError;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
int get pageNumber => throw _privateConstructorUsedError;
|
||||
String get label => throw _privateConstructorUsedError;
|
||||
int get color => 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.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
|
||||
String label,
|
||||
int color,
|
||||
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? color = null,
|
||||
Object? createdAt = null,
|
||||
Object? anchorLeft = freezed,
|
||||
Object? anchorTop = freezed,
|
||||
Object? anchorRight = freezed,
|
||||
Object? anchorBottom = freezed,
|
||||
Object? charIndex = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
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,
|
||||
);
|
||||
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
|
||||
String label,
|
||||
int color,
|
||||
DateTime createdAt,
|
||||
double? anchorLeft,
|
||||
double? anchorTop,
|
||||
double? anchorRight,
|
||||
double? anchorBottom,
|
||||
int? charIndex,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
||||
Object? label = null,
|
||||
Object? color = null,
|
||||
Object? createdAt = null,
|
||||
Object? anchorLeft = freezed,
|
||||
Object? anchorTop = freezed,
|
||||
Object? anchorRight = freezed,
|
||||
Object? anchorBottom = freezed,
|
||||
Object? charIndex = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$BookmarkImpl(
|
||||
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
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.color = 0xFF2196F3,
|
||||
required this.createdAt,
|
||||
this.anchorLeft,
|
||||
this.anchorTop,
|
||||
this.anchorRight,
|
||||
this.anchorBottom,
|
||||
this.charIndex,
|
||||
});
|
||||
|
||||
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
final String id;
|
||||
@override
|
||||
final String documentId;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
@override
|
||||
final int pageNumber;
|
||||
@override
|
||||
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
@override
|
||||
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
|
||||
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
|
||||
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
(identical(other.label, label) || other.label == label) &&
|
||||
(identical(other.color, color) || other.color == color) &&
|
||||
(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)
|
||||
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
|
||||
label,
|
||||
color,
|
||||
createdAt,
|
||||
anchorLeft,
|
||||
anchorTop,
|
||||
anchorRight,
|
||||
anchorBottom,
|
||||
charIndex,
|
||||
);
|
||||
|
||||
/// Create a copy of Bookmark
|
||||
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
|
||||
final String label,
|
||||
final int color,
|
||||
required final DateTime createdAt,
|
||||
final double? anchorLeft,
|
||||
final double? anchorTop,
|
||||
final double? anchorRight,
|
||||
final double? anchorBottom,
|
||||
final int? charIndex,
|
||||
}) = _$BookmarkImpl;
|
||||
|
||||
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
||||
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
|
||||
String get id;
|
||||
@override
|
||||
String get documentId;
|
||||
|
||||
/// 1-based page number this bookmark lives on.
|
||||
@override
|
||||
int get pageNumber;
|
||||
@override
|
||||
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
|
||||
@override
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
|
||||
@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
|
||||
label: json['label'] as String? ?? '',
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
||||
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) =>
|
||||
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
||||
'label': instance.label,
|
||||
'color': instance.color,
|
||||
'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:uuid/uuid.dart';
|
||||
|
||||
import '../models/document.dart';
|
||||
import '../services/database_service.dart';
|
||||
import 'note_provider.dart';
|
||||
import '../services/vault_service.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
|
||||
return VaultService.getInstance();
|
||||
});
|
||||
|
||||
final documentListProvider =
|
||||
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
|
||||
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>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
Future<VaultService> get _vault =>
|
||||
ref.read(vaultServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Document>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllDocuments();
|
||||
return _scan();
|
||||
}
|
||||
|
||||
/// Reloads documents from the database and publishes the result to [state]
|
||||
/// so the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<List<Document>> _scan() async {
|
||||
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 {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final db = await _db;
|
||||
return db.getAllDocuments();
|
||||
});
|
||||
}
|
||||
|
||||
Future<Document> addDocument({
|
||||
required String filename,
|
||||
required String docType,
|
||||
required String filePath,
|
||||
int pageCount = 0,
|
||||
}) async {
|
||||
final db = await _db;
|
||||
final now = DateTime.now();
|
||||
final document = Document(
|
||||
id: _uuid.v4(),
|
||||
filename: filename,
|
||||
docType: docType,
|
||||
filePath: filePath,
|
||||
pageCount: pageCount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertDocument(document);
|
||||
state = AsyncData([document, ...state.value ?? []]);
|
||||
return document;
|
||||
state = await AsyncValue.guard(_scan);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final db = await _db;
|
||||
await db.deleteDocument(id);
|
||||
final dir = Directory(id);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
final current = state.value ?? [];
|
||||
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:uuid/uuid.dart';
|
||||
|
||||
import '../models/note.dart';
|
||||
import '../services/database_service.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
|
||||
return DatabaseService.getInstance();
|
||||
});
|
||||
import '../services/vault_service.dart';
|
||||
import 'document_provider.dart' show vaultServiceProvider;
|
||||
|
||||
/// 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>>(
|
||||
NoteListNotifier.new,
|
||||
);
|
||||
|
||||
class NoteListNotifier extends AsyncNotifier<List<Note>> {
|
||||
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
|
||||
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<Note>> build() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
return _scan();
|
||||
}
|
||||
|
||||
/// Reloads notes from the database and publishes the result to [state] so
|
||||
/// the UI rebuilds. Used by pull-to-refresh.
|
||||
Future<List<Note>> _scan() async {
|
||||
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 {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final db = await _db;
|
||||
return db.getAllNotes();
|
||||
});
|
||||
state = await AsyncValue.guard(_scan);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
final db = await _db;
|
||||
final vault = await _vault;
|
||||
final notePath = await vault.createEmptyNotebook(title);
|
||||
final now = DateTime.now();
|
||||
final note = Note(
|
||||
id: _uuid.v4(),
|
||||
id: notePath,
|
||||
title: title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await db.insertNote(note);
|
||||
state = AsyncData([note, ...state.value ?? []]);
|
||||
return note;
|
||||
}
|
||||
|
||||
Future<void> updateNote(Note note) async {
|
||||
final db = await _db;
|
||||
await db.updateNote(note);
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
|
||||
}
|
||||
|
||||
/// Delete a note by removing its notebook folder (the sidecar travels with
|
||||
/// it). [id] is the synthetic note path `<folder>/notebook`.
|
||||
Future<void> deleteNote(String id) async {
|
||||
final db = await _db;
|
||||
await db.deleteNote(id);
|
||||
final folder = Directory(File(id).parent.path);
|
||||
if (await folder.exists()) {
|
||||
await folder.delete(recursive: true);
|
||||
}
|
||||
final current = state.value ?? [];
|
||||
state = AsyncData(current.where((n) => n.id != id).toList());
|
||||
}
|
||||
}
|
||||
|
||||
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
|
||||
final db = await ref.watch(databaseServiceProvider.future);
|
||||
return db.getNoteById(id);
|
||||
});
|
||||
|
||||
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 '../services/ocr_service.dart';
|
||||
import '../services/pdf_text_indexer.dart';
|
||||
import '../services/pdfrx_page_text_source.dart';
|
||||
|
||||
enum OcrStatus { none, processing, done, failed }
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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 '../models/document.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) => '');
|
||||
|
||||
@@ -34,59 +49,45 @@ class DocumentSearchHit extends SearchResult {
|
||||
|
||||
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
|
||||
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
|
||||
// initialization and disposal like every other consumer.
|
||||
final db = await ref.watch(databaseServiceProvider.future);
|
||||
final index = await ref.watch(vaultSearchIndexProvider.future);
|
||||
|
||||
// Run the note and document searches concurrently.
|
||||
final searches = await Future.wait([
|
||||
db.searchNotes(query),
|
||||
db.searchDocuments(query),
|
||||
]);
|
||||
final noteHits = searches[0] as List<Note>;
|
||||
final docHits = searches[1] as List<Map<String, dynamic>>;
|
||||
final hits = await index.search(query);
|
||||
|
||||
final results = <SearchResult>[];
|
||||
|
||||
// Add note results.
|
||||
for (final note in noteHits) {
|
||||
results.add(NoteSearchHit(note: note, snippet: note.title));
|
||||
}
|
||||
|
||||
// Resolve document metadata without an N+1 loop: collect the distinct
|
||||
// document ids referenced by the hits, look each up exactly once, then
|
||||
// build the result list from the cached lookups.
|
||||
final docIds = <String>{
|
||||
for (final hit in docHits)
|
||||
if (hit['document_id'] is String) hit['document_id'] as String,
|
||||
};
|
||||
final docEntries = await Future.wait(
|
||||
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
|
||||
);
|
||||
final docsById = <String, Document>{
|
||||
for (final entry in docEntries)
|
||||
if (entry.value != null) entry.key: entry.value!,
|
||||
};
|
||||
|
||||
for (final hit in docHits) {
|
||||
final documentId = hit['document_id'];
|
||||
if (documentId is! String) continue;
|
||||
final doc = docsById[documentId];
|
||||
if (doc == null) continue;
|
||||
|
||||
final pageNumber = hit['page_number'];
|
||||
final content = hit['content'];
|
||||
results.add(
|
||||
DocumentSearchHit(
|
||||
documentId: documentId,
|
||||
filename: doc.filename,
|
||||
filePath: doc.filePath,
|
||||
pageNumber: pageNumber is int ? pageNumber : 0,
|
||||
snippet: content is String ? content : '',
|
||||
),
|
||||
);
|
||||
for (final hit in hits) {
|
||||
final entry = hit.entry;
|
||||
final snippet = hit.snippet.text;
|
||||
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
|
||||
// lazily by the editor; the search list only needs id/title.
|
||||
final now = DateTime.now();
|
||||
results.add(
|
||||
NoteSearchHit(
|
||||
note: Note(
|
||||
id: entry.openPath,
|
||||
title: entry.title,
|
||||
createdAt: now,
|
||||
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;
|
||||
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/document.dart';
|
||||
import '../models/note.dart';
|
||||
import '../providers/document_provider.dart';
|
||||
import '../providers/note_provider.dart';
|
||||
import '../providers/notebook_container_provider.dart';
|
||||
import '../providers/ocr_provider.dart';
|
||||
import '../editor/pdf/spike_launcher.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../providers/search_provider.dart';
|
||||
import '../editor/canvas/pen_editor_screen.dart';
|
||||
import '../editor/canvas/office_document_screen.dart';
|
||||
import '../services/pptx_service.dart';
|
||||
import 'note_editor_screen.dart';
|
||||
import 'pdf_annotator_screen.dart';
|
||||
import 'ppt_annotator_screen.dart';
|
||||
import '../services/vault_service.dart';
|
||||
import '../editor/canvas/pen_note_screen.dart';
|
||||
import '../editor/canvas/pen_slide_screen.dart';
|
||||
import 'notebook_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'split_view_screen.dart';
|
||||
|
||||
// [M1] Relative date helper — no new package dependencies.
|
||||
String _formatDate(DateTime d) {
|
||||
// Relative date helper — localized.
|
||||
String _formatDate(BuildContext context, DateTime d) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(d);
|
||||
if (diff.inSeconds < 60) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inSeconds < 60) return l.relativeJustNow;
|
||||
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
|
||||
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')}';
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notesAsync = ref.watch(noteListProvider);
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('BadNote'),
|
||||
centerTitle: true,
|
||||
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
|
||||
centerTitle: !embeddedInShell,
|
||||
actions: [
|
||||
if (!embeddedInShell) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: l.settings,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: l.search,
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SearchScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: 'Import PDF',
|
||||
onPressed: () => _importPdf(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.slideshow),
|
||||
tooltip: 'Import PPT',
|
||||
onPressed: () => _importPptx(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search',
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
||||
},
|
||||
),
|
||||
// New pen-first canvas editor (beta).
|
||||
IconButton(
|
||||
icon: const Icon(Icons.draw_outlined),
|
||||
tooltip: 'Pen Canvas (beta)',
|
||||
onPressed: () => openM1Spike(context),
|
||||
icon: const Icon(Icons.file_open),
|
||||
tooltip: l.importFile,
|
||||
onPressed: () => _importFile(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createAndOpenNote(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(l.createNotebook),
|
||||
),
|
||||
body: notesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -86,8 +90,10 @@ class HomeScreen extends ConsumerWidget {
|
||||
data: (notes) {
|
||||
final documentsAsync = ref.watch(documentListProvider);
|
||||
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 RefreshIndicator(
|
||||
@@ -95,19 +101,40 @@ class HomeScreen extends ConsumerWidget {
|
||||
await Future.wait([
|
||||
ref.read(noteListProvider.notifier).loadNotes(),
|
||||
ref.read(documentListProvider.notifier).loadDocuments(),
|
||||
ref
|
||||
.read(notebookContainerListProvider.notifier)
|
||||
.loadContainers(),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Notes section header always shown when documents exist
|
||||
if (containers.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
l.notebooksSection,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
_ContainerTile(container: containers[index]),
|
||||
childCount: containers.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Notes',
|
||||
l.notesSection,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -128,7 +155,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No ink notes yet — tap + to create one',
|
||||
l.noNotesYetHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -139,19 +166,19 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Documents section header always shown when notes exist
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Recent Documents',
|
||||
l.documentsSection,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (documents.isNotEmpty)
|
||||
// continue existing document list below — marker for patch
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) =>
|
||||
@@ -160,7 +187,6 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
// [M2] Per-section empty hint when notes exist but documents don't
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -169,7 +195,7 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No documents yet — import a PDF or PPT',
|
||||
l.noDocumentsYet,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
@@ -189,54 +215,179 @@ class HomeScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// "Create notebook" (OneNote style): prompt a title (defaulting to
|
||||
/// Untitled), create the notebook container FOLDER + `notebook.json` (with
|
||||
/// one blank ink page) via `VaultService.createNotebookContainer`, then open
|
||||
/// [NotebookScreen] on it.
|
||||
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
||||
final note = await ref.read(noteListProvider.notifier).createNote();
|
||||
if (context.mounted) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPdf(BuildContext context) async {
|
||||
final pdfService = PdfService();
|
||||
final filePath = await pdfService.pickPdfFile();
|
||||
if (filePath != null && context.mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPptx(BuildContext context) async {
|
||||
final pptxService = PptxService();
|
||||
final filePath = await pptxService.openPptxFile();
|
||||
if (filePath == null || !context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
|
||||
}
|
||||
|
||||
final slideImages = await pptxService.convertToImages(filePath);
|
||||
final extractedText = await pptxService.extractText(filePath);
|
||||
|
||||
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) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
filePath: filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
builder: (_) => NotebookScreen(
|
||||
folderPath: container.folderPath,
|
||||
title: container.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a notebook title. Returns the entered string (possibly empty →
|
||||
/// caller defaults it), or null if the user cancelled.
|
||||
Future<String?> _promptNotebookTitle(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final controller = TextEditingController(text: l.untitledNote);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l.newNotebookTitle),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: l.notebookTitleHint),
|
||||
onSubmitted: (v) => Navigator.of(ctx).pop(v),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(l.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: Text(l.create),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Single top-level "Import file" action (sibling of "Create notebook"):
|
||||
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
|
||||
/// open the IN-VAULT copy in the right editor (routed by extension).
|
||||
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: VaultService.importableExtensions.toList(),
|
||||
);
|
||||
final picked = result?.files;
|
||||
if (picked == null || picked.isEmpty) return;
|
||||
final pickedPath = picked.first.path;
|
||||
if (pickedPath == null) return;
|
||||
|
||||
final messenger = context.mounted ? ScaffoldMessenger.of(context) : null;
|
||||
messenger?.showSnackBar(SnackBar(content: Text(l.processingImport)));
|
||||
|
||||
try {
|
||||
final vault = await ref.read(vaultServiceProvider.future);
|
||||
final vaultPath = await vault.createNotebook(pickedPath);
|
||||
// Refresh the documents list so the new notebook shows on return.
|
||||
await ref.read(documentListProvider.notifier).loadDocuments();
|
||||
// For a PDF, index its document body (embedded text layer, or background
|
||||
// OCR of a rasterized/scanned PDF) into the sidecar so search covers it.
|
||||
// Fire-and-forget: import returns and opens the editor immediately.
|
||||
_indexPdfInBackground(ref, vaultPath);
|
||||
if (!context.mounted) return;
|
||||
await _openVaultFile(context, ref, vaultPath);
|
||||
} catch (e) {
|
||||
messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off background document-body indexing for an in-vault PDF (no-op for
|
||||
/// other types). Runs detached from the import await chain so the editor opens
|
||||
/// immediately; on completion it bumps the search-index epoch so the newly
|
||||
/// indexed text is searchable. Idempotency and graceful OCR degradation live in
|
||||
/// [PdfTextIndexer]; failures here are swallowed (search just misses the body).
|
||||
void _indexPdfInBackground(WidgetRef ref, String vaultPath) {
|
||||
final ext = p.extension(vaultPath).replaceFirst('.', '').toLowerCase();
|
||||
if (ext != 'pdf') return;
|
||||
final indexer = ref.read(pdfTextIndexerProvider);
|
||||
unawaited(() async {
|
||||
final indexed = await indexer.indexPdf(vaultPath);
|
||||
if (indexed != null && indexed.isNotEmpty) {
|
||||
// Force the next search to re-scan the vault (picks up the new pageText).
|
||||
final epoch = ref.read(searchIndexEpochProvider.notifier);
|
||||
epoch.state = epoch.state + 1;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
/// Route an in-vault [filePath] to the correct editor by extension:
|
||||
/// pdf → [PenEditorScreen]; pptx/docx → native [OfficeDocumentScreen];
|
||||
/// legacy .ppt may still use image fallback.
|
||||
Future<void> _openVaultFile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String filePath,
|
||||
) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenEditorScreen(pdfPath: filePath),
|
||||
),
|
||||
);
|
||||
case 'pptx':
|
||||
case 'docx':
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OfficeDocumentScreen(filePath: filePath),
|
||||
),
|
||||
);
|
||||
case 'ppt':
|
||||
// Legacy binary PPT — try native-ish image path for now.
|
||||
await _openPresentation(context, filePath);
|
||||
default:
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.unsupportedFileType(ext))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openPresentation(
|
||||
BuildContext context,
|
||||
String filePath,
|
||||
) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final pptxService = PptxService();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.processingPresentation)),
|
||||
);
|
||||
}
|
||||
final slideImages = await pptxService.convertToImages(filePath);
|
||||
final extractedText = await pptxService.extractText(filePath);
|
||||
if (!context.mounted) return;
|
||||
if (slideImages.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenSlideScreen(
|
||||
filePath: filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -249,14 +400,14 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'No notes yet',
|
||||
AppLocalizations.of(context).emptyLibraryTitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Create your first note',
|
||||
AppLocalizations.of(context).emptyLibraryBody,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -265,19 +416,13 @@ class HomeScreen extends ConsumerWidget {
|
||||
FilledButton.icon(
|
||||
onPressed: () => _createAndOpenNote(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Note'),
|
||||
label: Text(AppLocalizations.of(context).createNotebook),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPdf(context),
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: const Text('Import PDF'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPptx(context),
|
||||
icon: const Icon(Icons.slideshow),
|
||||
label: const Text('Import PPT'),
|
||||
onPressed: () => _importFile(context, ref),
|
||||
icon: const Icon(Icons.file_open),
|
||||
label: Text(AppLocalizations.of(context).importFile),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -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 {
|
||||
final Note note;
|
||||
const _NoteTile({required this.note});
|
||||
@@ -300,7 +484,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final note = widget.note;
|
||||
// [M1] Use relative date helper
|
||||
final dateStr = _formatDate(note.updatedAt);
|
||||
final dateStr = _formatDate(context, note.updatedAt);
|
||||
|
||||
final ocrStatusMap = ref.watch(ocrStatusProvider);
|
||||
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
|
||||
@@ -332,8 +516,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
|
||||
dateStr,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (note.tags.isNotEmpty)
|
||||
@@ -373,7 +559,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
|
||||
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _confirmDelete(context),
|
||||
@@ -423,7 +609,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
||||
if (!mounted) return;
|
||||
if (result == 'open') {
|
||||
Navigator.of(this.context).push(
|
||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
|
||||
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
|
||||
);
|
||||
} else if (result == 'delete') {
|
||||
_confirmDelete(this.context);
|
||||
@@ -471,7 +657,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
Widget build(BuildContext context) {
|
||||
final document = widget.document;
|
||||
// [M1] Use relative date helper
|
||||
final dateStr = _formatDate(document.updatedAt);
|
||||
final dateStr = _formatDate(context, document.updatedAt);
|
||||
final isPdf = document.docType == 'pdf';
|
||||
|
||||
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
@@ -497,32 +683,18 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
// [H2] Trailing row: split-view (PDF only) + remove
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPdf)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.vertical_split,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Open in Split View',
|
||||
onPressed: () => _openSplitView(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.error
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Remove document',
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
],
|
||||
// [H2] Trailing remove button. Split view is now reached only by
|
||||
// tapping a scratch-link anchor inside the PDF editor, so the
|
||||
// standalone split-view entry was removed.
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: _hovering
|
||||
? Theme.of(context).colorScheme.error
|
||||
: subtleColor.withValues(alpha: 0.4),
|
||||
),
|
||||
tooltip: 'Remove document',
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
// [L2] Routing bug fix: route by docType
|
||||
onTap: () => _openDocument(context),
|
||||
@@ -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 {
|
||||
final document = widget.document;
|
||||
final isPdf = document.docType == 'pdf';
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
if (isPdf) {
|
||||
if (document.docType == 'pdf') {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
|
||||
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// PPT/PPTX: convert to images then push PptAnnotatorScreen
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Processing presentation...')),
|
||||
SnackBar(content: Text(l.processingPresentation)),
|
||||
);
|
||||
}
|
||||
final pptxService = PptxService();
|
||||
@@ -556,13 +740,13 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
if (!mounted) return;
|
||||
if (slideImages.isEmpty) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not open presentation.')),
|
||||
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(this.context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PptAnnotatorScreen(
|
||||
builder: (_) => PenSlideScreen(
|
||||
filePath: document.filePath,
|
||||
slideImagePaths: slideImages,
|
||||
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 {
|
||||
final isPdf = widget.document.docType == 'pdf';
|
||||
final result = await showMenu<String>(
|
||||
context: context,
|
||||
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(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
@@ -636,15 +797,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
if (!mounted) return;
|
||||
if (result == 'open') {
|
||||
_openDocument(this.context);
|
||||
} else if (result == 'split') {
|
||||
_openSplitView(this.context);
|
||||
} else if (result == 'remove') {
|
||||
_confirmDelete(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDocumentMenu(BuildContext context) {
|
||||
final isPdf = widget.document.docType == 'pdf';
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
@@ -652,16 +810,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPdf)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.vertical_split),
|
||||
title: const Text('Open in Split View'),
|
||||
subtitle: const Text('PDF reference + scratchpad'),
|
||||
onTap: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_openSplitView(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text(
|
||||
|
||||
@@ -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_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../editor/canvas/pen_editor_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/note.dart';
|
||||
import '../providers/search_provider.dart';
|
||||
import 'note_editor_screen.dart';
|
||||
import 'pdf_annotator_screen.dart';
|
||||
import '../editor/canvas/pen_note_screen.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
const SearchScreen({super.key});
|
||||
const SearchScreen({super.key, this.embeddedInShell = false});
|
||||
|
||||
final bool embeddedInShell;
|
||||
|
||||
@override
|
||||
ConsumerState<SearchScreen> createState() => _SearchScreenState();
|
||||
@@ -42,16 +45,17 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final results = ref.watch(searchResultsProvider);
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search notes and documents...',
|
||||
decoration: InputDecoration(
|
||||
hintText: l.searchHint,
|
||||
border: InputBorder.none,
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
onChanged: _onQueryChanged,
|
||||
onSubmitted: (value) {
|
||||
@@ -72,14 +76,14 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
body: results.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Search error: $e')),
|
||||
error: (e, _) => Center(child: Text(l.searchError('$e'))),
|
||||
data: (hits) {
|
||||
final query = ref.watch(searchQueryProvider);
|
||||
if (query.isEmpty) {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Text(
|
||||
'Type to search your notes and documents',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
l.typeToSearch,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -95,7 +99,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No results for "$query"',
|
||||
l.noResultsFor(query),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -114,7 +118,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'Notes',
|
||||
l.sectionNotes,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
@@ -129,7 +133,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
'Documents',
|
||||
l.sectionDocuments,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
@@ -177,7 +181,7 @@ class _NoteSearchResultTile extends StatelessWidget {
|
||||
onTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
||||
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -209,7 +213,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Page ${pageNumber + 1}',
|
||||
AppLocalizations.of(context).pageLabel(pageNumber + 1),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (snippet.isNotEmpty)
|
||||
@@ -220,7 +224,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
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_colorpicker/flutter_colorpicker.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/pressure_curve.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';
|
||||
|
||||
/// Material 3 settings screen for BadNote.
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
const SettingsScreen({super.key, this.embeddedInShell = false});
|
||||
|
||||
final bool embeddedInShell;
|
||||
|
||||
void _showColorPicker(
|
||||
BuildContext context,
|
||||
@@ -20,8 +29,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: const Text('Pick a color'),
|
||||
title: Text(l.pickColor),
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
@@ -31,14 +41,14 @@ class SettingsScreen extends ConsumerWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onPicked(pickerColor);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
child: Text(l.ok),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -50,25 +60,23 @@ class SettingsScreen extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Clear all local settings?'),
|
||||
content: const Text(
|
||||
'This will reset pen defaults and appearance settings. '
|
||||
'Notes and documents are not affected.',
|
||||
),
|
||||
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
|
||||
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(AppLocalizations.of(ctx).cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(settingsProvider).clearAllData();
|
||||
Navigator.pop(ctx);
|
||||
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;
|
||||
|
||||
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(
|
||||
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: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Default Tool',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).settingsDefaultTool,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<PenTool>(
|
||||
@@ -112,9 +136,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Default Color',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).settingsDefaultColor,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
@@ -147,9 +171,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Default Stroke Width',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).settingsDefaultWidth,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
Slider(
|
||||
value: settings.defaultStrokeWidth,
|
||||
@@ -160,9 +184,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onChanged: settings.setDefaultStrokeWidth,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Pressure Curve',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).settingsPressureCurve,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonFormField<PressureCurveType>(
|
||||
@@ -209,33 +233,36 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
_SectionHeader(title: 'Appearance', icon: Icons.palette),
|
||||
_SectionHeader(
|
||||
title: AppLocalizations.of(context).settingsAppearance,
|
||||
icon: Icons.palette,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Theme Mode',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).settingsAppearance,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
segments: [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('System'),
|
||||
icon: Icon(Icons.brightness_auto),
|
||||
label: Text(AppLocalizations.of(context).themeSystem),
|
||||
icon: const Icon(Icons.brightness_auto),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Light'),
|
||||
icon: Icon(Icons.light_mode),
|
||||
label: Text(AppLocalizations.of(context).themeLight),
|
||||
icon: const Icon(Icons.light_mode),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Dark'),
|
||||
icon: Icon(Icons.dark_mode),
|
||||
label: Text(AppLocalizations.of(context).themeDark),
|
||||
icon: const Icon(Icons.dark_mode),
|
||||
),
|
||||
],
|
||||
selected: {settings.themeMode},
|
||||
@@ -244,9 +271,9 @@ class SettingsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Color Scheme Seed',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
Text(
|
||||
AppLocalizations.of(context).seedColorDesc,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
@@ -272,14 +299,44 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Seed color for Material 3 theme'),
|
||||
Text(AppLocalizations.of(context).seedColorDesc),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
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 {
|
||||
final String title;
|
||||
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:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/undo_manager.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/annotation_toolbar.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
import '../storage/badnote_sidecar.dart';
|
||||
|
||||
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
|
||||
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
||||
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
||||
/// Split-view derivation surface for a single scratch-link anchor: left pane =
|
||||
/// the reference PDF (at [initialPage]), right pane = the anchor's private
|
||||
/// 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 {
|
||||
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({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.documentId,
|
||||
required this.scratchLinkId,
|
||||
this.initialPage = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -34,34 +59,46 @@ class SplitViewScreen extends StatefulWidget {
|
||||
class _SplitViewState extends State<SplitViewScreen> {
|
||||
// -- PDF (left pane) --
|
||||
final PdfViewerController _pdfController = PdfViewerController();
|
||||
int _currentPage = 0;
|
||||
int _currentPage = 0; // 0-based
|
||||
int _pageCount = 0;
|
||||
String _fileName = '';
|
||||
|
||||
// -- Split divider --
|
||||
double _leftPaneFraction = 0.5;
|
||||
bool _isDraggingDivider = false;
|
||||
|
||||
// -- 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();
|
||||
List<InkStroke> _strokes = [];
|
||||
double _canvasWidth = 4000;
|
||||
double _canvasHeight = 4000;
|
||||
static const _uuid = Uuid();
|
||||
|
||||
// -- Tool state --
|
||||
PenTool _currentTool = PenTool.pen;
|
||||
Color _currentColor = Colors.black;
|
||||
double _currentStrokeWidth = 2.0;
|
||||
bool _filled = false;
|
||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
||||
final TransformationController _scratchTransform = TransformationController();
|
||||
bool _scratchCentered = false;
|
||||
|
||||
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
|
||||
|
||||
// -- Tool state (new Material You brush palette) --
|
||||
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 --
|
||||
Timer? _saveTimer;
|
||||
bool _dirty = false;
|
||||
|
||||
// -- Page link markers (optional feature) --
|
||||
final List<_PageLink> _pageLinks = [];
|
||||
/// Sidecar persistence for the source file (the scratchpad is embedded in the
|
||||
/// anchor's `scratchLinks[id].scratchpad`). Null until [_loadScratchpad]
|
||||
/// resolves.
|
||||
SidecarRepository? _repo;
|
||||
|
||||
static const double _edgeThreshold = 200.0;
|
||||
static const double _expandAmount = 1000.0;
|
||||
@@ -69,30 +106,52 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPage = widget.initialPage;
|
||||
_loadScratchpad();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_saveTimer?.cancel();
|
||||
_saveImmediate();
|
||||
_pdfController.dispose();
|
||||
// Schedule a final flush; retain-counted repo may still be held by the
|
||||
// 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();
|
||||
}
|
||||
|
||||
// -- Persistence --
|
||||
// -- Persistence (sidecar's scratchLinks[id].scratchpad, keyed by anchor id) --
|
||||
|
||||
Future<void> _loadScratchpad() async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
final strokes = await db.loadScratchpad(widget.documentId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_strokes = strokes;
|
||||
for (final s in strokes) {
|
||||
_undoManager.addStroke(s);
|
||||
}
|
||||
});
|
||||
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
|
||||
if (!mounted) {
|
||||
repo.dispose();
|
||||
return;
|
||||
}
|
||||
_repo = repo;
|
||||
final pad = repo.scratchpadFor(widget.scratchLinkId);
|
||||
setState(() {
|
||||
if (pad != null) {
|
||||
// Restore the world size so the infinite canvas reopens at its grown
|
||||
// extent (previously always reset to 4000×4000).
|
||||
_canvasWidth = pad.canvasWidth;
|
||||
_canvasHeight = pad.canvasHeight;
|
||||
}
|
||||
final strokes = pad?.strokes ?? const <InkStroke>[];
|
||||
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
|
||||
// manager (shapes/text have no pen-canvas representation).
|
||||
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||
_strokes = freehand;
|
||||
for (final s in freehand) {
|
||||
_undoManager.addStroke(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleSave() {
|
||||
@@ -103,15 +162,26 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
|
||||
Future<void> _saveImmediate() async {
|
||||
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;
|
||||
final db = await DatabaseService.getInstance();
|
||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
||||
await db.saveScratchpad(widget.documentId, json);
|
||||
await repo.flush();
|
||||
}
|
||||
|
||||
// -- Scratchpad stroke callbacks --
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
void _onStrokeComplete(PenStroke pen) {
|
||||
final stroke = inkStrokeFromPen(pen, _worldSize,
|
||||
id: _uuid.v4(), createdAt: DateTime.now());
|
||||
setState(() {
|
||||
_strokes.add(stroke);
|
||||
_undoManager.addStroke(stroke);
|
||||
@@ -120,13 +190,17 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
void _onErase(int index, List<PenStroke> replacements) {
|
||||
if (index < 0 || index >= _strokes.length) return;
|
||||
setState(() {
|
||||
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
|
||||
if (original != null) {
|
||||
_undoManager.removeStroke(original, replacements: replacements);
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
}
|
||||
final original = _strokes[index];
|
||||
final inkReplacements = [
|
||||
for (final r in replacements)
|
||||
inkStrokeFromPen(r, _worldSize,
|
||||
id: _uuid.v4(), createdAt: DateTime.now()),
|
||||
];
|
||||
_undoManager.removeStroke(original, replacements: inkReplacements);
|
||||
_strokes = List.from(_undoManager.currentStrokes);
|
||||
});
|
||||
_scheduleSave();
|
||||
}
|
||||
@@ -190,71 +264,40 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
setState(() => _isDraggingDivider = false);
|
||||
}
|
||||
|
||||
// -- PDF page navigation --
|
||||
// -- PDF page navigation (left pane) --
|
||||
|
||||
void _prevPage() {
|
||||
if (_currentPage > 0) {
|
||||
_pdfController.previousPage();
|
||||
}
|
||||
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
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) {
|
||||
// Place a page link marker at the current scratchpad viewport center.
|
||||
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
||||
// manages its own transform — the user can reposition by panning.
|
||||
setState(() {
|
||||
_pageLinks.add(
|
||||
_PageLink(
|
||||
pageNumber: pageNumber,
|
||||
position: const Offset(100, 100), // default top-left area
|
||||
),
|
||||
);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Page link marker added for page $pageNumber')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPageLinkTap(_PageLink link) {
|
||||
_pdfController.jumpToPage(link.pageNumber);
|
||||
setState(() {
|
||||
_currentPage = link.pageNumber - 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _deletePageLink(_PageLink link) {
|
||||
setState(() {
|
||||
_pageLinks.remove(link);
|
||||
});
|
||||
}
|
||||
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||
? _highlighterWidthFraction
|
||||
: _penWidthFraction;
|
||||
|
||||
// -- Build --
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_fileName.isEmpty ? 'Split View' : _fileName,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
_saveImmediate();
|
||||
Navigator.of(context).pop();
|
||||
onPressed: () async {
|
||||
await _saveImmediate();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
// Left pane page navigation
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_before),
|
||||
tooltip: 'Previous page (PDF)',
|
||||
@@ -275,7 +318,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Canvas info
|
||||
Tooltip(
|
||||
message:
|
||||
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
||||
@@ -293,7 +335,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Label clarifying that the toolbar controls the scratchpad pane.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||
child: Align(
|
||||
@@ -301,47 +342,22 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
child: Text(
|
||||
'Scratchpad tools',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Toolbar (applies to scratchpad only)
|
||||
AnnotationToolbar(
|
||||
currentTool: _currentTool,
|
||||
currentColor: _currentColor,
|
||||
currentStrokeWidth: _currentStrokeWidth,
|
||||
filled: _filled,
|
||||
pressureCurveType: _pressureCurveType,
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
canUndo: _undoManager.canUndo,
|
||||
canRedo: _undoManager.canRedo,
|
||||
onToolChanged: (tool) => setState(() => _currentTool = tool),
|
||||
onColorChanged: (color) => setState(() => _currentColor = color),
|
||||
onStrokeWidthChanged: (w) =>
|
||||
setState(() => _currentStrokeWidth = w),
|
||||
onFilledChanged: (f) => setState(() => _filled = f),
|
||||
onPressureCurveChanged: (v) =>
|
||||
setState(() => _pressureCurveType = v),
|
||||
onStabilizationChanged: (v) =>
|
||||
setState(() => _stabilizationLevel = v),
|
||||
onUndo: _undo,
|
||||
onRedo: _redo,
|
||||
),
|
||||
// Split view body
|
||||
_buildBrushPalette(cs),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final totalWidth = constraints.maxWidth;
|
||||
final leftWidth = totalWidth * _leftPaneFraction;
|
||||
final rightWidth =
|
||||
totalWidth - leftWidth - 12; // 12px divider hit area
|
||||
final rightWidth = totalWidth - leftWidth - 12;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Left pane: PDF reference (read-only)
|
||||
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
||||
// Draggable divider: 12px hit area, 4px visual strip.
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: _onDividerDragStart,
|
||||
onHorizontalDragUpdate: (d) =>
|
||||
@@ -362,7 +378,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right pane: Infinite scratchpad
|
||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||
],
|
||||
);
|
||||
@@ -374,96 +389,188 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPdfPane() {
|
||||
return Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
// Long-press on PDF to create page link marker
|
||||
_onPdfLongPress(_currentPage + 1);
|
||||
},
|
||||
child: SfPdfViewer.file(
|
||||
File(widget.filePath),
|
||||
controller: _pdfController,
|
||||
canShowScrollHead: true,
|
||||
canShowScrollStatus: true,
|
||||
onPageChanged: (PdfPageChangedDetails details) {
|
||||
setState(() {
|
||||
_currentPage = details.newPageNumber - 1;
|
||||
});
|
||||
},
|
||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
|
||||
setState(() {
|
||||
_pageCount = details.document.pages.count;
|
||||
_fileName = widget.filePath.split(Platform.pathSeparator).last;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
||||
if (_pageLinks.isNotEmpty)
|
||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageLinkChips() {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: _pageLinks.map((link) {
|
||||
return GestureDetector(
|
||||
onTap: () => _onPageLinkTap(link),
|
||||
onLongPress: () => _deletePageLink(link),
|
||||
child: Chip(
|
||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
||||
label: Text(
|
||||
'p${link.pageNumber}',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.blue.shade600,
|
||||
padding: EdgeInsets.zero,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScratchpadPane() {
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: InteractiveViewer(
|
||||
constrained: false,
|
||||
minScale: 0.25,
|
||||
maxScale: 8.0,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
child: SizedBox(
|
||||
width: _canvasWidth,
|
||||
height: _canvasHeight,
|
||||
child: InkCanvas(
|
||||
strokes: _strokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: _currentTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth,
|
||||
pressureCurve: PressureCurve(type: _pressureCurveType),
|
||||
stabilizationLevel: _stabilizationLevel,
|
||||
filled: _filled,
|
||||
interactionMode: InteractionMode.draw,
|
||||
/// Material You brush palette (shared chrome with the pen-first editors):
|
||||
/// 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: [
|
||||
BrushPickerButton(
|
||||
selected: _penBrush,
|
||||
active: _tool == CanvasTool.pen,
|
||||
tooltip: 'Brush',
|
||||
labelFor: brushLabelEn,
|
||||
// The scratchpad keeps a single shared color (no per-brush color
|
||||
// memory in this surface); show it for every brush.
|
||||
colorFor: (_) => _color,
|
||||
onSelected: (b) => setState(() {
|
||||
_penBrush = b;
|
||||
_tool = CanvasTool.pen;
|
||||
}),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.brush_outlined,
|
||||
selected: _tool == CanvasTool.highlighter,
|
||||
tooltip: 'Highlighter',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker linking a scratchpad position to a specific PDF page.
|
||||
class _PageLink {
|
||||
final int pageNumber;
|
||||
final Offset position;
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
final selected =
|
||||
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
}),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: c,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? cs.onSurface : cs.outlineVariant,
|
||||
width: selected ? 3 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _PageLink({required this.pageNumber, required this.position});
|
||||
Widget _buildPdfPane() {
|
||||
// Read-only reference PDF on the same engine (pdfrx) as the rest of the
|
||||
// app, opened at the anchor's page.
|
||||
return PdfViewer.file(
|
||||
widget.filePath,
|
||||
controller: _pdfController,
|
||||
params: PdfViewerParams(
|
||||
onViewerReady: (document, controller) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pageCount = document.pages.length;
|
||||
});
|
||||
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
||||
if (target > 0) {
|
||||
controller.goToPage(pageNumber: target + 1);
|
||||
}
|
||||
},
|
||||
onPageChanged: (pageNumber) {
|
||||
if (pageNumber == null || !mounted) return;
|
||||
final idx = pageNumber - 1;
|
||||
if (idx != _currentPage) setState(() => _currentPage = idx);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScratchpadPane() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!_scratchCentered) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_frameScratchpad(
|
||||
Size(constraints.maxWidth, constraints.maxHeight));
|
||||
setState(() => _scratchCentered = true);
|
||||
});
|
||||
}
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: PenCanvas(
|
||||
pageSize: _worldSize,
|
||||
strokes: penStrokesFromInk(_strokes, _worldSize),
|
||||
transformationController: _scratchTransform,
|
||||
tool: _activeTool,
|
||||
brush: _penBrush,
|
||||
color: _color,
|
||||
strokeWidth: _strokeWidth,
|
||||
eraserRadius: kDefaultEraserRadius,
|
||||
minScale: 0.1,
|
||||
maxScale: 8.0,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onEraseStroke: _onErase,
|
||||
pageWidget: const ColoredBox(color: Colors.white),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Position the scratchpad so existing ink is on-screen (fit its bbox into
|
||||
/// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1.
|
||||
void _frameScratchpad(Size pane) {
|
||||
if (pane.isEmpty) return;
|
||||
if (_strokes.isEmpty) {
|
||||
_scratchTransform.value = Matrix4.identity();
|
||||
return;
|
||||
}
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = -double.infinity, maxY = -double.infinity;
|
||||
for (final s in _strokes) {
|
||||
for (final p in s.points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
}
|
||||
if (minX > maxX) {
|
||||
_scratchTransform.value = Matrix4.identity();
|
||||
return;
|
||||
}
|
||||
const pad = 80.0;
|
||||
final boxW = (maxX - minX) + pad * 2;
|
||||
final boxH = (maxY - minY) + pad * 2;
|
||||
final scale = (pane.width / boxW < pane.height / boxH
|
||||
? pane.width / boxW
|
||||
: pane.height / boxH)
|
||||
.clamp(0.15, 1.5);
|
||||
final cx = (minX + maxX) / 2;
|
||||
final cy = (minY + maxY) / 2;
|
||||
final tx = pane.width / 2 - scale * cx;
|
||||
final ty = pane.height / 2 - scale * cy;
|
||||
_scratchTransform.value = Matrix4.identity()
|
||||
..setEntry(0, 0, scale)
|
||||
..setEntry(1, 1, scale)
|
||||
..setEntry(2, 2, scale)
|
||||
..setTranslationRaw(tx, ty, 0);
|
||||
}
|
||||
}
|
||||
|
||||
158
lib/screens/vault_setup_screen.dart
Normal file
158
lib/screens/vault_setup_screen.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/vault_service.dart';
|
||||
|
||||
/// First-run (and re-prompt) gate that asks the user to pick a vault root
|
||||
/// folder — the single folder under which all notebooks will live, like an
|
||||
/// Obsidian vault.
|
||||
///
|
||||
/// On a successful, writable selection the chosen path is persisted via
|
||||
/// [VaultService] and [onVaultReady] is invoked so the host can proceed to the
|
||||
/// home screen. When [missing] is true the screen shows a "your vault folder is
|
||||
/// missing" message instead of the first-run copy (the saved folder no longer
|
||||
/// exists).
|
||||
class VaultSetupScreen extends StatefulWidget {
|
||||
const VaultSetupScreen({
|
||||
super.key,
|
||||
required this.vaultService,
|
||||
required this.onVaultReady,
|
||||
this.missing = false,
|
||||
});
|
||||
|
||||
final VaultService vaultService;
|
||||
|
||||
/// Called after a valid, writable vault root has been persisted.
|
||||
final VoidCallback onVaultReady;
|
||||
|
||||
/// Whether a previously-chosen folder went missing (changes the copy).
|
||||
final bool missing;
|
||||
|
||||
@override
|
||||
State<VaultSetupScreen> createState() => _VaultSetupScreenState();
|
||||
}
|
||||
|
||||
class _VaultSetupScreenState extends State<VaultSetupScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _pickFolder() async {
|
||||
final l = AppLocalizations.of(context);
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// getDirectoryPath is Desktop/Windows supported by file_picker.
|
||||
// lockParentWindow makes the native Windows dialog modal.
|
||||
final path = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: l.vaultSetupTitle,
|
||||
lockParentWindow: true,
|
||||
);
|
||||
if (path == null) {
|
||||
// User cancelled the native dialog.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
if (!await _isWritable(path)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = l.vaultNotWritable;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await widget.vaultService.setVaultRoot(path);
|
||||
if (mounted) widget.onVaultReady();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = l.vaultPickFailed(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe writability by creating and deleting a temp file in [path]. The
|
||||
/// native folder dialog can hand back a read-only location on Windows, so we
|
||||
/// validate before committing it as the vault.
|
||||
Future<bool> _isWritable(String path) async {
|
||||
final probe = File(p.join(path, '.badnote-write-probe'));
|
||||
try {
|
||||
await probe.writeAsString('ok', flush: true);
|
||||
await probe.delete();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
widget.missing
|
||||
? Icons.folder_off_outlined
|
||||
: Icons.folder_special_outlined,
|
||||
size: 56,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
widget.missing ? l.vaultMissingTitle : l.vaultSetupHeadline,
|
||||
style: textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.missing ? l.vaultMissingBody : l.vaultSetupBody,
|
||||
style: textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _pickFolder,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.folder_open),
|
||||
label: Text(l.vaultChooseFolder),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
lib/services/badnote_server_client.dart
Normal file
139
lib/services/badnote_server_client.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
|
||||
// WebDAV remains the primary NAS sync path; this client covers health, auth,
|
||||
// vault manifest assist, and OCR job submit/poll.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BadNoteServerConfig {
|
||||
const BadNoteServerConfig({
|
||||
this.baseUrl = '',
|
||||
this.username = '',
|
||||
this.password = '',
|
||||
this.token = '',
|
||||
this.userId = '',
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
final String token;
|
||||
final String userId;
|
||||
|
||||
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||
bool get isLoggedIn => token.isNotEmpty;
|
||||
|
||||
Uri? uri(String path) {
|
||||
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
if (base.isEmpty) return null;
|
||||
final p = path.startsWith('/') ? path : '/$path';
|
||||
return Uri.parse('$base$p');
|
||||
}
|
||||
|
||||
BadNoteServerConfig copyWith({
|
||||
String? baseUrl,
|
||||
String? username,
|
||||
String? password,
|
||||
String? token,
|
||||
String? userId,
|
||||
}) =>
|
||||
BadNoteServerConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
token: token ?? this.token,
|
||||
userId: userId ?? this.userId,
|
||||
);
|
||||
|
||||
static const _kBase = 'badnote.server.baseUrl';
|
||||
static const _kUser = 'badnote.server.username';
|
||||
static const _kPass = 'badnote.server.password';
|
||||
static const _kToken = 'badnote.server.token';
|
||||
static const _kUid = 'badnote.server.userId';
|
||||
|
||||
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
|
||||
return BadNoteServerConfig(
|
||||
baseUrl: prefs.getString(_kBase) ?? '',
|
||||
username: prefs.getString(_kUser) ?? '',
|
||||
password: prefs.getString(_kPass) ?? '',
|
||||
token: prefs.getString(_kToken) ?? '',
|
||||
userId: prefs.getString(_kUid) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(SharedPreferences prefs) async {
|
||||
await prefs.setString(_kBase, baseUrl);
|
||||
await prefs.setString(_kUser, username);
|
||||
await prefs.setString(_kPass, password);
|
||||
await prefs.setString(_kToken, token);
|
||||
await prefs.setString(_kUid, userId);
|
||||
}
|
||||
}
|
||||
|
||||
class BadNoteServerClient {
|
||||
BadNoteServerClient(this.config, {http.Client? httpClient})
|
||||
: _http = httpClient ?? http.Client();
|
||||
|
||||
BadNoteServerConfig config;
|
||||
final http.Client _http;
|
||||
|
||||
Map<String, String> get _authHeaders => {
|
||||
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
|
||||
};
|
||||
|
||||
Future<Map<String, dynamic>> health() async {
|
||||
final uri = config.uri('/api/v1/health');
|
||||
if (uri == null) throw StateError('server URL not set');
|
||||
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('health ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<BadNoteServerConfig> registerOrLogin({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final registerUri = config.uri('/api/v1/auth/register');
|
||||
if (registerUri == null) throw StateError('server URL not set');
|
||||
var res = await _http.post(
|
||||
registerUri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
if (res.statusCode == 409) {
|
||||
final loginUri = config.uri('/api/v1/auth/login')!;
|
||||
res = await _http.post(
|
||||
loginUri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
}
|
||||
if (res.statusCode != 200 && res.statusCode != 201) {
|
||||
throw StateError('auth ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
config = config.copyWith(
|
||||
username: username,
|
||||
password: password,
|
||||
token: body['token'] as String? ?? '',
|
||||
userId: body['user_id'] as String? ?? '',
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> vaultManifest() async {
|
||||
final uri = config.uri('/api/v1/vault/manifest');
|
||||
if (uri == null) throw StateError('server URL not set');
|
||||
final res = await _http.get(uri, headers: _authHeaders);
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('manifest ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show Offset, Size;
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../editor/board/board.dart';
|
||||
import '../editor/engine/stroke_model.dart';
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/document.dart' as doc;
|
||||
import '../models/ink_point.dart';
|
||||
@@ -13,6 +18,8 @@ import '../models/ink_stroke.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/scratch_link.dart';
|
||||
import 'vault_service.dart';
|
||||
|
||||
class DatabaseService {
|
||||
static DatabaseService? _instance;
|
||||
@@ -28,25 +35,83 @@ class DatabaseService {
|
||||
return service;
|
||||
}
|
||||
|
||||
/// Test-only: drop the cached singleton so the next [getInstance] re-opens a
|
||||
/// fresh database (e.g. after pointing PathProviderPlatform at a new temp
|
||||
/// dir). Closes the current handle if one is open.
|
||||
@visibleForTesting
|
||||
static Future<void> resetForTest() async {
|
||||
final existing = _instance;
|
||||
_instance = null;
|
||||
if (existing != null) {
|
||||
await existing._database.close();
|
||||
}
|
||||
}
|
||||
|
||||
Database get database => _database;
|
||||
|
||||
/// Re-resolve the DB location and reopen the singleton there. Called once the
|
||||
/// vault root becomes valid at startup so the live database moves from the
|
||||
/// legacy app-documents `badnote.db` to the vault cache
|
||||
/// `<vault>/.badnote/index.sqlite` (§A.1). No-op-safe: if the resolved path is
|
||||
/// unchanged it simply reopens the same file. Closes the previous handle.
|
||||
static Future<DatabaseService> reopen() async {
|
||||
final existing = _instance;
|
||||
if (existing != null) {
|
||||
await existing._database.close();
|
||||
_instance = null;
|
||||
}
|
||||
return getInstance();
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
}
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = p.join(dir.path, 'badnote.db');
|
||||
final dbPath = await _resolveDbPath();
|
||||
// Ensure the parent dir exists (the vault's hidden `.badnote/` cache dir is
|
||||
// not guaranteed to exist yet on first run).
|
||||
await Directory(p.dirname(dbPath)).create(recursive: true);
|
||||
|
||||
_database = await openDatabase(
|
||||
dbPath,
|
||||
version: 6,
|
||||
version: 8,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
}
|
||||
|
||||
/// The application-documents path of the LEGACY (pre-vault) database. This is
|
||||
/// the location [DatabaseService] used before the file-based re-architecture;
|
||||
/// the one-time migrator reads from here, then renames it to `.premigration`.
|
||||
static Future<String> legacyDbPath() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
return p.join(dir.path, 'badnote.db');
|
||||
}
|
||||
|
||||
/// Resolve where the live database should live. When a valid vault root is
|
||||
/// set, the DB is the vault's rebuildable cache/index at
|
||||
/// `<vault>/.badnote/index.sqlite` (§A.1). Otherwise (no vault yet — e.g. a
|
||||
/// fresh first run before the gate, or tests) fall back to the legacy
|
||||
/// app-documents `badnote.db` so the app still works.
|
||||
Future<String> _resolveDbPath() async {
|
||||
String? root;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
root = prefs.getString(VaultService.vaultRootKey);
|
||||
} catch (_) {
|
||||
// SharedPreferences may be unavailable (e.g. a unit test that mocks only
|
||||
// the path provider). Fall back to the legacy app-documents location so
|
||||
// the DB still opens — it is never the source of truth anyway.
|
||||
root = null;
|
||||
}
|
||||
if (root != null && root.isNotEmpty && await Directory(root).exists()) {
|
||||
return p.join(root, '.badnote', 'index.sqlite');
|
||||
}
|
||||
return legacyDbPath();
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
// Core tables (original v1)
|
||||
await db.execute('''
|
||||
@@ -184,6 +249,53 @@ class DatabaseService {
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
// Sticky-note board cards (v7): F7 双链 + 无限便利贴.
|
||||
await _createBoardCardsTable(db);
|
||||
|
||||
// PDF-anchored scratch links (v8).
|
||||
await _createScratchLinksTable(db);
|
||||
}
|
||||
|
||||
/// PDF-anchored scratch links table (v8). One row per [ScratchLink] anchor.
|
||||
/// The anchor [id] doubles as the storage key for its private scratchpad
|
||||
/// (reused from the [scratchpads] table — see [saveScratchpad]).
|
||||
Future<void> _createScratchLinksTable(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE scratch_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
page_index INTEGER NOT NULL,
|
||||
nx REAL NOT NULL,
|
||||
ny REAL NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_scratch_links_doc ON scratch_links(document_id)',
|
||||
);
|
||||
}
|
||||
|
||||
/// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the
|
||||
/// set of rows sharing a [board_id]. Geometry is stored as plain columns
|
||||
/// (rows, not a blob) so a board round-trips and could be queried later.
|
||||
Future<void> _createBoardCardsTable(DatabaseExecutor db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE board_cards (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
w REAL NOT NULL,
|
||||
h REAL NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
ordinal INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_board_cards_board ON board_cards(board_id)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||
@@ -191,6 +303,20 @@ class DatabaseService {
|
||||
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
||||
if (oldVersion < 5) await _migrateV4toV5(db);
|
||||
if (oldVersion < 6) await _migrateV5toV6(db);
|
||||
if (oldVersion < 7) await _migrateV6toV7(db);
|
||||
if (oldVersion < 8) await _migrateV7toV8(db);
|
||||
}
|
||||
|
||||
Future<void> _migrateV7toV8(Database db) async {
|
||||
await db.transaction((txn) async {
|
||||
await _createScratchLinksTable(txn);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV6toV7(Database db) async {
|
||||
await db.transaction((txn) async {
|
||||
await _createBoardCardsTable(txn);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _migrateV5toV6(Database db) async {
|
||||
@@ -897,4 +1023,285 @@ class DatabaseService {
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Board cards CRUD (F7 双链 + 无限便利贴) ──────────────────────────
|
||||
|
||||
/// Replace ALL cards for [boardId] with [cards]. Geometry is persisted as
|
||||
/// rows (id, x, y, w, h, text) so the board survives restart. The whole
|
||||
/// replace runs in one transaction so a crash mid-save cannot leave a
|
||||
/// half-written board.
|
||||
Future<void> saveBoardCards(
|
||||
String boardId,
|
||||
List<BoardCard> cards,
|
||||
) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete(
|
||||
'board_cards',
|
||||
where: 'board_id = ?',
|
||||
whereArgs: [boardId],
|
||||
);
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
final c = cards[i];
|
||||
await txn.insert('board_cards', {
|
||||
'id': c.id,
|
||||
'board_id': boardId,
|
||||
'x': c.position.dx,
|
||||
'y': c.position.dy,
|
||||
'w': c.size.width,
|
||||
'h': c.size.height,
|
||||
'text': c.text,
|
||||
'ordinal': i,
|
||||
'updated_at': now,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Load the [Board] for [boardId] (empty board when nothing is stored).
|
||||
Future<Board> loadBoard(String boardId) async {
|
||||
final rows = await _database.query(
|
||||
'board_cards',
|
||||
where: 'board_id = ?',
|
||||
whereArgs: [boardId],
|
||||
orderBy: 'ordinal ASC',
|
||||
);
|
||||
return Board([
|
||||
for (final row in rows)
|
||||
BoardCard(
|
||||
id: row['id'] as String,
|
||||
position: Offset(
|
||||
(row['x'] as num).toDouble(),
|
||||
(row['y'] as num).toDouble(),
|
||||
),
|
||||
size: Size(
|
||||
(row['w'] as num).toDouble(),
|
||||
(row['h'] as num).toDouble(),
|
||||
),
|
||||
text: row['text'] as String? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Scratch links CRUD (PDF-anchored scratchpad tabs) ──────────────────
|
||||
|
||||
/// Insert or replace a [ScratchLink] anchor. The anchor's private scratchpad
|
||||
/// lives in the [scratchpads] table keyed by [ScratchLink.id] — saved/loaded
|
||||
/// via [saveScratchpad] / [loadScratchpad].
|
||||
Future<void> saveScratchLink(ScratchLink link) async {
|
||||
await _database.insert(
|
||||
'scratch_links',
|
||||
{
|
||||
'id': link.id,
|
||||
'document_id': link.documentId,
|
||||
'page_index': link.pageIndex,
|
||||
'nx': link.nx,
|
||||
'ny': link.ny,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Load all anchors for [documentId], oldest first.
|
||||
Future<List<ScratchLink>> loadScratchLinks(String documentId) async {
|
||||
final rows = await _database.query(
|
||||
'scratch_links',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows
|
||||
.map(
|
||||
(row) => ScratchLink(
|
||||
id: row['id'] as String,
|
||||
documentId: row['document_id'] as String,
|
||||
pageIndex: row['page_index'] as int,
|
||||
nx: (row['nx'] as num).toDouble(),
|
||||
ny: (row['ny'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Delete an anchor and its private scratchpad (the scratchpad row keyed by
|
||||
/// the anchor id), so a deleted anchor leaves no orphaned ink behind.
|
||||
Future<void> deleteScratchLink(String id) async {
|
||||
await _database.transaction((txn) async {
|
||||
await txn.delete('scratch_links', where: 'id = ?', whereArgs: [id]);
|
||||
await txn.delete('scratchpads', where: 'document_id = ?', whereArgs: [id]);
|
||||
});
|
||||
}
|
||||
|
||||
// ── RAW legacy reads (one-time SQLite→sidecar migration, Phase 5) ───────────
|
||||
//
|
||||
// These operate on an arbitrary [Database] handle (the LEGACY db the migrator
|
||||
// opens directly), NOT the live [_database] cache, so the migrator can read
|
||||
// pre-migration data without touching the relocated index. They reuse this
|
||||
// class's row-parsers so the JSON shapes stay identical to the live reads.
|
||||
|
||||
/// All `documents` rows from [db], oldest first (stable migration order).
|
||||
static Future<List<doc.Document>> rawAllDocuments(Database db) async {
|
||||
if (!await _tableExists(db, 'documents')) return const [];
|
||||
final rows = await db.query('documents', orderBy: 'created_at ASC');
|
||||
final dummy = DatabaseService._();
|
||||
return rows.map(dummy._documentFromRow).toList();
|
||||
}
|
||||
|
||||
/// All `notes` rows (with their `strokes`) from [db], oldest first.
|
||||
static Future<List<Note>> rawAllNotes(Database db) async {
|
||||
if (!await _tableExists(db, 'notes')) return const [];
|
||||
final rows = await db.query('notes', orderBy: 'created_at ASC');
|
||||
final dummy = DatabaseService._();
|
||||
final notes = <Note>[];
|
||||
for (final row in rows) {
|
||||
final strokeRows = await db.query(
|
||||
'strokes',
|
||||
where: 'note_id = ?',
|
||||
whereArgs: [row['id'] as String],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
final strokes = strokeRows.map(dummy._strokeFromRow).toList();
|
||||
final tagsJson = jsonDecode(row['tags'] as String) as List;
|
||||
notes.add(Note(
|
||||
id: row['id'] as String,
|
||||
title: row['title'] as String,
|
||||
strokes: strokes,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
tags: tagsJson.cast<String>(),
|
||||
));
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Committed editor strokes for [documentId] from [db], grouped by 0-based
|
||||
/// page index. Parses the `ink.host_id = "doc:<documentId>:page:<i>"` scheme
|
||||
/// (see [EditorRepository.loadDocument]) and decodes each `stroke_json`
|
||||
/// straight into an [EditorStroke]. Returns `{}` when there is no `ink` table
|
||||
/// or no rows.
|
||||
static Future<Map<int, List<EditorStroke>>> rawStrokesByPage(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'ink')) return <int, List<EditorStroke>>{};
|
||||
final rows = await db.query(
|
||||
'ink',
|
||||
where: 'host_kind = ? AND host_id LIKE ?',
|
||||
whereArgs: ['page', 'doc:$documentId:page:%'],
|
||||
orderBy: 'host_id ASC, ordinal ASC',
|
||||
);
|
||||
final out = <int, List<EditorStroke>>{};
|
||||
for (final row in rows) {
|
||||
final hostId = row['host_id'] as String;
|
||||
final pageIndex = _pageIndexFromHostId(hostId);
|
||||
if (pageIndex == null) continue;
|
||||
final json =
|
||||
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
|
||||
out.putIfAbsent(pageIndex, () => []).add(EditorStroke.fromJson(json));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Bookmarks for [documentId] from [db] (empty when no `bookmarks` table).
|
||||
static Future<List<Bookmark>> rawBookmarks(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'bookmarks')) return const [];
|
||||
final rows = await db.query(
|
||||
'bookmarks',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
final dummy = DatabaseService._();
|
||||
return rows.map(dummy._bookmarkFromRow).toList();
|
||||
}
|
||||
|
||||
/// Scratch-link anchors for [documentId] from [db] (empty when no table).
|
||||
static Future<List<ScratchLink>> rawScratchLinks(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'scratch_links')) return const [];
|
||||
final rows = await db.query(
|
||||
'scratch_links',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'created_at ASC',
|
||||
);
|
||||
return rows
|
||||
.map(
|
||||
(row) => ScratchLink(
|
||||
id: row['id'] as String,
|
||||
documentId: row['document_id'] as String,
|
||||
pageIndex: row['page_index'] as int,
|
||||
nx: (row['nx'] as num).toDouble(),
|
||||
ny: (row['ny'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Scratchpad strokes stored under [key] (an anchor id) from [db]. Empty when
|
||||
/// there is no `scratchpads` table or no row.
|
||||
static Future<List<InkStroke>> rawScratchpad(
|
||||
Database db,
|
||||
String key,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'scratchpads')) return const [];
|
||||
final rows = await db.query(
|
||||
'scratchpads',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [key],
|
||||
);
|
||||
if (rows.isEmpty) return const [];
|
||||
final json = rows.first['strokes_json'] as String;
|
||||
if (json.isEmpty || json == '[]') return const [];
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
return list
|
||||
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Raw legacy per-page `annotation_json` blobs for [documentId] from [db],
|
||||
/// keyed by page number. These belong to the DEAD pre-editor annotation path
|
||||
/// (§1 `annotations` table); the migrator copies them verbatim into the
|
||||
/// sidecar's `legacyAnnotations` so nothing is silently dropped.
|
||||
static Future<Map<int, String>> rawLegacyAnnotations(
|
||||
Database db,
|
||||
String documentId,
|
||||
) async {
|
||||
if (!await _tableExists(db, 'annotations')) return <int, String>{};
|
||||
final rows = await db.query(
|
||||
'annotations',
|
||||
where: 'document_id = ?',
|
||||
whereArgs: [documentId],
|
||||
orderBy: 'page_number ASC',
|
||||
);
|
||||
final out = <int, String>{};
|
||||
for (final row in rows) {
|
||||
out[row['page_number'] as int] = row['annotation_json'] as String;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Parse the 0-based page index out of an `ink.host_id` of the form
|
||||
/// `doc:<documentId>:page:<pageIndex>`. Returns null on an unexpected shape.
|
||||
static int? _pageIndexFromHostId(String hostId) {
|
||||
final i = hostId.lastIndexOf(':page:');
|
||||
if (i == -1) return null;
|
||||
return int.tryParse(hostId.substring(i + ':page:'.length));
|
||||
}
|
||||
|
||||
/// True iff [name] is an existing table in [db]. Lets the raw readers tolerate
|
||||
/// a legacy DB that predates a given table (older schema versions).
|
||||
static Future<bool> _tableExists(Database db, String name) async {
|
||||
final rows = await db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
[name],
|
||||
);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../diagnostics/badnote_log.dart';
|
||||
import '../editor/persistence/sidecar_repository.dart';
|
||||
import '../models/note.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import 'database_service.dart';
|
||||
@@ -6,8 +10,17 @@ import 'stroke_rasterizer.dart';
|
||||
|
||||
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
|
||||
class OcrService {
|
||||
/// Extract searchable text from [note] and merge into the local FTS index.
|
||||
/// Extract searchable text from [note] and persist it for search. The text is
|
||||
/// written to the note's `*.badnote.json` sidecar `ocrText` field — the
|
||||
/// vault-scan source of truth the Phase 6 search index reads — and also
|
||||
/// appended to the (rebuildable) SQLite FTS cache so legacy callers keep
|
||||
/// working. [note.id] is the synthetic note path, which is exactly the
|
||||
/// `sourceFilePath` the editor opened its [SidecarRepository] with.
|
||||
Future<void> processNote(Note note) async {
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_start', fields: {
|
||||
'note': note.id,
|
||||
'strokes': note.strokes.length,
|
||||
});
|
||||
final parts = <String>[];
|
||||
|
||||
for (final stroke in note.strokes) {
|
||||
@@ -33,13 +46,35 @@ class OcrService {
|
||||
final recognized = await OcrEngine.recognizeImage(png);
|
||||
if (recognized != null && recognized.isNotEmpty) {
|
||||
parts.add(recognized);
|
||||
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_handwriting', fields: {
|
||||
'chars': recognized.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final combined = parts.join(' ').trim();
|
||||
if (combined.isEmpty) return;
|
||||
if (combined.isEmpty) {
|
||||
BadNoteLog.instance.debug(LogSubsystem.diag, 'ocr_empty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist into the note's sidecar so the vault-scan search index finds it.
|
||||
// Prefer the editor's already-open repo (same in-memory sidecar — no race);
|
||||
// if the note is closed, open/flush/dispose a transient handle.
|
||||
final open = SidecarRepositoryRegistry.forPath(note.id);
|
||||
if (open != null) {
|
||||
open.scheduleOcrTextSave(combined);
|
||||
await open.flush();
|
||||
} else if (await File('${note.id}$kSidecarSuffix').exists()) {
|
||||
final repo = await SidecarRepository.open(note.id, docType: 'notebook');
|
||||
repo.scheduleOcrTextSave(combined);
|
||||
await repo.flush();
|
||||
repo.dispose();
|
||||
}
|
||||
|
||||
// Also keep the legacy SQLite FTS cache warm (rebuildable; not the source
|
||||
// of truth). Harmless if the row is never read.
|
||||
final db = await DatabaseService.getInstance();
|
||||
await db.appendOcrToFts(note.id, combined);
|
||||
}
|
||||
|
||||
120
lib/services/office/docx_parser.dart
Normal file
120
lib/services/office/docx_parser.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../../diagnostics/badnote_log.dart';
|
||||
import 'office_document.dart';
|
||||
|
||||
/// Native DOCX parser — block-level structure for BadNote annotation pages.
|
||||
class DocxParser {
|
||||
Future<ParsedDocx> parse(String docxPath, {Directory? cacheDir}) async {
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_start', fields: {
|
||||
'path': docxPath,
|
||||
});
|
||||
final bytes = await File(docxPath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
Directory out = cacheDir ??
|
||||
Directory(p.join(Directory.systemTemp.path, 'badnote_docx_${DateTime.now().millisecondsSinceEpoch}'));
|
||||
if (!await out.exists()) await out.create(recursive: true);
|
||||
|
||||
final documentXml = _decode(_find(archive, 'word/document.xml'));
|
||||
if (documentXml == null) {
|
||||
return ParsedDocx(sourcePath: docxPath, blocks: const []);
|
||||
}
|
||||
|
||||
// Media map from relationships.
|
||||
final media = <String, String>{};
|
||||
final rels = _decode(_find(archive, 'word/_rels/document.xml.rels'));
|
||||
if (rels != null) {
|
||||
try {
|
||||
final relDoc = XmlDocument.parse(rels);
|
||||
for (final rel in relDoc.findAllElements('Relationship')) {
|
||||
final id = rel.getAttribute('Id');
|
||||
final type = rel.getAttribute('Type') ?? '';
|
||||
final target = rel.getAttribute('Target') ?? '';
|
||||
if (id == null || !type.contains('image') || target.isEmpty) continue;
|
||||
final mediaPath = p.normalize(p.join('word', target));
|
||||
final file = _find(archive, mediaPath);
|
||||
if (file?.content is! List<int>) continue;
|
||||
final outPath = p.join(out.path, p.basename(mediaPath));
|
||||
await File(outPath).writeAsBytes(file!.content as List<int>);
|
||||
media[id] = outPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final blocks = <DocBlock>[];
|
||||
try {
|
||||
final doc = XmlDocument.parse(documentXml);
|
||||
for (final pEl in doc.findAllElements('w:p')) {
|
||||
final style = pEl
|
||||
.findElements('w:pPr')
|
||||
.expand((e) => e.findElements('w:pStyle'))
|
||||
.map((e) => e.getAttribute('w:val') ?? '')
|
||||
.firstWhere((s) => s.isNotEmpty, orElse: () => '');
|
||||
final texts = pEl.findAllElements('w:t').map((t) => t.innerText).join();
|
||||
final blips = pEl.findAllElements('a:blip');
|
||||
for (final blip in blips) {
|
||||
final embed = blip.getAttribute('r:embed') ?? blip.getAttribute('embed');
|
||||
if (embed != null && media[embed] != null) {
|
||||
blocks.add(DocBlock(
|
||||
type: DocBlockType.image,
|
||||
text: '',
|
||||
imagePath: media[embed],
|
||||
));
|
||||
}
|
||||
}
|
||||
if (texts.trim().isEmpty && blips.isEmpty) continue;
|
||||
if (texts.trim().isEmpty) continue;
|
||||
final isHeading = style.toLowerCase().startsWith('heading') ||
|
||||
RegExp(r'^Heading\s*\d', caseSensitive: false).hasMatch(style);
|
||||
final level = int.tryParse(RegExp(r'(\d+)').firstMatch(style)?.group(1) ?? '') ??
|
||||
(isHeading ? 1 : 0);
|
||||
blocks.add(DocBlock(
|
||||
type: isHeading ? DocBlockType.heading : DocBlockType.paragraph,
|
||||
text: texts,
|
||||
level: level,
|
||||
));
|
||||
}
|
||||
// Tables
|
||||
for (final row in doc.findAllElements('w:tr')) {
|
||||
final cells = row
|
||||
.findElements('w:tc')
|
||||
.map((tc) => tc.findAllElements('w:t').map((t) => t.innerText).join())
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.join(' | ');
|
||||
if (cells.isEmpty) continue;
|
||||
blocks.add(DocBlock(type: DocBlockType.tableRow, text: cells));
|
||||
}
|
||||
} catch (e) {
|
||||
BadNoteLog.instance.warn(LogSubsystem.office, 'docx_parse_error', fields: {
|
||||
'error': '$e',
|
||||
});
|
||||
}
|
||||
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_done', fields: {
|
||||
'blocks': blocks.length,
|
||||
});
|
||||
return ParsedDocx(sourcePath: docxPath, blocks: blocks);
|
||||
}
|
||||
|
||||
Future<String> extractText(String docxPath) async {
|
||||
final parsed = await parse(docxPath);
|
||||
return parsed.plainText;
|
||||
}
|
||||
|
||||
static ArchiveFile? _find(Archive archive, String name) {
|
||||
final n = name.replaceAll('\\', '/');
|
||||
for (final f in archive.files) {
|
||||
if (f.name.replaceAll('\\', '/') == n) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _decode(ArchiveFile? file) {
|
||||
if (file == null) return null;
|
||||
return String.fromCharCodes(file.content);
|
||||
}
|
||||
}
|
||||
98
lib/services/office/office_document.dart
Normal file
98
lib/services/office/office_document.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
/// Shared OOXML document models for native Word/PPT parsing.
|
||||
library;
|
||||
|
||||
class OfficeTextRun {
|
||||
const OfficeTextRun({
|
||||
required this.text,
|
||||
this.x = 0,
|
||||
this.y = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
this.fontSize = 18,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
}
|
||||
|
||||
class OfficeImage {
|
||||
const OfficeImage({
|
||||
required this.bytesPath,
|
||||
this.x = 0,
|
||||
this.y = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
});
|
||||
|
||||
final String bytesPath;
|
||||
final double x;
|
||||
final double y;
|
||||
final double width;
|
||||
final double height;
|
||||
}
|
||||
|
||||
class OfficeSlide {
|
||||
const OfficeSlide({
|
||||
required this.index,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.runs = const [],
|
||||
this.images = const [],
|
||||
this.plainText = '',
|
||||
});
|
||||
|
||||
final int index;
|
||||
final double width;
|
||||
final double height;
|
||||
final List<OfficeTextRun> runs;
|
||||
final List<OfficeImage> images;
|
||||
final String plainText;
|
||||
}
|
||||
|
||||
class ParsedPptx {
|
||||
const ParsedPptx({
|
||||
required this.sourcePath,
|
||||
required this.slides,
|
||||
});
|
||||
|
||||
final String sourcePath;
|
||||
final List<OfficeSlide> slides;
|
||||
|
||||
String get allText =>
|
||||
slides.map((s) => '--- Slide ${s.index + 1} ---\n${s.plainText}').join('\n\n');
|
||||
|
||||
/// Alias used by [PptxService.extractText].
|
||||
String get plainText => allText;
|
||||
}
|
||||
|
||||
enum DocBlockType { heading, paragraph, tableRow, image }
|
||||
|
||||
class DocBlock {
|
||||
const DocBlock({
|
||||
required this.type,
|
||||
required this.text,
|
||||
this.level = 0,
|
||||
this.imagePath,
|
||||
});
|
||||
|
||||
final DocBlockType type;
|
||||
final String text;
|
||||
final int level;
|
||||
final String? imagePath;
|
||||
}
|
||||
|
||||
class ParsedDocx {
|
||||
const ParsedDocx({
|
||||
required this.sourcePath,
|
||||
required this.blocks,
|
||||
});
|
||||
|
||||
final String sourcePath;
|
||||
final List<DocBlock> blocks;
|
||||
|
||||
String get plainText => blocks.map((b) => b.text).where((t) => t.isNotEmpty).join('\n');
|
||||
}
|
||||
164
lib/services/office/pptx_parser.dart
Normal file
164
lib/services/office/pptx_parser.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../../diagnostics/badnote_log.dart';
|
||||
import 'office_document.dart';
|
||||
|
||||
/// Native PPTX parser — no LibreOffice. Reads OOXML zip + slide XML.
|
||||
class PptxParser {
|
||||
/// EMUs per English inch (Office drawing unit).
|
||||
static const double _emuPerInch = 914400;
|
||||
static const double _defaultDpi = 96;
|
||||
|
||||
Future<ParsedPptx> parse(String pptxPath, {Directory? cacheDir, String? cacheDirPath}) async {
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_start', fields: {
|
||||
'path': pptxPath,
|
||||
});
|
||||
final bytes = await File(pptxPath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
|
||||
Directory out = cacheDir ??
|
||||
(cacheDirPath != null
|
||||
? Directory(cacheDirPath)
|
||||
: Directory(p.join(Directory.systemTemp.path, 'badnote_pptx_${DateTime.now().millisecondsSinceEpoch}')));
|
||||
if (!await out.exists()) await out.create(recursive: true);
|
||||
|
||||
// Default slide size (widescreen 13.333" x 7.5") in pixels at 96dpi.
|
||||
double slideW = 13.333 * _defaultDpi;
|
||||
double slideH = 7.5 * _defaultDpi;
|
||||
final sldSz = _file(archive, 'ppt/presentation.xml');
|
||||
if (sldSz != null) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(sldSz);
|
||||
final candidates = [
|
||||
...doc.findAllElements('sldSz', namespace: '*'),
|
||||
...doc.findAllElements('p:sldSz'),
|
||||
];
|
||||
final el = candidates.isEmpty ? null : candidates.first;
|
||||
if (el != null) {
|
||||
final cx = int.tryParse(el.getAttribute('cx') ?? '') ?? 0;
|
||||
final cy = int.tryParse(el.getAttribute('cy') ?? '') ?? 0;
|
||||
if (cx > 0 && cy > 0) {
|
||||
slideW = cx / _emuPerInch * _defaultDpi;
|
||||
slideH = cy / _emuPerInch * _defaultDpi;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final slideFiles = archive.files
|
||||
.where((f) =>
|
||||
f.name.startsWith('ppt/slides/slide') &&
|
||||
f.name.endsWith('.xml') &&
|
||||
!f.name.contains('_rels'))
|
||||
.toList()
|
||||
..sort((a, b) => _slideNum(a.name).compareTo(_slideNum(b.name)));
|
||||
|
||||
final slides = <OfficeSlide>[];
|
||||
for (var i = 0; i < slideFiles.length; i++) {
|
||||
final file = slideFiles[i];
|
||||
final xml = _decode(file);
|
||||
if (xml == null) continue;
|
||||
final runs = <OfficeTextRun>[];
|
||||
final images = <OfficeImage>[];
|
||||
final textBuf = StringBuffer();
|
||||
|
||||
try {
|
||||
final doc = XmlDocument.parse(xml);
|
||||
for (final t in doc.findAllElements('a:t')) {
|
||||
final text = t.innerText;
|
||||
if (text.isEmpty) continue;
|
||||
textBuf.writeln(text);
|
||||
// Approximate: stack text vertically when no transform is parsed.
|
||||
runs.add(OfficeTextRun(
|
||||
text: text,
|
||||
x: 48,
|
||||
y: 48.0 + runs.length * 28,
|
||||
width: math.max(120, slideW - 96),
|
||||
height: 28,
|
||||
));
|
||||
}
|
||||
|
||||
// Extract images referenced by this slide's relationships.
|
||||
final relsName =
|
||||
'ppt/slides/_rels/slide${_slideNum(file.name)}.xml.rels';
|
||||
final relsXml = _file(archive, relsName);
|
||||
if (relsXml != null) {
|
||||
final relsDoc = XmlDocument.parse(relsXml);
|
||||
for (final rel in relsDoc.findAllElements('Relationship')) {
|
||||
final type = rel.getAttribute('Type') ?? '';
|
||||
if (!type.contains('image')) continue;
|
||||
var target = rel.getAttribute('Target') ?? '';
|
||||
if (target.isEmpty) continue;
|
||||
// Targets are relative to ppt/slides/ → often ../media/image1.png
|
||||
final mediaPath = p.normalize(p.join('ppt/slides', target));
|
||||
final media = _archiveFile(archive, mediaPath) ??
|
||||
_archiveFile(archive, target.replaceFirst('../', 'ppt/'));
|
||||
if (media == null) continue;
|
||||
final content = media.content;
|
||||
final outPath = p.join(out.path, p.basename(mediaPath));
|
||||
await File(outPath).writeAsBytes(content);
|
||||
images.add(OfficeImage(
|
||||
bytesPath: outPath,
|
||||
x: 80,
|
||||
y: slideH * 0.35,
|
||||
width: slideW * 0.4,
|
||||
height: slideH * 0.4,
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
BadNoteLog.instance.warn(LogSubsystem.office, 'slide_parse_error', fields: {
|
||||
'slide': file.name,
|
||||
'error': '$e',
|
||||
});
|
||||
}
|
||||
|
||||
slides.add(OfficeSlide(
|
||||
index: i,
|
||||
width: slideW,
|
||||
height: slideH,
|
||||
runs: runs,
|
||||
images: images,
|
||||
plainText: textBuf.toString().trim(),
|
||||
));
|
||||
}
|
||||
|
||||
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_done', fields: {
|
||||
'slides': slides.length,
|
||||
});
|
||||
return ParsedPptx(sourcePath: pptxPath, slides: slides);
|
||||
}
|
||||
|
||||
Future<String> extractText(String pptxPath) async {
|
||||
final parsed = await parse(pptxPath);
|
||||
return parsed.allText;
|
||||
}
|
||||
|
||||
static int _slideNum(String name) {
|
||||
final m = RegExp(r'slide(\d+)\.xml').firstMatch(name);
|
||||
return int.tryParse(m?.group(1) ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String? _file(Archive archive, String name) {
|
||||
final f = _archiveFile(archive, name);
|
||||
return _decode(f);
|
||||
}
|
||||
|
||||
static ArchiveFile? _archiveFile(Archive archive, String name) {
|
||||
final normalized = name.replaceAll('\\', '/');
|
||||
for (final f in archive.files) {
|
||||
if (f.name.replaceAll('\\', '/') == normalized) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _decode(ArchiveFile? file) {
|
||||
if (file == null) return null;
|
||||
return String.fromCharCodes(file.content);
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,12 @@ class PdfService {
|
||||
// ONE shared recipe with the on-screen painter (R7): export can no longer
|
||||
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
|
||||
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
|
||||
//
|
||||
// TODO(brush-persist) / TODO(brush-export): InkStroke does not persist the
|
||||
// brush, so export can only use the legacy (no-brush) recipe — it does NOT
|
||||
// yet pass `brush:` here. Once the brush is persisted on InkStroke, resolve
|
||||
// brushProfileFor(stroke.brush) and pass it so export matches the brush-aware
|
||||
// screen geometry (taper/caps/per-brush thinning) for non-fountain brushes.
|
||||
final outline = freehandOutlinePoints(
|
||||
pfPoints: pfPoints,
|
||||
size: pixelWidth,
|
||||
|
||||
198
lib/services/pdf_text_indexer.dart
Normal file
198
lib/services/pdf_text_indexer.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// lib/services/pdf_text_indexer.dart
|
||||
//
|
||||
// Import-time document-body text indexing for file-backed PDF notebooks, so the
|
||||
// vault-scan search index (VaultSearchIndex) covers the underlying document —
|
||||
// not just the user's annotations. Three text sources now feed search:
|
||||
// (a) handwriting → OCR'd into the sidecar's `ocrText` (OcrService),
|
||||
// (b) a PDF text layer → its embedded printed text, captured here,
|
||||
// (c) a RASTERIZED PDF → background OCR of the rendered pages, captured here.
|
||||
//
|
||||
// Flow (kicked off after VaultService.createNotebook, fire-and-forget):
|
||||
// 1. IDEMPOTENCY: if the sidecar already carries `pageText`, do nothing.
|
||||
// 2. EXTRACT the embedded text layer per page (pdfrx `loadText`).
|
||||
// 3. DECIDE text-layer vs rasterized: sum the embedded text length across all
|
||||
// pages; if it clears [textLayerThreshold] the PDF has a usable text layer
|
||||
// and we persist that. Otherwise the PDF is rasterized (scanned image, no
|
||||
// text) and we OCR each rendered page.
|
||||
// 4. PERSIST the per-page text (joined by form-feed) into the sidecar's
|
||||
// `pageText` field — writing THROUGH an open SidecarRepository when the
|
||||
// editor already has the doc open (no race), else a transient handle.
|
||||
//
|
||||
// GRACEFUL DEGRADATION / HONESTY:
|
||||
// * The OCR engine is whatever OcrService/OcrEngine resolves to: the bundled
|
||||
// ONNX PP-OCR recognizer if present, else the native Windows OCR
|
||||
// MethodChannel, else NONE. On a platform/CI without any backend, OCR
|
||||
// returns null and a rasterized PDF simply gets no `pageText` — no crash,
|
||||
// and the embedded-text path still works.
|
||||
// * pdfrx render + native OCR can only be exercised on-device. The pdfrx-
|
||||
// backed loaders are injected through [PdfTextIndexer] so unit tests fake
|
||||
// them; the production wiring lives in [PdfrxPageTextSource].
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import '../editor/persistence/sidecar_repository.dart';
|
||||
import '../storage/badnote_sidecar.dart';
|
||||
import '../storage/sidecar_store.dart';
|
||||
|
||||
/// Extracts a PDF's embedded text layer, one entry per page (page order). An
|
||||
/// empty list (or all-empty entries) means "no usable text layer".
|
||||
typedef PdfEmbeddedTextLoader = Future<List<String>> Function(String pdfPath);
|
||||
|
||||
/// OCRs the rendered pages of a rasterized PDF, returning one entry per page
|
||||
/// (page order). Entries may be empty where a page yielded nothing. Returns an
|
||||
/// empty list when no OCR backend is available (a clean no-op).
|
||||
typedef PdfPageOcrRunner = Future<List<String>> Function(String pdfPath);
|
||||
|
||||
/// Indexes a PDF's document body into its sidecar's `pageText` at import time.
|
||||
///
|
||||
/// Stateless apart from the two injected text sources; safe to construct per
|
||||
/// import. All disk/native work is awaited internally — call [indexPdf] without
|
||||
/// awaiting (fire-and-forget) from the import handler to keep import snappy.
|
||||
class PdfTextIndexer {
|
||||
PdfTextIndexer({
|
||||
required PdfEmbeddedTextLoader loadEmbeddedText,
|
||||
required PdfPageOcrRunner ocrPages,
|
||||
this.textLayerThreshold = 16,
|
||||
}) : _loadEmbeddedText = loadEmbeddedText,
|
||||
_ocrPages = ocrPages;
|
||||
|
||||
final PdfEmbeddedTextLoader _loadEmbeddedText;
|
||||
final PdfPageOcrRunner _ocrPages;
|
||||
|
||||
/// Minimum total embedded-text length (across all pages, after trimming) for a
|
||||
/// PDF to count as having a usable text layer. Below this it is treated as
|
||||
/// rasterized and routed to OCR. Small on purpose: a scanned PDF typically
|
||||
/// yields zero or a few stray ligature chars, while any real text page clears
|
||||
/// it easily.
|
||||
final int textLayerThreshold;
|
||||
|
||||
/// The page separator stored inside `pageText` (form feed). The search index
|
||||
/// treats `pageText` as a flat blob, so this is purely cosmetic / future-proof.
|
||||
static const String pageSeparator = '\f';
|
||||
|
||||
/// Index the PDF at [pdfPath] (an in-vault copy) into its sidecar's `pageText`.
|
||||
///
|
||||
/// Idempotent: returns immediately if the sidecar already has non-empty
|
||||
/// `pageText`. Never throws — any failure (unreadable PDF, missing OCR backend)
|
||||
/// degrades to leaving `pageText` unset. Returns the text it persisted (for
|
||||
/// tests), or null when nothing was indexed.
|
||||
Future<String?> indexPdf(String pdfPath) async {
|
||||
try {
|
||||
// 1. Idempotency: skip a doc whose body has already been indexed.
|
||||
final existing = await _currentPageText(pdfPath);
|
||||
if (existing != null && existing.trim().isNotEmpty) return null;
|
||||
|
||||
// 2. Embedded text layer.
|
||||
final embedded = await _loadEmbeddedText(pdfPath);
|
||||
final embeddedLen = embedded.fold<int>(
|
||||
0,
|
||||
(sum, page) => sum + page.trim().length,
|
||||
);
|
||||
|
||||
// 3. Text-layer vs rasterized decision.
|
||||
List<String> pages;
|
||||
if (embeddedLen >= textLayerThreshold) {
|
||||
pages = embedded;
|
||||
} else {
|
||||
// Rasterized (scanned, no text layer) → background OCR.
|
||||
pages = await _ocrPages(pdfPath);
|
||||
}
|
||||
|
||||
final joined = _joinPages(pages);
|
||||
if (joined.isEmpty) return null;
|
||||
|
||||
// 4. Persist into the sidecar (through an open repo if the editor holds it).
|
||||
await _persistPageText(pdfPath, joined);
|
||||
return joined;
|
||||
} catch (_) {
|
||||
// Background indexing must never surface an error to the import flow.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the embedded text in [embedded] clears [textLayerThreshold], i.e.
|
||||
/// the PDF has a usable text layer (false → rasterized, needs OCR). Exposed for
|
||||
/// unit-testing the decision in isolation.
|
||||
bool hasUsableTextLayer(List<String> embedded) {
|
||||
final len = embedded.fold<int>(0, (sum, p) => sum + p.trim().length);
|
||||
return len >= textLayerThreshold;
|
||||
}
|
||||
|
||||
static String _joinPages(List<String> pages) {
|
||||
final nonEmpty = pages.map((p) => p.trim()).where((p) => p.isNotEmpty);
|
||||
return nonEmpty.join(pageSeparator).trim();
|
||||
}
|
||||
|
||||
/// Read the sidecar's current `pageText` (for the idempotency check), from the
|
||||
/// open repo if present else from disk. Null when no sidecar exists yet.
|
||||
Future<String?> _currentPageText(String pdfPath) async {
|
||||
final open = SidecarRepositoryRegistry.forPath(pdfPath);
|
||||
if (open != null) return open.loadedPageText;
|
||||
final sidecar = await SidecarStore.read(
|
||||
File('$pdfPath$kSidecarSuffix'),
|
||||
);
|
||||
return sidecar?.pageText;
|
||||
}
|
||||
|
||||
/// Write [pageText] into the sidecar. Prefer the editor's already-open repo
|
||||
/// (same in-memory sidecar — no race); otherwise merge into the on-disk
|
||||
/// sidecar (creating one if the editor hasn't yet).
|
||||
Future<void> _persistPageText(String pdfPath, String pageText) async {
|
||||
final open = SidecarRepositoryRegistry.forPath(pdfPath);
|
||||
if (open != null) {
|
||||
open.schedulePageTextSave(pageText);
|
||||
await open.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
final file = File('$pdfPath$kSidecarSuffix');
|
||||
final current = await SidecarStore.read(file);
|
||||
final merged = _withPageText(current, pdfPath, pageText);
|
||||
await SidecarStore.writeAtomic(file, merged);
|
||||
}
|
||||
|
||||
/// Build a sidecar carrying [pageText], preserving every other field of
|
||||
/// [current] (or a fresh minimal sidecar when none exists yet).
|
||||
static BadnoteSidecar _withPageText(
|
||||
BadnoteSidecar? current,
|
||||
String pdfPath,
|
||||
String pageText,
|
||||
) {
|
||||
if (current == null) {
|
||||
return BadnoteSidecar(
|
||||
sourceFile: _basename(pdfPath),
|
||||
docType: 'pdf',
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
pageText: pageText,
|
||||
);
|
||||
}
|
||||
return BadnoteSidecar(
|
||||
version: current.version,
|
||||
sourceFile: current.sourceFile,
|
||||
docType: current.docType,
|
||||
title: current.title,
|
||||
pageCount: current.pageCount,
|
||||
rotation: current.rotation,
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: DateTime.now().toUtc(),
|
||||
strokes: current.strokes,
|
||||
highlights: current.highlights,
|
||||
texts: current.texts,
|
||||
bookmarks: current.bookmarks,
|
||||
scratchLinks: current.scratchLinks,
|
||||
legacyAnnotations: current.legacyAnnotations,
|
||||
ocrText: current.ocrText,
|
||||
pageText: pageText,
|
||||
legacyId: current.legacyId,
|
||||
background: current.background,
|
||||
);
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final norm = path.replaceAll('\\', '/');
|
||||
final i = norm.lastIndexOf('/');
|
||||
return i == -1 ? norm : norm.substring(i + 1);
|
||||
}
|
||||
}
|
||||
111
lib/services/pdfrx_page_text_source.dart
Normal file
111
lib/services/pdfrx_page_text_source.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
// lib/services/pdfrx_page_text_source.dart
|
||||
//
|
||||
// Production wiring for [PdfTextIndexer]'s two injected text sources, backed by
|
||||
// pdfrx (the same engine the editor renders with). Kept SEPARATE from
|
||||
// PdfTextIndexer so the indexer's logic (the threshold decision, idempotency,
|
||||
// sidecar persistence) is unit-testable without the native pdfium/OCR stack —
|
||||
// only this file touches pdfrx, dart:ui, and the OCR engine, and it is exercised
|
||||
// on-device, not in CI.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import 'ocr_engine.dart';
|
||||
|
||||
/// pdfrx-backed loaders for [PdfTextIndexer].
|
||||
class PdfrxPageTextSource {
|
||||
const PdfrxPageTextSource._();
|
||||
|
||||
/// Load the embedded text layer of every page (page order). Each entry is a
|
||||
/// page's raw text (possibly empty). Returns an empty list on any failure, so
|
||||
/// the indexer treats the PDF as having no text layer (→ OCR fallback).
|
||||
static Future<List<String>> loadEmbeddedText(String pdfPath) async {
|
||||
PdfDocument? doc;
|
||||
try {
|
||||
doc = await PdfDocument.openFile(pdfPath);
|
||||
final out = <String>[];
|
||||
for (final page in doc.pages) {
|
||||
final raw = await page.loadText();
|
||||
out.add(raw?.fullText ?? '');
|
||||
}
|
||||
return out;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
} finally {
|
||||
await doc?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Render each page and OCR it (page order). Returns one entry per page
|
||||
/// (empty where nothing was recognized), or an empty list when the PDF can't
|
||||
/// be opened. Honours the OCR engine's own graceful no-op: when no backend is
|
||||
/// available every page comes back empty.
|
||||
///
|
||||
/// Rendering is done at [renderScale]× the page's native 72-dpi size to give
|
||||
/// the recognizer enough resolution on scanned scans without exploding memory.
|
||||
static Future<List<String>> ocrPages(
|
||||
String pdfPath, {
|
||||
double renderScale = 2.0,
|
||||
}) async {
|
||||
PdfDocument? doc;
|
||||
try {
|
||||
doc = await PdfDocument.openFile(pdfPath);
|
||||
final out = <String>[];
|
||||
for (final page in doc.pages) {
|
||||
final text = await _ocrOnePage(page, renderScale);
|
||||
out.add(text ?? '');
|
||||
}
|
||||
return out;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
} finally {
|
||||
await doc?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _ocrOnePage(PdfPage page, double renderScale) async {
|
||||
PdfImage? image;
|
||||
try {
|
||||
final fullWidth = page.width * renderScale;
|
||||
final fullHeight = page.height * renderScale;
|
||||
image = await page.render(
|
||||
fullWidth: fullWidth,
|
||||
fullHeight: fullHeight,
|
||||
);
|
||||
if (image == null) return null;
|
||||
final png = await _bgraToPng(image.pixels, image.width, image.height);
|
||||
if (png == null) return null;
|
||||
return OcrEngine.recognizeImage(png);
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
image?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode pdfrx's BGRA8888 raw pixels as PNG (the format [OcrEngine] expects).
|
||||
static Future<Uint8List?> _bgraToPng(
|
||||
Uint8List bgra,
|
||||
int width,
|
||||
int height,
|
||||
) async {
|
||||
final completer = Completer<ui.Image>();
|
||||
ui.decodeImageFromPixels(
|
||||
bgra,
|
||||
width,
|
||||
height,
|
||||
ui.PixelFormat.bgra8888,
|
||||
completer.complete,
|
||||
);
|
||||
final image = await completer.future;
|
||||
try {
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
return data?.buffer.asUint8List();
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,83 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Service for processing PPTX files: text extraction, image conversion, file picking.
|
||||
import 'office/office_document.dart';
|
||||
import 'office/pptx_parser.dart';
|
||||
|
||||
/// Service for processing PPTX files: text extraction, structured slide parse,
|
||||
/// optional LibreOffice image conversion, and file picking.
|
||||
///
|
||||
/// PPTX files are ZIP archives containing XML. We extract text from
|
||||
/// `ppt/slides/slide*.xml` `<a:t>` elements and convert slides to images
|
||||
/// using LibreOffice (headless) or generate placeholder images as fallback.
|
||||
/// **Native OOXML parsing is primary** ([PptxParser] via `package:archive` +
|
||||
/// `package:xml`). LibreOffice (`soffice`) is an optional fallback ONLY when
|
||||
/// the native path fails AND the binary is present on the machine.
|
||||
class PptxService {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
final PptxParser _parser;
|
||||
|
||||
PptxService({PptxParser? parser}) : _parser = parser ?? PptxParser();
|
||||
|
||||
/// Extract all text content from a PPTX file.
|
||||
///
|
||||
/// PPTX is a ZIP archive. Slide text lives in `ppt/slides/slide*.xml`
|
||||
/// inside `<a:t>` (ASCII text) elements within `<a:r>` (run) or
|
||||
/// `<a:p>` (paragraph) nodes.
|
||||
/// Prefers the native [PptxParser]. Falls back to a legacy unzip+regex path
|
||||
/// only if native parsing throws.
|
||||
Future<String> extractText(String pptxPath) async {
|
||||
try {
|
||||
final parsed = await _parser.parse(pptxPath);
|
||||
return parsed.plainText;
|
||||
} catch (_) {
|
||||
return _extractTextLegacy(pptxPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse PPTX into structured slides (text runs with approximate positions,
|
||||
/// embedded images extracted to a cache dir). Native-only — no LibreOffice.
|
||||
Future<ParsedPptx> parseSlides(String pptxPath, {String? cacheDir}) {
|
||||
return _parser.parse(pptxPath, cacheDirPath: cacheDir);
|
||||
}
|
||||
|
||||
/// Convert PPTX slides to a list of image file paths (legacy PenSlideScreen).
|
||||
///
|
||||
/// Prefer [parseSlides] for native text+image rendering. This method only
|
||||
/// invokes LibreOffice when native parse fails AND `soffice` exists;
|
||||
/// otherwise it emits placeholder PNGs from the native slide count.
|
||||
Future<List<String>> convertToImages(String pptxPath) async {
|
||||
try {
|
||||
final parsed = await _parser.parse(pptxPath);
|
||||
// Native succeeded — do NOT call LibreOffice; placeholders for callers
|
||||
// that still expect image paths. OfficeDocumentScreen uses [parseSlides].
|
||||
if (parsed.slides.isEmpty) return [];
|
||||
return _generatePlaceholderImages(pptxPath);
|
||||
} catch (_) {
|
||||
// Native failed — LibreOffice fallback ONLY if soffice exists.
|
||||
final soffice = await resolveSoffice();
|
||||
if (soffice != null) {
|
||||
final loImages = await _convertViaLibreOffice(pptxPath);
|
||||
if (loImages.isNotEmpty) return loImages;
|
||||
}
|
||||
return _generatePlaceholderImages(pptxPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a file picker dialog and return the selected PPTX path, or null.
|
||||
Future<String?> openPptxFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pptx', 'ppt'],
|
||||
);
|
||||
final files = result?.files;
|
||||
if (files == null || files.isEmpty) return null;
|
||||
return files.first.path;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy / LibreOffice helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<String> _extractTextLegacy(String pptxPath) async {
|
||||
final tmpDir = await _makeTmpDir('pptx_text');
|
||||
|
||||
try {
|
||||
// Unzip the PPTX
|
||||
final unzipResult = await Process.run('unzip', [
|
||||
'-o',
|
||||
'-q',
|
||||
@@ -35,7 +94,6 @@ class PptxService {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Find all slide XML files
|
||||
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
|
||||
if (!await slidesDir.exists()) return '';
|
||||
|
||||
@@ -44,7 +102,6 @@ class PptxService {
|
||||
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
|
||||
.toList();
|
||||
|
||||
// Sort by slide number
|
||||
slideFiles.sort((a, b) {
|
||||
final aNum = _extractSlideNumber(a.path);
|
||||
final bNum = _extractSlideNumber(b.path);
|
||||
@@ -67,47 +124,14 @@ class PptxService {
|
||||
} catch (_) {
|
||||
return '';
|
||||
} finally {
|
||||
// Cleanup
|
||||
try {
|
||||
await tmpDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert PPTX slides to a list of image file paths.
|
||||
///
|
||||
/// Attempts LibreOffice headless conversion first. Falls back to
|
||||
/// generating placeholder slide images (colored rectangles with slide numbers).
|
||||
Future<List<String>> convertToImages(String pptxPath) async {
|
||||
// Try LibreOffice first
|
||||
final loImages = await _convertViaLibreOffice(pptxPath);
|
||||
if (loImages.isNotEmpty) return loImages;
|
||||
|
||||
// Fallback: generate placeholder images
|
||||
return _generatePlaceholderImages(pptxPath);
|
||||
}
|
||||
|
||||
/// Open a file picker dialog and return the selected PPTX path, or null.
|
||||
///
|
||||
/// Uses the cross-platform file_picker package.
|
||||
Future<String?> openPptxFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pptx', 'ppt'],
|
||||
);
|
||||
final files = result?.files;
|
||||
if (files == null || files.isEmpty) return null;
|
||||
return files.first.path;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implementation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract text from PPTX slide XML by finding `<a:t>` content.
|
||||
String _extractTextFromXml(String xml) {
|
||||
final lines = <String>[];
|
||||
// Match <a:t>...</a:t> — handles both <a:t>text</a:t> and <a:t xml:space="preserve">text</a:t>
|
||||
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
|
||||
for (final match in regex.allMatches(xml)) {
|
||||
final text = match.group(1) ?? '';
|
||||
@@ -124,16 +148,16 @@ class PptxService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Try converting via LibreOffice headless.
|
||||
/// LibreOffice fallback — only when native fails or caller wants PNGs and
|
||||
/// soffice is installed.
|
||||
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
|
||||
try {
|
||||
// Check if LibreOffice is available
|
||||
final which = await Process.run('which', ['libreoffice']);
|
||||
if (which.exitCode != 0) return [];
|
||||
final soffice = await resolveSoffice();
|
||||
if (soffice == null) return [];
|
||||
|
||||
final outDir = await _makeTmpDir('pptx_images');
|
||||
|
||||
final result = await Process.run('libreoffice', [
|
||||
final result = await Process.run(soffice, [
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'png',
|
||||
@@ -144,7 +168,6 @@ class PptxService {
|
||||
|
||||
if (result.exitCode != 0) return [];
|
||||
|
||||
// Collect generated PNGs, sorted by name
|
||||
final pngs = await outDir
|
||||
.list()
|
||||
.where((f) => f.path.endsWith('.png'))
|
||||
@@ -153,7 +176,6 @@ class PptxService {
|
||||
|
||||
pngs.sort();
|
||||
|
||||
// Move to a persistent temp location so outDir can be cleaned up
|
||||
final persistDir = await _makeTmpDir('pptx_slides');
|
||||
final persistentPaths = <String>[];
|
||||
for (var i = 0; i < pngs.length; i++) {
|
||||
@@ -163,7 +185,6 @@ class PptxService {
|
||||
persistentPaths.add(dst);
|
||||
}
|
||||
|
||||
// Clean up the LibreOffice output dir
|
||||
try {
|
||||
await outDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
@@ -174,20 +195,68 @@ class PptxService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate placeholder slide images when LibreOffice is not available.
|
||||
///
|
||||
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
|
||||
/// If ImageMagick is not available, writes minimal 1x1 white PNGs as
|
||||
/// last-resort placeholders.
|
||||
/// Resolve the LibreOffice CLI binary, or null when unavailable.
|
||||
static Future<String?> resolveSoffice() async {
|
||||
if (Platform.isWindows) {
|
||||
const candidates = [
|
||||
r'C:\Program Files\LibreOffice\program\soffice.exe',
|
||||
r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
|
||||
];
|
||||
for (final c in candidates) {
|
||||
if (await File(c).exists()) return c;
|
||||
}
|
||||
if (await _whichOk('soffice')) return 'soffice';
|
||||
return null;
|
||||
}
|
||||
if (await _whichOk('libreoffice')) return 'libreoffice';
|
||||
if (await _whichOk('soffice')) return 'soffice';
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<bool> _whichOk(String cmd) async {
|
||||
try {
|
||||
final r = await Process.run('which', [cmd]);
|
||||
return r.exitCode == 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an arbitrary office document (e.g. DOCX) to PDF via LibreOffice.
|
||||
/// Optional — native [DocxParser] is preferred for opening in BadNote.
|
||||
Future<String?> convertToPdf(String sourcePath) async {
|
||||
final soffice = await resolveSoffice();
|
||||
if (soffice == null) return null;
|
||||
|
||||
final outDir = p.dirname(sourcePath);
|
||||
try {
|
||||
final result = await Process.run(soffice, [
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
outDir,
|
||||
sourcePath,
|
||||
]);
|
||||
if (result.exitCode != 0) return null;
|
||||
final pdfPath = p.join(
|
||||
outDir,
|
||||
'${p.basenameWithoutExtension(sourcePath)}.pdf',
|
||||
);
|
||||
if (await File(pdfPath).exists()) return pdfPath;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
|
||||
// Count slides by unzipping and counting slide XML files
|
||||
final slideCount = await _countSlides(pptxPath);
|
||||
final slideCount = await _countSlidesNative(pptxPath);
|
||||
if (slideCount == 0) return [];
|
||||
|
||||
final outDir = await _makeTmpDir('pptx_placeholders');
|
||||
final paths = <String>[];
|
||||
|
||||
// Try ImageMagick
|
||||
final hasConvert = await _hasCommand('convert');
|
||||
|
||||
for (var i = 1; i <= slideCount; i++) {
|
||||
@@ -203,7 +272,16 @@ class PptxService {
|
||||
return paths;
|
||||
}
|
||||
|
||||
Future<int> _countSlides(String pptxPath) async {
|
||||
Future<int> _countSlidesNative(String pptxPath) async {
|
||||
try {
|
||||
final parsed = await _parser.parse(pptxPath);
|
||||
return parsed.slides.length;
|
||||
} catch (_) {
|
||||
return _countSlidesUnzip(pptxPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _countSlidesUnzip(String pptxPath) async {
|
||||
final tmpDir = await _makeTmpDir('pptx_count');
|
||||
try {
|
||||
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
|
||||
@@ -237,8 +315,7 @@ class PptxService {
|
||||
int slideNum,
|
||||
int total,
|
||||
) async {
|
||||
// Light pastel background with slide number
|
||||
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
|
||||
final hue = ((slideNum - 1) * 137) % 360;
|
||||
await Process.run('convert', [
|
||||
'-size',
|
||||
'1920x1080',
|
||||
@@ -256,30 +333,24 @@ class PptxService {
|
||||
]);
|
||||
}
|
||||
|
||||
/// Write a minimal valid 1x1 white PNG as an absolute last resort.
|
||||
/// This is a hand-crafted PNG (IHDR + single white pixel IDAT + IEND).
|
||||
Future<void> _writeMinimalPng(String path) async {
|
||||
// Minimal valid 1x1 white PNG
|
||||
const pngBytes = <int>[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
// IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x0D, // length = 13
|
||||
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||
0x08, 0x02, // bit depth = 8, color type = 2 (RGB)
|
||||
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||
// IDAT chunk
|
||||
0x00, 0x00, 0x00, 0x0C, // length = 12
|
||||
0x49, 0x44, 0x41, 0x54, // "IDAT"
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D,
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02,
|
||||
0x00, 0x00, 0x00,
|
||||
0x90, 0x77, 0x53, 0xDE,
|
||||
0x00, 0x00, 0x00, 0x0C,
|
||||
0x49, 0x44, 0x41, 0x54,
|
||||
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
|
||||
0x01, 0x01, 0x01, 0x00, // compressed data
|
||||
0x18, 0xDD, 0x8D, 0xB4, // CRC
|
||||
// IEND chunk
|
||||
0x00, 0x00, 0x00, 0x00, // length = 0
|
||||
0x49, 0x45, 0x4E, 0x44, // "IEND"
|
||||
0xAE, 0x42, 0x60, 0x82, // CRC
|
||||
0x01, 0x01, 0x01, 0x00,
|
||||
0x18, 0xDD, 0x8D, 0xB4,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4E, 0x44,
|
||||
0xAE, 0x42, 0x60, 0x82,
|
||||
];
|
||||
await File(path).writeAsBytes(pngBytes);
|
||||
}
|
||||
|
||||
194
lib/services/vault_search_index.dart
Normal file
194
lib/services/vault_search_index.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
// lib/services/vault_search_index.dart
|
||||
//
|
||||
// Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||
// storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars
|
||||
// (the source of truth) rather than the demoted SQLite cache.
|
||||
//
|
||||
// What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar:
|
||||
// * the title (standalone notebooks) / source filename (file-backed docs),
|
||||
// * every typed text box — EditorStroke(tool: text).textContent across all
|
||||
// pages,
|
||||
// * the handwriting OCR text persisted in the sidecar's `ocrText` field.
|
||||
//
|
||||
// It ALSO indexes a file-backed PDF's document body text, captured once at
|
||||
// import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded
|
||||
// (printed) text layer for a normal PDF, or background OCR of the rendered pages
|
||||
// for a RASTERIZED / scanned PDF that has no text layer. Matching uses the
|
||||
// existing pure search primitives (normalize / rank / snippet), so CJK substring
|
||||
// search works.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../editor/search/search_ranking.dart';
|
||||
import '../editor/search/search_snippet.dart';
|
||||
import '../storage/badnote_sidecar.dart';
|
||||
import '../storage/sidecar_store.dart';
|
||||
import 'vault_service.dart';
|
||||
|
||||
/// One indexed notebook: where it lives and the text harvested from its sidecar.
|
||||
class VaultSearchEntry {
|
||||
const VaultSearchEntry({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.openPath,
|
||||
required this.docType,
|
||||
required this.text,
|
||||
required this.isNote,
|
||||
});
|
||||
|
||||
/// Stable id (the notebook folder path).
|
||||
final String id;
|
||||
|
||||
/// Display title (note title / source filename).
|
||||
final String title;
|
||||
|
||||
/// Path to pass to the editor: the in-vault source file for docs, or the
|
||||
/// synthetic `<folder>/notebook` note path for standalone notebooks.
|
||||
final String openPath;
|
||||
|
||||
/// `pdf` / `pptx` / `ppt` / `docx` / `notebook`.
|
||||
final String docType;
|
||||
|
||||
/// All searchable text harvested from the sidecar (title + typed text + OCR),
|
||||
/// joined for substring matching.
|
||||
final String text;
|
||||
|
||||
/// True for standalone (free-ink) notebooks, false for file-backed documents.
|
||||
final bool isNote;
|
||||
}
|
||||
|
||||
/// A search hit over the vault: the entry plus a display snippet of the match.
|
||||
class VaultSearchHit {
|
||||
const VaultSearchHit({required this.entry, required this.snippet});
|
||||
|
||||
final VaultSearchEntry entry;
|
||||
final Snippet snippet;
|
||||
}
|
||||
|
||||
/// Builds and queries a scan-based full-text index over the vault sidecars.
|
||||
///
|
||||
/// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a
|
||||
/// pure substring/rank over their harvested text (CJK-safe). Cheap enough to
|
||||
/// rebuild lazily on demand for a single-user vault; there is no background
|
||||
/// thread and no persisted index file (the SQLite cache is no longer the search
|
||||
/// source of truth).
|
||||
///
|
||||
/// HONEST SCOPE (what search covers / does NOT):
|
||||
/// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted
|
||||
/// into a sidecar's `ocrText` field, AND a PDF's document body text persisted
|
||||
/// into `pageText` at import — the embedded text layer, or background OCR of
|
||||
/// a rasterized/scanned PDF (see [PdfTextIndexer]).
|
||||
/// * CAVEAT: `pageText` is only present once import-time indexing has run and
|
||||
/// written it back to the sidecar. A PDF imported before this feature (or
|
||||
/// whose OCR backend was unavailable) has no `pageText`, so only its
|
||||
/// annotations are searchable until it is re-indexed.
|
||||
class VaultSearchIndex {
|
||||
VaultSearchIndex(this._vault);
|
||||
|
||||
final VaultService _vault;
|
||||
|
||||
List<VaultSearchEntry> _entries = const [];
|
||||
bool _built = false;
|
||||
|
||||
/// The entries from the most recent [rebuild] (for tests / inspection).
|
||||
List<VaultSearchEntry> get entries => List.unmodifiable(_entries);
|
||||
|
||||
/// Scan the vault and (re)build the in-memory index. Safe on an empty/missing
|
||||
/// vault (yields an empty index). Never throws on a single unreadable sidecar.
|
||||
Future<void> rebuild() async {
|
||||
final entries = <VaultSearchEntry>[];
|
||||
|
||||
final notebooks = await _vault.scanNotebooks();
|
||||
for (final nb in notebooks) {
|
||||
final sidecar = await SidecarStore.read(
|
||||
File('${nb.sourceFilePath}$kVaultSidecarSuffix'),
|
||||
);
|
||||
entries.add(
|
||||
VaultSearchEntry(
|
||||
id: nb.folderPath,
|
||||
title: nb.filename,
|
||||
openPath: nb.sourceFilePath,
|
||||
docType: nb.docType,
|
||||
text: _harvest(title: nb.filename, sidecar: sidecar),
|
||||
isNote: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final notes = await _vault.scanNotes();
|
||||
for (final note in notes) {
|
||||
final sidecar = await SidecarStore.read(
|
||||
File('${note.notePath}$kVaultSidecarSuffix'),
|
||||
);
|
||||
entries.add(
|
||||
VaultSearchEntry(
|
||||
id: note.folderPath,
|
||||
title: note.title,
|
||||
openPath: note.notePath,
|
||||
docType: 'notebook',
|
||||
text: _harvest(title: note.title, sidecar: sidecar),
|
||||
isNote: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_entries = entries;
|
||||
_built = true;
|
||||
}
|
||||
|
||||
/// Search the index for [query], rebuilding it first if it has never been
|
||||
/// built. Returns the best-matching notebooks/notes, most-relevant first. An
|
||||
/// empty/whitespace query yields no hits.
|
||||
Future<List<VaultSearchHit>> search(String query) async {
|
||||
if (query.trim().isEmpty) return const [];
|
||||
if (!_built) await rebuild();
|
||||
|
||||
final sources = <String, String>{
|
||||
for (final e in _entries) e.id: e.text,
|
||||
};
|
||||
final ranked = rankHits(sources, query);
|
||||
final byId = {for (final e in _entries) e.id: e};
|
||||
|
||||
final hits = <VaultSearchHit>[];
|
||||
for (final hit in ranked) {
|
||||
final entry = byId[hit.ref];
|
||||
if (entry == null) continue;
|
||||
hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet));
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/// Concatenate every searchable string from one sidecar: the [title], every
|
||||
/// typed text box across all pages, and the persisted handwriting OCR text.
|
||||
static String _harvest({
|
||||
required String title,
|
||||
required BadnoteSidecar? sidecar,
|
||||
}) {
|
||||
final parts = <String>[title];
|
||||
if (sidecar != null) {
|
||||
// Typed text boxes on the pen-first text tool (SidecarText), plus any
|
||||
// legacy stroke-embedded textContent.
|
||||
for (final pageTexts in sidecar.texts.values) {
|
||||
for (final t in pageTexts) {
|
||||
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
|
||||
}
|
||||
}
|
||||
for (final pageStrokes in sidecar.strokes.values) {
|
||||
for (final stroke in pageStrokes) {
|
||||
final text = stroke.textContent;
|
||||
if (text != null && text.trim().isNotEmpty) {
|
||||
parts.add(text.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
final ocr = sidecar.ocrText;
|
||||
if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim());
|
||||
// Document body text captured at import: the PDF's embedded text layer,
|
||||
// or background OCR of a rasterized/scanned PDF. Covers the underlying
|
||||
// document, not just the user's annotations.
|
||||
final body = sidecar.pageText;
|
||||
if (body != null && body.trim().isNotEmpty) parts.add(body.trim());
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
}
|
||||
546
lib/services/vault_service.dart
Normal file
546
lib/services/vault_service.dart
Normal file
@@ -0,0 +1,546 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../storage/badnote_sidecar.dart';
|
||||
import '../storage/notebook_manifest.dart';
|
||||
import '../storage/sidecar_store.dart';
|
||||
|
||||
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
|
||||
/// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering
|
||||
/// dependency from the service onto the editor.
|
||||
const String kVaultSidecarSuffix = '.badnote.json';
|
||||
|
||||
/// A notebook discovered by scanning the vault: one folder holding a source
|
||||
/// file (and, optionally, its sidecar). This is the file-backed source of truth
|
||||
/// for the home screen list (the SQLite `documents` table is no longer read).
|
||||
class VaultNotebook {
|
||||
const VaultNotebook({
|
||||
required this.folderPath,
|
||||
required this.sourceFilePath,
|
||||
required this.filename,
|
||||
required this.docType,
|
||||
required this.modified,
|
||||
this.hasSidecar = false,
|
||||
});
|
||||
|
||||
/// Absolute path to the notebook folder.
|
||||
final String folderPath;
|
||||
|
||||
/// Absolute path to the annotatable source file inside the folder.
|
||||
final String sourceFilePath;
|
||||
|
||||
/// Source filename including extension, e.g. `Calculus Lecture 3.pdf`.
|
||||
final String filename;
|
||||
|
||||
/// Lowercased extension without the dot: `pdf` / `pptx` / `ppt` / `docx`.
|
||||
final String docType;
|
||||
|
||||
/// Last-modified time of the source file (used for recency sorting).
|
||||
final DateTime modified;
|
||||
|
||||
/// Whether a `<file>.badnote.json` sidecar exists next to the source file.
|
||||
final bool hasSidecar;
|
||||
}
|
||||
|
||||
/// Basename (without the sidecar suffix) of a standalone notebook's synthetic
|
||||
/// note "source". Opening `SidecarRepository.open('<folder>/notebook', …)`
|
||||
/// therefore writes `<folder>/notebook.badnote.json`.
|
||||
const String kNotebookBaseName = 'notebook';
|
||||
|
||||
/// Full sidecar filename for a standalone (free-ink) notebook folder.
|
||||
const String kNotebookSidecarName = '$kNotebookBaseName$kVaultSidecarSuffix';
|
||||
|
||||
/// A standalone (non-file-backed) free-ink notebook discovered by scanning the
|
||||
/// vault: a folder holding a `notebook.badnote.json` and NO importable source
|
||||
/// file. This is the file-based replacement for the old SQLite `notes` table.
|
||||
class VaultNote {
|
||||
const VaultNote({
|
||||
required this.folderPath,
|
||||
required this.notePath,
|
||||
required this.title,
|
||||
required this.modified,
|
||||
});
|
||||
|
||||
/// Absolute path to the notebook folder.
|
||||
final String folderPath;
|
||||
|
||||
/// Synthetic note "source" path `<folder>/notebook`. Pass this to
|
||||
/// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the
|
||||
/// `<folder>/notebook.badnote.json` sidecar. Doubles as the note's stable id.
|
||||
final String notePath;
|
||||
|
||||
/// Display title (from the sidecar's `title`, falling back to the folder name).
|
||||
final String title;
|
||||
|
||||
/// Last-modified time of the sidecar (used for recency sorting).
|
||||
final DateTime modified;
|
||||
}
|
||||
|
||||
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
|
||||
class VaultContainer {
|
||||
const VaultContainer({
|
||||
required this.folderPath,
|
||||
required this.title,
|
||||
required this.modified,
|
||||
required this.memberCount,
|
||||
});
|
||||
|
||||
final String folderPath;
|
||||
final String title;
|
||||
final DateTime modified;
|
||||
final int memberCount;
|
||||
}
|
||||
|
||||
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
||||
/// gates app startup behind a valid choice.
|
||||
///
|
||||
/// The vault root is the single folder under which all notebooks will live.
|
||||
/// Phase 0 only persists the path and validates it exists; no data is moved
|
||||
/// into the vault yet (later phases do that).
|
||||
///
|
||||
/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is
|
||||
/// easily mockable: inject a [SharedPreferences] (e.g. from
|
||||
/// `SharedPreferences.setMockInitialValues`) via the constructor for tests.
|
||||
class VaultService {
|
||||
/// SharedPreferences key under which the vault root path is stored.
|
||||
static const String vaultRootKey = 'vaultRoot';
|
||||
|
||||
/// SharedPreferences key gating the one-time SQLite→sidecar migration
|
||||
/// (Phase 5). Set true once the migration completes so it never re-runs.
|
||||
static const String vaultMigrationDoneKey = 'vaultMigrationDone';
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
VaultService._(this._prefs);
|
||||
|
||||
static VaultService? _instance;
|
||||
|
||||
/// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads
|
||||
/// the shared [SharedPreferences] instance.
|
||||
static Future<VaultService> getInstance() async {
|
||||
if (_instance != null) return _instance!;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final service = VaultService._(prefs);
|
||||
_instance = service;
|
||||
return service;
|
||||
}
|
||||
|
||||
/// Test-only constructor: inject a (typically mock) [SharedPreferences] so
|
||||
/// the vault root can be exercised without platform channels.
|
||||
@visibleForTesting
|
||||
VaultService.forTest(SharedPreferences prefs) : _prefs = prefs;
|
||||
|
||||
/// Test-only: drop the cached singleton so the next [getInstance] rebuilds.
|
||||
@visibleForTesting
|
||||
static void resetForTest() {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
/// The currently stored vault root path, or null if none has been chosen.
|
||||
String? get vaultRoot => _prefs.getString(vaultRootKey);
|
||||
|
||||
/// Persist [path] as the vault root.
|
||||
Future<void> setVaultRoot(String path) async {
|
||||
await _prefs.setString(vaultRootKey, path);
|
||||
}
|
||||
|
||||
/// Forget the stored vault root (e.g. to re-prompt the user).
|
||||
Future<void> clearVaultRoot() async {
|
||||
await _prefs.remove(vaultRootKey);
|
||||
}
|
||||
|
||||
/// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
|
||||
/// When false, startup runs the migrator before opening the home screen.
|
||||
bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;
|
||||
|
||||
/// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
|
||||
Future<void> setVaultMigrationDone() async {
|
||||
await _prefs.setBool(vaultMigrationDoneKey, true);
|
||||
}
|
||||
|
||||
/// True iff a vault root is set AND that directory currently exists.
|
||||
///
|
||||
/// Returns false when no path is stored or when the stored path no longer
|
||||
/// resolves to a directory (external drive unplugged, folder deleted) — the
|
||||
/// caller then re-prompts rather than silently scattering data elsewhere.
|
||||
Future<bool> vaultRootValid() async {
|
||||
final path = vaultRoot;
|
||||
if (path == null || path.isEmpty) return false;
|
||||
return Directory(path).exists();
|
||||
}
|
||||
|
||||
/// Source-file extensions BadNote can import as notebooks.
|
||||
static const Set<String> importableExtensions = {'pdf', 'docx', 'pptx', 'ppt'};
|
||||
|
||||
/// Create a notebook FOLDER under the vault root, COPY the source file at
|
||||
/// [sourceFilePath] into it, and return the path of the in-vault copy.
|
||||
///
|
||||
/// The folder name is the sanitized source basename (without extension),
|
||||
/// de-duplicated with a numeric suffix on collision (`Lecture`, `Lecture 2`,
|
||||
/// …). The sidecar (`<file>.badnote.json`) will live next to the copy — the
|
||||
/// [SidecarRepository] keys off the returned path, so nothing else is needed.
|
||||
///
|
||||
/// Throws [StateError] if no valid vault root is set.
|
||||
Future<String> createNotebook(String sourceFilePath) async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) {
|
||||
throw StateError('No vault root is set; cannot create a notebook.');
|
||||
}
|
||||
final source = File(sourceFilePath);
|
||||
final filename = p.basename(sourceFilePath);
|
||||
final baseName = _sanitizeFolderName(p.basenameWithoutExtension(filename));
|
||||
|
||||
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||
await folder.create(recursive: true);
|
||||
|
||||
final destPath = p.join(folder.path, filename);
|
||||
await source.copy(destPath);
|
||||
return destPath;
|
||||
}
|
||||
|
||||
/// Scan the vault root for notebook folders. A notebook is a direct
|
||||
/// subfolder (excluding the hidden `.badnote` metadata folder) that contains
|
||||
/// at least one importable source file. Returns the notebooks sorted by
|
||||
/// source-file mtime, most-recent first. An empty / missing vault yields an
|
||||
/// empty list (never throws).
|
||||
/// Scan vault folders that hold a [kNotebookManifestName] container.
|
||||
Future<List<VaultContainer>> scanContainers() async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) return const [];
|
||||
final dir = Directory(root);
|
||||
if (!await dir.exists()) return const [];
|
||||
|
||||
final out = <VaultContainer>[];
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is! Directory) continue;
|
||||
final folderName = p.basename(entity.path);
|
||||
if (folderName.startsWith('.')) continue;
|
||||
final c = await _readContainerFolder(entity);
|
||||
if (c != null) out.add(c);
|
||||
}
|
||||
out.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
|
||||
final manifest = await NotebookManifest.read(folder.path);
|
||||
if (manifest == null) return null;
|
||||
final file = NotebookManifest.fileIn(folder.path);
|
||||
final stat = await file.stat();
|
||||
final title = manifest.title.trim().isNotEmpty
|
||||
? manifest.title.trim()
|
||||
: p.basename(folder.path);
|
||||
return VaultContainer(
|
||||
folderPath: folder.path,
|
||||
title: title,
|
||||
modified: stat.modified,
|
||||
memberCount: manifest.members.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create an OneNote-style notebook container with one blank ink page.
|
||||
Future<VaultContainer> createNotebookContainer(String title) async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) {
|
||||
throw StateError('No vault root is set; cannot create a notebook.');
|
||||
}
|
||||
final trimmed = title.trim();
|
||||
final baseName = _sanitizeFolderName(trimmed);
|
||||
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||
await folder.create(recursive: true);
|
||||
|
||||
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final pageRel = p.join('pages', pageId, kNotebookBaseName);
|
||||
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
|
||||
await pageDir.create(recursive: true);
|
||||
|
||||
final notePath = p.join(folder.path, pageRel);
|
||||
final now = DateTime.now().toUtc();
|
||||
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$notePath$kVaultSidecarSuffix'),
|
||||
BadnoteSidecar(
|
||||
docType: 'notebook',
|
||||
title: pageTitle,
|
||||
pageCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
final manifest = NotebookManifest(
|
||||
title: pageTitle,
|
||||
members: [
|
||||
NotebookMember(
|
||||
id: pageId,
|
||||
kind: NotebookMemberKind.note,
|
||||
relativePath: pageRel.replaceAll('\\', '/'),
|
||||
title: pageTitle,
|
||||
),
|
||||
],
|
||||
);
|
||||
await NotebookManifest.write(folder.path, manifest);
|
||||
|
||||
return VaultContainer(
|
||||
folderPath: folder.path,
|
||||
title: pageTitle,
|
||||
modified: now,
|
||||
memberCount: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Append a blank ink page to an existing container. Returns the new member.
|
||||
Future<NotebookMember> addBlankPageToContainer(
|
||||
String folderPath, {
|
||||
String title = 'Untitled page',
|
||||
}) async {
|
||||
final manifest = await NotebookManifest.read(folderPath);
|
||||
if (manifest == null) {
|
||||
throw StateError('Not a notebook container: $folderPath');
|
||||
}
|
||||
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final pageRel = 'pages/$pageId/$kNotebookBaseName';
|
||||
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
|
||||
await pageDir.create(recursive: true);
|
||||
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
|
||||
final now = DateTime.now().toUtc();
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$notePath$kVaultSidecarSuffix'),
|
||||
BadnoteSidecar(
|
||||
docType: 'notebook',
|
||||
title: title,
|
||||
pageCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
final member = NotebookMember(
|
||||
id: pageId,
|
||||
kind: NotebookMemberKind.note,
|
||||
relativePath: pageRel,
|
||||
title: title,
|
||||
);
|
||||
await NotebookManifest.write(
|
||||
folderPath,
|
||||
manifest.copyWith(members: [...manifest.members, member]),
|
||||
);
|
||||
return member;
|
||||
}
|
||||
|
||||
/// Copy [sourceAbsolutePath] into the container and register it as a member.
|
||||
Future<NotebookMember> importFileIntoContainer(
|
||||
String folderPath,
|
||||
String sourceAbsolutePath,
|
||||
) async {
|
||||
final manifest = await NotebookManifest.read(folderPath);
|
||||
if (manifest == null) {
|
||||
throw StateError('Not a notebook container: $folderPath');
|
||||
}
|
||||
final basename = p.basename(sourceAbsolutePath);
|
||||
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
|
||||
final kind = notebookMemberKindFromExt(ext);
|
||||
if (kind == null || kind == NotebookMemberKind.note) {
|
||||
throw StateError('Unsupported import type: $ext');
|
||||
}
|
||||
final destRel = basename;
|
||||
var destPath = p.join(folderPath, destRel);
|
||||
var n = 2;
|
||||
while (await File(destPath).exists()) {
|
||||
final stem = p.basenameWithoutExtension(basename);
|
||||
destPath = p.join(folderPath, '$stem $n.$ext');
|
||||
n++;
|
||||
}
|
||||
await File(sourceAbsolutePath).copy(destPath);
|
||||
final member = NotebookMember(
|
||||
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
|
||||
kind: kind,
|
||||
relativePath: p.basename(destPath),
|
||||
title: p.basenameWithoutExtension(destPath),
|
||||
);
|
||||
await NotebookManifest.write(
|
||||
folderPath,
|
||||
manifest.copyWith(members: [...manifest.members, member]),
|
||||
);
|
||||
return member;
|
||||
}
|
||||
|
||||
/// Absolute path for a member inside [folderPath].
|
||||
String memberAbsolutePath(String folderPath, NotebookMember member) =>
|
||||
p.normalize(p.join(folderPath, member.relativePath));
|
||||
|
||||
Future<List<VaultNotebook>> scanNotebooks() async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) return const [];
|
||||
final dir = Directory(root);
|
||||
if (!await dir.exists()) return const [];
|
||||
|
||||
final notebooks = <VaultNotebook>[];
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is! Directory) continue;
|
||||
final folderName = p.basename(entity.path);
|
||||
if (folderName.startsWith('.')) continue;
|
||||
// Container folders are listed by [scanContainers], not here.
|
||||
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||
|
||||
final notebook = await _readNotebookFolder(entity);
|
||||
if (notebook != null) notebooks.add(notebook);
|
||||
}
|
||||
|
||||
notebooks.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return notebooks;
|
||||
}
|
||||
|
||||
/// Create an empty (free-ink) standalone notebook FOLDER under the vault root
|
||||
/// named from [title], write an initial `notebook.badnote.json` carrying that
|
||||
/// title (so the scan sees it immediately), and return the synthetic note
|
||||
/// path `<folder>/notebook`.
|
||||
///
|
||||
/// Pass the returned path to `SidecarRepository.open(path, docType:
|
||||
/// 'notebook')`, which keys the folder's `notebook.badnote.json` — there is
|
||||
/// NO fake source file. Throws [StateError] if no valid vault root is set.
|
||||
Future<String> createEmptyNotebook(String title) async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) {
|
||||
throw StateError('No vault root is set; cannot create a notebook.');
|
||||
}
|
||||
final trimmed = title.trim();
|
||||
final baseName = _sanitizeFolderName(trimmed);
|
||||
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||
await folder.create(recursive: true);
|
||||
|
||||
final notePath = p.join(folder.path, kNotebookBaseName);
|
||||
final now = DateTime.now().toUtc();
|
||||
final sidecar = BadnoteSidecar(
|
||||
docType: 'notebook',
|
||||
title: trimmed.isEmpty ? null : trimmed,
|
||||
pageCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$notePath$kVaultSidecarSuffix'),
|
||||
sidecar,
|
||||
);
|
||||
return notePath;
|
||||
}
|
||||
|
||||
/// Scan the vault root for standalone (free-ink) notebook folders: direct
|
||||
/// subfolders (excluding hidden `.` folders) that contain a
|
||||
/// `notebook.badnote.json` and NO importable source file. Returns them sorted
|
||||
/// by sidecar mtime, most-recent first. Missing / empty vault → empty list.
|
||||
///
|
||||
/// File-backed document folders (which DO hold an importable source file) are
|
||||
/// surfaced by [scanNotebooks] instead, so the two scans never overlap.
|
||||
Future<List<VaultNote>> scanNotes() async {
|
||||
final root = vaultRoot;
|
||||
if (root == null || root.isEmpty) return const [];
|
||||
final dir = Directory(root);
|
||||
if (!await dir.exists()) return const [];
|
||||
|
||||
final notes = <VaultNote>[];
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is! Directory) continue;
|
||||
final folderName = p.basename(entity.path);
|
||||
if (folderName.startsWith('.')) continue;
|
||||
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||
|
||||
final note = await _readNoteFolder(entity);
|
||||
if (note != null) notes.add(note);
|
||||
}
|
||||
|
||||
notes.sort((a, b) => b.modified.compareTo(a.modified));
|
||||
return notes;
|
||||
}
|
||||
|
||||
/// Inspect a folder, returning a [VaultNote] iff it holds a
|
||||
/// `notebook.badnote.json` and NO importable source file, else null.
|
||||
Future<VaultNote?> _readNoteFolder(Directory folder) async {
|
||||
File? noteSidecar;
|
||||
var hasSource = false;
|
||||
await for (final entity in folder.list(followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
final name = p.basename(entity.path);
|
||||
if (name == kNotebookSidecarName) {
|
||||
noteSidecar = entity;
|
||||
continue;
|
||||
}
|
||||
if (name.endsWith(kVaultSidecarSuffix)) continue;
|
||||
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
|
||||
if (importableExtensions.contains(ext)) hasSource = true;
|
||||
}
|
||||
if (noteSidecar == null || hasSource) return null;
|
||||
|
||||
final notePath = p.join(folder.path, kNotebookBaseName);
|
||||
final loaded = await SidecarStore.read(noteSidecar);
|
||||
final stat = await noteSidecar.stat();
|
||||
final title = (loaded?.title?.trim().isNotEmpty ?? false)
|
||||
? loaded!.title!.trim()
|
||||
: p.basename(folder.path);
|
||||
return VaultNote(
|
||||
folderPath: folder.path,
|
||||
notePath: notePath,
|
||||
title: title,
|
||||
modified: stat.modified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inspect a single notebook folder, returning a [VaultNotebook] when it
|
||||
/// holds an importable source file, else null. Picks the first importable
|
||||
/// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF).
|
||||
Future<VaultNotebook?> _readNotebookFolder(Directory folder) async {
|
||||
File? chosen;
|
||||
String? chosenExt;
|
||||
await for (final entity in folder.list(followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
final name = p.basename(entity.path);
|
||||
if (name.endsWith(kVaultSidecarSuffix)) continue;
|
||||
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
|
||||
if (!importableExtensions.contains(ext)) continue;
|
||||
// Prefer a PDF artifact when present (DOCX-converted notebooks keep both).
|
||||
if (chosen == null || (ext == 'pdf' && chosenExt != 'pdf')) {
|
||||
chosen = entity;
|
||||
chosenExt = ext;
|
||||
}
|
||||
}
|
||||
if (chosen == null || chosenExt == null) return null;
|
||||
|
||||
final stat = await chosen.stat();
|
||||
final sidecar = File('${chosen.path}$kVaultSidecarSuffix');
|
||||
return VaultNotebook(
|
||||
folderPath: folder.path,
|
||||
sourceFilePath: chosen.path,
|
||||
filename: p.basename(chosen.path),
|
||||
docType: chosenExt,
|
||||
modified: stat.modified,
|
||||
hasSidecar: await sidecar.exists(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Find an unused notebook folder under [root] for [baseName], appending a
|
||||
/// ` 2`, ` 3`, … suffix on collision.
|
||||
Future<Directory> _uniqueNotebookFolder(String root, String baseName) async {
|
||||
final safeBase = baseName.isEmpty ? 'Untitled' : baseName;
|
||||
var candidate = Directory(p.join(root, safeBase));
|
||||
var n = 2;
|
||||
while (await candidate.exists()) {
|
||||
candidate = Directory(p.join(root, '$safeBase $n'));
|
||||
n++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// Sanitize a basename into a safe folder name: strip characters illegal on
|
||||
/// Windows/POSIX (`\ / : * ? " < > |`) and control chars, collapse
|
||||
/// whitespace, and trim trailing dots/spaces (illegal on Windows).
|
||||
static String _sanitizeFolderName(String name) {
|
||||
final cleaned = name
|
||||
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'[. ]+$'), '');
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
348
lib/services/webdav_client.dart
Normal file
348
lib/services/webdav_client.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
// lib/services/webdav_client.dart
|
||||
//
|
||||
// A minimal WebDAV client abstraction for vault sync. The [WebDavClient]
|
||||
// interface is intentionally tiny (the four verbs the sync algorithm needs:
|
||||
// list / download / upload / mkcol) so that:
|
||||
// * the sync ALGORITHM in WebDavSyncService can be unit-tested against a
|
||||
// FAKE in-memory implementation (no real server), and
|
||||
// * the real network adapter ([HttpWebDavClient]) stays a thin shim over
|
||||
// `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL).
|
||||
//
|
||||
// Paths handled here are REMOTE paths relative to the configured remote root,
|
||||
// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths
|
||||
// to/from these remote paths lives in WebDavSyncService.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// One remote resource returned by a directory listing (PROPFIND).
|
||||
class RemoteEntry {
|
||||
const RemoteEntry({
|
||||
required this.path,
|
||||
required this.isDirectory,
|
||||
this.modified,
|
||||
this.size,
|
||||
this.etag,
|
||||
});
|
||||
|
||||
/// Remote path RELATIVE to the configured remote root, forward-slashed and
|
||||
/// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no
|
||||
/// trailing slash here (normalized by the client).
|
||||
final String path;
|
||||
|
||||
/// Whether this entry is a collection (directory) rather than a file.
|
||||
final bool isDirectory;
|
||||
|
||||
/// Server last-modified time (UTC) if the server reported one.
|
||||
final DateTime? modified;
|
||||
|
||||
/// Content length in bytes if reported (files only).
|
||||
final int? size;
|
||||
|
||||
/// Weak/strong ETag if reported (quotes stripped).
|
||||
final String? etag;
|
||||
}
|
||||
|
||||
/// Thrown by [WebDavClient] implementations for any transport/protocol error.
|
||||
/// Carries a human-readable [message] suitable for surfacing in the UI.
|
||||
class WebDavException implements Exception {
|
||||
WebDavException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
String toString() => 'WebDavException($message'
|
||||
'${statusCode != null ? ', status: $statusCode' : ''})';
|
||||
}
|
||||
|
||||
/// The four WebDAV operations the sync algorithm depends on. Inject a fake in
|
||||
/// tests; inject [HttpWebDavClient] in production.
|
||||
abstract class WebDavClient {
|
||||
/// List the immediate-and-nested files under [remoteDir] (relative to the
|
||||
/// remote root, `''` meaning the root itself). Returns every FILE found in
|
||||
/// the subtree (directories are created on demand via [makeCollection], so
|
||||
/// callers care about files). Implementations PROPFIND with Depth: infinity
|
||||
/// and flatten the result. A missing remote dir yields an empty list.
|
||||
Future<List<RemoteEntry>> list(String remoteDir);
|
||||
|
||||
/// Download the bytes of the remote file at [remotePath].
|
||||
Future<Uint8List> download(String remotePath);
|
||||
|
||||
/// Upload [bytes] to [remotePath], creating/overwriting the remote file.
|
||||
/// Parent collections must already exist (use [makeCollection]).
|
||||
Future<void> upload(String remotePath, Uint8List bytes);
|
||||
|
||||
/// Create the collection (directory) at [remotePath]. Idempotent: an
|
||||
/// already-existing collection is not an error.
|
||||
Future<void> makeCollection(String remotePath);
|
||||
|
||||
/// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
|
||||
/// Throws [WebDavException] on failure; returns normally on success.
|
||||
Future<void> testConnection();
|
||||
}
|
||||
|
||||
/// Real WebDAV adapter over `package:http`. Thin by design — all the sync
|
||||
/// decision logic lives in WebDavSyncService, NOT here.
|
||||
///
|
||||
/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips
|
||||
/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below
|
||||
/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync
|
||||
/// algorithm that consumes it is what the unit tests cover, via a fake client.
|
||||
class HttpWebDavClient implements WebDavClient {
|
||||
HttpWebDavClient({
|
||||
required String baseUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String remoteRoot = '',
|
||||
http.Client? httpClient,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
}) : _client = httpClient ?? http.Client(),
|
||||
_ownsClient = httpClient == null,
|
||||
_baseUri = _normalizeBase(baseUrl, remoteRoot),
|
||||
_authHeader =
|
||||
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||
|
||||
final http.Client _client;
|
||||
final bool _ownsClient;
|
||||
|
||||
/// Absolute base URI INCLUDING the remote root path, always ending in `/`.
|
||||
final Uri _baseUri;
|
||||
final String _authHeader;
|
||||
final Duration timeout;
|
||||
|
||||
/// Combine the server [baseUrl] with the [remoteRoot] folder into a single
|
||||
/// absolute base URI ending in a slash. Tolerates trailing/leading slashes.
|
||||
static Uri _normalizeBase(String baseUrl, String remoteRoot) {
|
||||
var base = baseUrl.trim();
|
||||
if (!base.endsWith('/')) base = '$base/';
|
||||
var uri = Uri.parse(base);
|
||||
final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (root.isNotEmpty) {
|
||||
uri = uri.resolve('${Uri.encodeFull(root)}/');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/// Resolve a remote-root-relative [remotePath] to an absolute URI.
|
||||
Uri _resolve(String remotePath) {
|
||||
final clean = remotePath.replaceAll(RegExp(r'^/+'), '');
|
||||
if (clean.isEmpty) return _baseUri;
|
||||
// Encode each segment but keep the slashes.
|
||||
final encoded = clean.split('/').map(Uri.encodeComponent).join('/');
|
||||
return _baseUri.resolve(encoded);
|
||||
}
|
||||
|
||||
Map<String, String> get _headers => {'Authorization': _authHeader};
|
||||
|
||||
@override
|
||||
Future<void> testConnection() async {
|
||||
final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw WebDavException(
|
||||
'Server responded ${res.statusCode}',
|
||||
statusCode: res.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RemoteEntry>> list(String remoteDir) async {
|
||||
final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/');
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'});
|
||||
} on WebDavException {
|
||||
rethrow;
|
||||
}
|
||||
if (res.statusCode == 404) return const [];
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw WebDavException(
|
||||
'PROPFIND failed (${res.statusCode})',
|
||||
statusCode: res.statusCode,
|
||||
);
|
||||
}
|
||||
return _parseMultiStatus(res.body);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> download(String remotePath) async {
|
||||
final res = await _get(_resolve(remotePath));
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw WebDavException(
|
||||
'Download failed (${res.statusCode})',
|
||||
statusCode: res.statusCode,
|
||||
);
|
||||
}
|
||||
return res.bodyBytes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upload(String remotePath, Uint8List bytes) async {
|
||||
final res = await _put(_resolve(remotePath), bytes);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw WebDavException(
|
||||
'Upload failed (${res.statusCode})',
|
||||
statusCode: res.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> makeCollection(String remotePath) async {
|
||||
final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/');
|
||||
final res = await _send('MKCOL', uri);
|
||||
// 201 created; 405 method-not-allowed means it already exists (fine).
|
||||
if (res.statusCode == 201 || res.statusCode == 405) return;
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw WebDavException(
|
||||
'MKCOL failed (${res.statusCode})',
|
||||
statusCode: res.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping
|
||||
/// collections. Paths are made relative to [_baseUri]'s path and stripped of
|
||||
/// a leading slash.
|
||||
List<RemoteEntry> _parseMultiStatus(String body) {
|
||||
final doc = XmlDocument.parse(body);
|
||||
final basePath = _baseUri.path; // ends with '/'
|
||||
final entries = <RemoteEntry>[];
|
||||
|
||||
for (final response in doc.findAllElements('response', namespace: '*')) {
|
||||
final href = response
|
||||
.findElements('href', namespace: '*')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((_) => true, orElse: () => '');
|
||||
if (href.isEmpty) continue;
|
||||
|
||||
// href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative
|
||||
// (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath.
|
||||
var hrefPath = Uri.parse(href).path;
|
||||
hrefPath = Uri.decodeFull(hrefPath);
|
||||
final decodedBase = Uri.decodeFull(basePath);
|
||||
if (!hrefPath.startsWith(decodedBase)) {
|
||||
// Some servers omit the app prefix; try a looser suffix match.
|
||||
final idx = hrefPath.indexOf(decodedBase);
|
||||
if (idx < 0) continue;
|
||||
hrefPath = hrefPath.substring(idx);
|
||||
}
|
||||
var rel = hrefPath.substring(decodedBase.length);
|
||||
final isDir = rel.endsWith('/');
|
||||
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
if (rel.isEmpty) continue; // the root collection itself
|
||||
|
||||
final propstat = response.findElements('propstat', namespace: '*');
|
||||
DateTime? modified;
|
||||
int? size;
|
||||
String? etag;
|
||||
var collection = isDir;
|
||||
for (final ps in propstat) {
|
||||
for (final prop in ps.findElements('prop', namespace: '*')) {
|
||||
final lm = prop
|
||||
.findElements('getlastmodified', namespace: '*')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((_) => true, orElse: () => '');
|
||||
if (lm.isNotEmpty) modified = _parseHttpDate(lm);
|
||||
final cl = prop
|
||||
.findElements('getcontentlength', namespace: '*')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((_) => true, orElse: () => '');
|
||||
if (cl.isNotEmpty) size = int.tryParse(cl);
|
||||
final et = prop
|
||||
.findElements('getetag', namespace: '*')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((_) => true, orElse: () => '');
|
||||
if (et.isNotEmpty) etag = et.replaceAll('"', '');
|
||||
if (prop.findAllElements('collection', namespace: '*').isNotEmpty) {
|
||||
collection = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (collection) continue; // sync only cares about files
|
||||
entries.add(RemoteEntry(
|
||||
path: rel,
|
||||
isDirectory: false,
|
||||
modified: modified?.toUtc(),
|
||||
size: size,
|
||||
etag: etag,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
static DateTime? _parseHttpDate(String s) {
|
||||
try {
|
||||
return parseHttpDate(s);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _send(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
final req = http.Request(method, uri)..headers.addAll(_headers);
|
||||
if (headers != null) req.headers.addAll(headers);
|
||||
try {
|
||||
final streamed = await _client.send(req).timeout(timeout);
|
||||
return http.Response.fromStream(streamed);
|
||||
} on WebDavException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw WebDavException('Network error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _get(Uri uri) async {
|
||||
try {
|
||||
return await _client.get(uri, headers: _headers).timeout(timeout);
|
||||
} catch (e) {
|
||||
throw WebDavException('Network error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> _put(Uri uri, Uint8List bytes) async {
|
||||
try {
|
||||
return await _client
|
||||
.put(uri, headers: _headers, body: bytes)
|
||||
.timeout(timeout);
|
||||
} catch (e) {
|
||||
throw WebDavException('Network error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the underlying [http.Client] if this instance created it.
|
||||
void close() {
|
||||
if (_ownsClient) _client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather
|
||||
/// than pulling `http_parser`) since only `getlastmodified` needs it.
|
||||
DateTime? parseHttpDate(String input) {
|
||||
final s = input.trim();
|
||||
// RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT"
|
||||
final months = {
|
||||
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
||||
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12,
|
||||
};
|
||||
final m = RegExp(
|
||||
r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})',
|
||||
).firstMatch(s);
|
||||
if (m == null) return null;
|
||||
final day = int.parse(m.group(1)!);
|
||||
final month = months[m.group(2)!];
|
||||
if (month == null) return null;
|
||||
final year = int.parse(m.group(3)!);
|
||||
final hour = int.parse(m.group(4)!);
|
||||
final min = int.parse(m.group(5)!);
|
||||
final sec = int.parse(m.group(6)!);
|
||||
return DateTime.utc(year, month, day, hour, min, sec);
|
||||
}
|
||||
573
lib/services/webdav_sync_service.dart
Normal file
573
lib/services/webdav_sync_service.dart
Normal file
@@ -0,0 +1,573 @@
|
||||
// lib/services/webdav_sync_service.dart
|
||||
//
|
||||
// Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote.
|
||||
// The vault is a flat folder of notebook subfolders, each holding a source file
|
||||
// plus its `<file>.badnote.json` sidecar; sync operates on the FILES only and
|
||||
// never touches editors or storage formats.
|
||||
//
|
||||
// DESIGN: all sync DECISION logic (per-file winner, conflict handling, path
|
||||
// mapping, last-synced bookkeeping) lives here and is injected with a
|
||||
// [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires
|
||||
// an [HttpWebDavClient]. The real network round-trip is device/server-validated
|
||||
// only (no WebDAV server in CI).
|
||||
//
|
||||
// ALGORITHM (per file, keyed by its vault-root-relative path):
|
||||
// Let L = local state (exists? mtime), R = remote state (exists? mtime),
|
||||
// and B = the per-file LAST-SYNCED baseline we stored after the previous sync
|
||||
// (the mtime we last reconciled to, or absent for never-synced files).
|
||||
//
|
||||
// * local-only (L, !R) -> upload L (create remote dirs)
|
||||
// * remote-only (!L, R):
|
||||
// - known-before (B present) -> remote was DELETED by peer? We
|
||||
// do NOT delete locally (conservative);
|
||||
// we re-UPLOAD to restore. [see note]
|
||||
// - never-seen (B absent) -> download R
|
||||
// * both exist (L, R):
|
||||
// - localChanged = L.mtime != B.mtime (or B absent)
|
||||
// - remoteChanged = R.mtime != B.mtime (or B absent)
|
||||
// - neither changed -> skip
|
||||
// - only local changed -> upload L
|
||||
// - only remote changed -> download R
|
||||
// - BOTH changed (true conflict) -> last-write-wins by mtime:
|
||||
// keep the WINNER as the canonical file, write the LOSER's bytes to
|
||||
// `<file>.conflict-<winnerMtimeMillis>` on BOTH sides so NO data is
|
||||
// lost, then converge canonical (upload or download as needed).
|
||||
// After acting, record the converged mtime as the new baseline B.
|
||||
//
|
||||
// Deletes are handled CONSERVATIVELY: we never delete a file on either side as a
|
||||
// result of sync. A file vanishing on one side is treated as "restore from the
|
||||
// other side" rather than "propagate the delete", because we cannot distinguish
|
||||
// an intentional delete from a half-finished transfer without a tombstone log
|
||||
// (a deliberate TODO — see report).
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'webdav_client.dart';
|
||||
|
||||
/// Persisted WebDAV connection + sync configuration.
|
||||
class WebDavConfig {
|
||||
const WebDavConfig({
|
||||
required this.baseUrl,
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.remoteRoot = '',
|
||||
this.autoSync = false,
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
|
||||
/// Folder under the server's WebDAV root to sync into, e.g. `BadNote`.
|
||||
final String remoteRoot;
|
||||
|
||||
/// When true, sync runs on launch/resume (non-blocking, failure-tolerant).
|
||||
final bool autoSync;
|
||||
|
||||
/// True once enough is set to attempt a sync (URL present).
|
||||
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||
|
||||
WebDavConfig copyWith({
|
||||
String? baseUrl,
|
||||
String? username,
|
||||
String? password,
|
||||
String? remoteRoot,
|
||||
bool? autoSync,
|
||||
}) {
|
||||
return WebDavConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
remoteRoot: remoteRoot ?? this.remoteRoot,
|
||||
autoSync: autoSync ?? this.autoSync,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI.
|
||||
class SyncResult {
|
||||
const SyncResult({
|
||||
this.uploaded = 0,
|
||||
this.downloaded = 0,
|
||||
this.conflicts = 0,
|
||||
this.skipped = 0,
|
||||
this.finishedAt,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int conflicts;
|
||||
final int skipped;
|
||||
final DateTime? finishedAt;
|
||||
|
||||
/// Friendly error message when the run failed wholesale; null on success.
|
||||
final String? error;
|
||||
|
||||
bool get ok => error == null;
|
||||
}
|
||||
|
||||
/// What to do with one file after comparing local/remote/baseline.
|
||||
@visibleForTesting
|
||||
enum SyncAction { skip, upload, download, conflict }
|
||||
|
||||
/// A single planned per-file decision (exposed for testing the algorithm).
|
||||
@visibleForTesting
|
||||
class SyncDecision {
|
||||
const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal});
|
||||
final String relPath;
|
||||
final SyncAction action;
|
||||
|
||||
/// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the
|
||||
/// remote copy is the loser kept as `.conflict-*`; false if remote won.
|
||||
final bool? conflictWinnerIsLocal;
|
||||
}
|
||||
|
||||
/// Per-file last-synced baseline. After each sync we record BOTH sides'
|
||||
/// observed mtimes, because an upload makes the server stamp its OWN mtime (≠
|
||||
/// the local one) — a single shared timestamp would then look "changed" on the
|
||||
/// next run. Comparing each side to its own baseline avoids that false conflict.
|
||||
@visibleForTesting
|
||||
class SyncBaseline {
|
||||
const SyncBaseline({this.localMtime, this.remoteMtime});
|
||||
final DateTime? localMtime;
|
||||
final DateTime? remoteMtime;
|
||||
}
|
||||
|
||||
/// Compact local/remote snapshot of a file for the decision function.
|
||||
@visibleForTesting
|
||||
class FileFacts {
|
||||
const FileFacts({
|
||||
required this.relPath,
|
||||
required this.localMtime,
|
||||
required this.remoteMtime,
|
||||
this.baseline,
|
||||
});
|
||||
|
||||
final String relPath;
|
||||
|
||||
/// Local file mtime (UTC, whole-second), or null if the file is absent.
|
||||
final DateTime? localMtime;
|
||||
|
||||
/// Remote file mtime (UTC, whole-second), or null if absent.
|
||||
final DateTime? remoteMtime;
|
||||
|
||||
/// Per-side mtimes recorded after the last successful sync, or null if never.
|
||||
final SyncBaseline? baseline;
|
||||
}
|
||||
|
||||
class WebDavSyncService {
|
||||
WebDavSyncService(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _kBaseUrl = 'webdav.baseUrl';
|
||||
static const String _kUsername = 'webdav.username';
|
||||
static const String _kPassword = 'webdav.password';
|
||||
static const String _kRemoteRoot = 'webdav.remoteRoot';
|
||||
static const String _kAutoSync = 'webdav.autoSync';
|
||||
static const String _kLastSyncMillis = 'webdav.lastSyncMillis';
|
||||
|
||||
/// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across
|
||||
/// runs — the per-file, per-side baseline that lets us detect which side
|
||||
/// changed since the last successful sync.
|
||||
static const String _kBaselineJson = 'webdav.baseline';
|
||||
|
||||
/// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated
|
||||
/// conflicts from clobbering each other.
|
||||
static const String conflictMarker = '.conflict-';
|
||||
|
||||
// ---- Configuration (SharedPreferences-backed) -------------------------
|
||||
|
||||
WebDavConfig get config => WebDavConfig(
|
||||
baseUrl: _prefs.getString(_kBaseUrl) ?? '',
|
||||
username: _prefs.getString(_kUsername) ?? '',
|
||||
password: _prefs.getString(_kPassword) ?? '',
|
||||
remoteRoot: _prefs.getString(_kRemoteRoot) ?? '',
|
||||
autoSync: _prefs.getBool(_kAutoSync) ?? false,
|
||||
);
|
||||
|
||||
Future<void> saveConfig(WebDavConfig c) async {
|
||||
await _prefs.setString(_kBaseUrl, c.baseUrl.trim());
|
||||
await _prefs.setString(_kUsername, c.username);
|
||||
await _prefs.setString(_kPassword, c.password);
|
||||
await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim());
|
||||
await _prefs.setBool(_kAutoSync, c.autoSync);
|
||||
}
|
||||
|
||||
DateTime? get lastSyncTime {
|
||||
final millis = _prefs.getInt(_kLastSyncMillis);
|
||||
return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
|
||||
}
|
||||
|
||||
// ---- Baseline map -----------------------------------------------------
|
||||
|
||||
Map<String, SyncBaseline> _readBaseline() {
|
||||
final raw = _prefs.getString(_kBaselineJson);
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return decoded.map((k, v) {
|
||||
final m = v as Map<String, dynamic>;
|
||||
final lm = m['l'] as int?;
|
||||
final rm = m['r'] as int?;
|
||||
return MapEntry(
|
||||
k,
|
||||
SyncBaseline(
|
||||
localMtime:
|
||||
lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm),
|
||||
remoteMtime:
|
||||
rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm),
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeBaseline(Map<String, SyncBaseline> baseline) async {
|
||||
final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, {
|
||||
'l': v.localMtime?.millisecondsSinceEpoch,
|
||||
'r': v.remoteMtime?.millisecondsSinceEpoch,
|
||||
})));
|
||||
await _prefs.setString(_kBaselineJson, encoded);
|
||||
}
|
||||
|
||||
// ---- The pure decision function (unit-tested) -------------------------
|
||||
|
||||
/// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is
|
||||
/// the heart of the algorithm and is exercised directly by the tests.
|
||||
///
|
||||
/// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified`
|
||||
/// has 1-second resolution); callers must truncate before passing facts in.
|
||||
@visibleForTesting
|
||||
static SyncDecision decide(FileFacts f) {
|
||||
final l = f.localMtime;
|
||||
final r = f.remoteMtime;
|
||||
final bl = f.baseline?.localMtime;
|
||||
final br = f.baseline?.remoteMtime;
|
||||
|
||||
if (l != null && r == null) {
|
||||
// Local-only: either brand new locally, or remote vanished. Either way we
|
||||
// (re)upload — never delete the local file.
|
||||
return SyncDecision(f.relPath, SyncAction.upload);
|
||||
}
|
||||
if (l == null && r != null) {
|
||||
// Never synced OR previously known but now gone locally — in both cases we
|
||||
// conservatively pull from remote rather than propagating a delete.
|
||||
return SyncDecision(f.relPath, SyncAction.download);
|
||||
}
|
||||
if (l == null && r == null) {
|
||||
return SyncDecision(f.relPath, SyncAction.skip);
|
||||
}
|
||||
|
||||
// Both sides have the file. Compare each side to ITS OWN baseline so an
|
||||
// upload-stamped remote mtime isn't mistaken for a remote edit.
|
||||
final localChanged = bl == null || !_sameSecond(l!, bl);
|
||||
final remoteChanged = br == null || !_sameSecond(r!, br);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
return SyncDecision(f.relPath, SyncAction.skip);
|
||||
}
|
||||
if (localChanged && !remoteChanged) {
|
||||
return SyncDecision(f.relPath, SyncAction.upload);
|
||||
}
|
||||
if (!localChanged && remoteChanged) {
|
||||
return SyncDecision(f.relPath, SyncAction.download);
|
||||
}
|
||||
// Both changed since baseline -> true conflict. Newer mtime wins.
|
||||
final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy)
|
||||
return SyncDecision(
|
||||
f.relPath,
|
||||
SyncAction.conflict,
|
||||
conflictWinnerIsLocal: localWins,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _sameSecond(DateTime a, DateTime b) =>
|
||||
a.toUtc().millisecondsSinceEpoch ~/ 1000 ==
|
||||
b.toUtc().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// ---- Path mapping (vault <-> remote), unit-tested ---------------------
|
||||
|
||||
/// Map a vault-root-relative path (OS separators) to a forward-slashed remote
|
||||
/// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`.
|
||||
@visibleForTesting
|
||||
static String toRemotePath(String relPath) =>
|
||||
p.split(relPath).where((s) => s.isNotEmpty).join('/');
|
||||
|
||||
/// Map a forward-slashed remote path back to a vault-root-relative path using
|
||||
/// OS separators.
|
||||
@visibleForTesting
|
||||
static String toLocalRelPath(String remotePath) =>
|
||||
p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty));
|
||||
|
||||
/// True for files sync must IGNORE: sidecar temp/backup artifacts and our own
|
||||
/// conflict copies (conflict copies stay local; they are not re-synced as if
|
||||
/// canonical, but ARE uploaded as plain new files if the user keeps them).
|
||||
@visibleForTesting
|
||||
static bool isSyncable(String relPath) {
|
||||
final name = p.basename(relPath);
|
||||
if (name.startsWith('.')) return false; // hidden / .badnote metadata
|
||||
if (name.endsWith('.tmp') || name.endsWith('.bak')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- The orchestrator (I/O; device/server-validated) ------------------
|
||||
|
||||
/// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions
|
||||
/// from [decide] drive uploads/downloads; conflicts keep the loser as a
|
||||
/// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale
|
||||
/// failure returns a [SyncResult] with [SyncResult.error] set (never throws).
|
||||
Future<SyncResult> syncNow({
|
||||
required String vaultRoot,
|
||||
required WebDavClient client,
|
||||
}) async {
|
||||
try {
|
||||
final root = Directory(vaultRoot);
|
||||
if (!await root.exists()) {
|
||||
return const SyncResult(error: 'Vault folder not found');
|
||||
}
|
||||
|
||||
// 1. Snapshot both sides keyed by vault-relative path.
|
||||
final localFiles = await _scanLocal(root);
|
||||
final remoteList = await client.list('');
|
||||
final remoteFiles = <String, RemoteEntry>{};
|
||||
for (final e in remoteList) {
|
||||
final rel = toLocalRelPath(e.path);
|
||||
if (isSyncable(rel)) remoteFiles[rel] = e;
|
||||
}
|
||||
|
||||
final baseline = _readBaseline();
|
||||
|
||||
var uploaded = 0;
|
||||
var downloaded = 0;
|
||||
var conflicts = 0;
|
||||
var skipped = 0;
|
||||
|
||||
// Rel paths that ended up converged (and so deserve a fresh baseline).
|
||||
final converged = <String>{};
|
||||
|
||||
final allPaths = <String>{...localFiles.keys, ...remoteFiles.keys};
|
||||
|
||||
for (final rel in allPaths) {
|
||||
final localMtime = localFiles[rel];
|
||||
final remoteEntry = remoteFiles[rel];
|
||||
final facts = FileFacts(
|
||||
relPath: rel,
|
||||
localMtime: localMtime == null ? null : _truncate(localMtime),
|
||||
remoteMtime: remoteEntry?.modified == null
|
||||
? null
|
||||
: _truncate(remoteEntry!.modified!),
|
||||
baseline: baseline[rel],
|
||||
);
|
||||
final decision = decide(facts);
|
||||
|
||||
switch (decision.action) {
|
||||
case SyncAction.skip:
|
||||
skipped++;
|
||||
converged.add(rel);
|
||||
break;
|
||||
|
||||
case SyncAction.upload:
|
||||
await _doUpload(root, client, rel);
|
||||
uploaded++;
|
||||
converged.add(rel);
|
||||
break;
|
||||
|
||||
case SyncAction.download:
|
||||
await _doDownload(root, client, rel, remoteEntry!.modified);
|
||||
downloaded++;
|
||||
converged.add(rel);
|
||||
break;
|
||||
|
||||
case SyncAction.conflict:
|
||||
await _doConflict(
|
||||
root,
|
||||
client,
|
||||
rel,
|
||||
localWins: decision.conflictWinnerIsLocal == true,
|
||||
localMtime: localMtime!,
|
||||
remoteMtime: remoteEntry!.modified,
|
||||
);
|
||||
conflicts++;
|
||||
converged.add(rel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-snapshot both sides so the new baseline records each side's ACTUAL
|
||||
// post-sync mtime (uploads make the server stamp its own mtime). Comparing
|
||||
// each side to its own baseline next time avoids a false "remote changed".
|
||||
final finalLocal = await _scanLocal(root);
|
||||
final finalRemote = <String, DateTime?>{};
|
||||
for (final e in await client.list('')) {
|
||||
final r = toLocalRelPath(e.path);
|
||||
if (isSyncable(r)) finalRemote[r] = e.modified;
|
||||
}
|
||||
final newBaseline = <String, SyncBaseline>{};
|
||||
for (final rel in converged) {
|
||||
final lm = finalLocal[rel];
|
||||
final rm = finalRemote[rel];
|
||||
// Only keep a baseline once a file exists on BOTH sides; a one-sided
|
||||
// file (mid-restore) stays "new" so the next run finishes converging it.
|
||||
if (lm != null && rm != null) {
|
||||
newBaseline[rel] = SyncBaseline(
|
||||
localMtime: _truncate(lm),
|
||||
remoteMtime: _truncate(rm),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _writeBaseline(newBaseline);
|
||||
final finishedAt = DateTime.now();
|
||||
await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch);
|
||||
|
||||
return SyncResult(
|
||||
uploaded: uploaded,
|
||||
downloaded: downloaded,
|
||||
conflicts: conflicts,
|
||||
skipped: skipped,
|
||||
finishedAt: finishedAt,
|
||||
);
|
||||
} on WebDavException catch (e) {
|
||||
return SyncResult(error: e.message);
|
||||
} catch (e) {
|
||||
return SyncResult(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect every syncable file under [root], keyed by its
|
||||
/// vault-root-relative path, mapped to its mtime.
|
||||
Future<Map<String, DateTime>> _scanLocal(Directory root) async {
|
||||
final out = <String, DateTime>{};
|
||||
await for (final entity in root.list(recursive: true, followLinks: false)) {
|
||||
if (entity is! File) continue;
|
||||
final rel = p.relative(entity.path, from: root.path);
|
||||
// Skip anything inside a hidden folder (e.g. .badnote) or hidden file.
|
||||
if (p.split(rel).any((seg) => seg.startsWith('.'))) continue;
|
||||
if (!isSyncable(rel)) continue;
|
||||
final stat = await entity.stat();
|
||||
out[rel] = stat.modified;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Upload the local file at [rel], creating remote parent collections.
|
||||
Future<void> _doUpload(Directory root, WebDavClient client, String rel) async {
|
||||
final file = File(p.join(root.path, rel));
|
||||
final bytes = await file.readAsBytes();
|
||||
await _ensureRemoteDirs(client, rel);
|
||||
await client.upload(toRemotePath(rel), bytes);
|
||||
}
|
||||
|
||||
/// Download the remote file at [rel] into the vault, creating local parent
|
||||
/// dirs. Sets the local mtime to the remote's so the next run sees no drift.
|
||||
Future<void> _doDownload(
|
||||
Directory root,
|
||||
WebDavClient client,
|
||||
String rel,
|
||||
DateTime? remoteMtime,
|
||||
) async {
|
||||
final bytes = await client.download(toRemotePath(rel));
|
||||
final file = File(p.join(root.path, rel));
|
||||
await file.parent.create(recursive: true);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
if (remoteMtime != null) {
|
||||
try {
|
||||
await file.setLastModified(remoteMtime);
|
||||
} catch (_) {
|
||||
// Some filesystems reject setLastModified; the post-sync re-snapshot
|
||||
// captures whatever mtime landed, so convergence still holds.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a true conflict: keep the loser as `<file>.conflict-<winnerMillis>`
|
||||
/// on BOTH sides (no data lost), then converge the canonical to the winner.
|
||||
Future<void> _doConflict(
|
||||
Directory root,
|
||||
WebDavClient client,
|
||||
String rel, {
|
||||
required bool localWins,
|
||||
required DateTime localMtime,
|
||||
required DateTime? remoteMtime,
|
||||
}) async {
|
||||
final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime);
|
||||
final stamp = _truncate(winnerMtime).millisecondsSinceEpoch;
|
||||
final conflictRel = '$rel$conflictMarker$stamp';
|
||||
|
||||
final localFile = File(p.join(root.path, rel));
|
||||
final remoteBytes = await client.download(toRemotePath(rel));
|
||||
|
||||
if (localWins) {
|
||||
// Local is canonical. Save the REMOTE bytes as the local conflict copy,
|
||||
// upload that conflict copy remotely too, then push local up as canonical.
|
||||
final conflictFile = File(p.join(root.path, conflictRel));
|
||||
await conflictFile.parent.create(recursive: true);
|
||||
await conflictFile.writeAsBytes(remoteBytes, flush: true);
|
||||
|
||||
await _ensureRemoteDirs(client, conflictRel);
|
||||
await client.upload(toRemotePath(conflictRel), remoteBytes);
|
||||
|
||||
final localBytes = await localFile.readAsBytes();
|
||||
await _ensureRemoteDirs(client, rel);
|
||||
await client.upload(toRemotePath(rel), localBytes);
|
||||
} else {
|
||||
// Remote is canonical. Save the LOCAL bytes as the local conflict copy
|
||||
// and push it remotely, then overwrite local with the remote (winner).
|
||||
final localBytes = await localFile.readAsBytes();
|
||||
final conflictFile = File(p.join(root.path, conflictRel));
|
||||
await conflictFile.parent.create(recursive: true);
|
||||
await conflictFile.writeAsBytes(localBytes, flush: true);
|
||||
|
||||
await _ensureRemoteDirs(client, conflictRel);
|
||||
await client.upload(toRemotePath(conflictRel), localBytes);
|
||||
|
||||
await localFile.writeAsBytes(remoteBytes, flush: true);
|
||||
if (remoteMtime != null) {
|
||||
try {
|
||||
await localFile.setLastModified(remoteMtime);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create each remote parent collection of [rel] from the root down (MKCOL is
|
||||
/// idempotent), so an upload never 409s on a missing directory.
|
||||
Future<void> _ensureRemoteDirs(WebDavClient client, String rel) async {
|
||||
final segments = p.split(rel).where((s) => s.isNotEmpty).toList();
|
||||
if (segments.length <= 1) return; // file at root, no dirs needed
|
||||
var acc = '';
|
||||
for (var i = 0; i < segments.length - 1; i++) {
|
||||
acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}';
|
||||
await client.makeCollection(acc);
|
||||
}
|
||||
}
|
||||
|
||||
static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch(
|
||||
(t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
// ---- Convenience: build a real client from the saved config -----------
|
||||
|
||||
/// Construct an [HttpWebDavClient] from the persisted [config], or null when
|
||||
/// unconfigured (caller disables the sync button). Caller owns close().
|
||||
HttpWebDavClient? buildClient() {
|
||||
final c = config;
|
||||
if (!c.isConfigured) return null;
|
||||
return HttpWebDavClient(
|
||||
baseUrl: c.baseUrl,
|
||||
username: c.username,
|
||||
password: c.password,
|
||||
remoteRoot: c.remoteRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
529
lib/storage/badnote_sidecar.dart
Normal file
529
lib/storage/badnote_sidecar.dart
Normal file
@@ -0,0 +1,529 @@
|
||||
// lib/storage/badnote_sidecar.dart
|
||||
//
|
||||
// The on-disk sidecar model: all annotations for one source file, serialized as
|
||||
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
|
||||
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
|
||||
// pure model with NO runtime wiring yet.
|
||||
//
|
||||
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
|
||||
// stroke format. Specifically:
|
||||
// * per-page ink → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
|
||||
// byte-for-byte the `ink.stroke_json` column today)
|
||||
// * scratchpad ink → List<InkStroke> (lib/models/ink_stroke.dart; the exact
|
||||
// format scratchpads already persist, absolute world px)
|
||||
// * scratch anchors → ScratchLink (lib/models/scratch_link.dart)
|
||||
// * bookmarks → Bookmark (lib/models/bookmark.dart)
|
||||
//
|
||||
// Only the *containers* and the (previously in-memory-only) highlight rect are
|
||||
// new here. Unknown JSON fields are ignored on read so the schema is
|
||||
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).
|
||||
|
||||
import 'dart:ui' show Rect;
|
||||
|
||||
import '../editor/engine/stroke_model.dart';
|
||||
import '../models/bookmark.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/scratch_link.dart';
|
||||
|
||||
/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
|
||||
/// forward-compat; readers tolerate unknown extra fields.
|
||||
const int kBadnoteSidecarVersion = 1;
|
||||
|
||||
/// A single highlighted text rectangle on a page, normalized to the page rect
|
||||
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
|
||||
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
|
||||
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
|
||||
/// `TODO(persist-highlights)`), so this small value class is the representation.
|
||||
class SidecarHighlight {
|
||||
const SidecarHighlight({
|
||||
required this.l,
|
||||
required this.t,
|
||||
required this.r,
|
||||
required this.b,
|
||||
this.color = 0xFFFFFF00,
|
||||
});
|
||||
|
||||
/// Normalized left edge in [0,1].
|
||||
final double l;
|
||||
|
||||
/// Normalized top edge in [0,1].
|
||||
final double t;
|
||||
|
||||
/// Normalized right edge in [0,1].
|
||||
final double r;
|
||||
|
||||
/// Normalized bottom edge in [0,1].
|
||||
final double b;
|
||||
|
||||
/// ARGB color of the highlight.
|
||||
final int color;
|
||||
|
||||
/// Builds a highlight from a normalized [Rect] (as stored in
|
||||
/// `_highlightsByPage`) and an ARGB color.
|
||||
factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
|
||||
SidecarHighlight(
|
||||
l: rect.left,
|
||||
t: rect.top,
|
||||
r: rect.right,
|
||||
b: rect.bottom,
|
||||
color: color,
|
||||
);
|
||||
|
||||
/// The normalized rect (page-relative) for rendering.
|
||||
Rect toRect() => Rect.fromLTRB(l, t, r, b);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'l': l,
|
||||
't': t,
|
||||
'r': r,
|
||||
'b': b,
|
||||
'color': color,
|
||||
};
|
||||
|
||||
factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
|
||||
SidecarHighlight(
|
||||
l: (json['l'] as num).toDouble(),
|
||||
t: (json['t'] as num).toDouble(),
|
||||
r: (json['r'] as num).toDouble(),
|
||||
b: (json['b'] as num).toDouble(),
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SidecarHighlight &&
|
||||
runtimeType == other.runtimeType &&
|
||||
l == other.l &&
|
||||
t == other.t &&
|
||||
r == other.r &&
|
||||
b == other.b &&
|
||||
color == other.color;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(l, t, r, b, color);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
|
||||
}
|
||||
|
||||
/// A single typed-text annotation on a page (PDF editor for now). Position is
|
||||
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
|
||||
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
|
||||
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
|
||||
/// with the page; the editor multiplies it by the on-screen page width.
|
||||
class SidecarText {
|
||||
const SidecarText({
|
||||
required this.id,
|
||||
required this.nx,
|
||||
required this.ny,
|
||||
required this.text,
|
||||
this.fontSize = 0.03,
|
||||
this.color = 0xFF000000,
|
||||
this.fontWeight = 400,
|
||||
this.fontFamily,
|
||||
});
|
||||
|
||||
/// Stable id (uuid) so edits/deletes address a specific box.
|
||||
final String id;
|
||||
|
||||
/// Normalized x of the box's top-left in [0,1].
|
||||
final double nx;
|
||||
|
||||
/// Normalized y of the box's top-left in [0,1].
|
||||
final double ny;
|
||||
|
||||
/// The typed text.
|
||||
final String text;
|
||||
|
||||
/// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
|
||||
final double fontSize;
|
||||
|
||||
/// ARGB text color.
|
||||
final int color;
|
||||
|
||||
/// CSS-like numeric weight (100–900). Default 400 (regular).
|
||||
final int fontWeight;
|
||||
|
||||
/// Optional family name. Null → editor default (IBM Plex Sans).
|
||||
final String? fontFamily;
|
||||
|
||||
SidecarText copyWith({
|
||||
String? id,
|
||||
double? nx,
|
||||
double? ny,
|
||||
String? text,
|
||||
double? fontSize,
|
||||
int? color,
|
||||
int? fontWeight,
|
||||
String? fontFamily,
|
||||
bool clearFontFamily = false,
|
||||
}) =>
|
||||
SidecarText(
|
||||
id: id ?? this.id,
|
||||
nx: nx ?? this.nx,
|
||||
ny: ny ?? this.ny,
|
||||
text: text ?? this.text,
|
||||
fontSize: fontSize ?? this.fontSize,
|
||||
color: color ?? this.color,
|
||||
fontWeight: fontWeight ?? this.fontWeight,
|
||||
fontFamily:
|
||||
clearFontFamily ? null : (fontFamily ?? this.fontFamily),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'nx': nx,
|
||||
'ny': ny,
|
||||
'text': text,
|
||||
'fontSize': fontSize,
|
||||
'color': color,
|
||||
'fontWeight': fontWeight,
|
||||
if (fontFamily != null) 'fontFamily': fontFamily,
|
||||
};
|
||||
|
||||
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
|
||||
id: json['id'] as String,
|
||||
nx: (json['nx'] as num).toDouble(),
|
||||
ny: (json['ny'] as num).toDouble(),
|
||||
text: (json['text'] as String?) ?? '',
|
||||
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
||||
fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400,
|
||||
fontFamily: json['fontFamily'] as String?,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SidecarText &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
nx == other.nx &&
|
||||
ny == other.ny &&
|
||||
text == other.text &&
|
||||
fontSize == other.fontSize &&
|
||||
color == other.color &&
|
||||
fontWeight == other.fontWeight &&
|
||||
fontFamily == other.fontFamily;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
id,
|
||||
nx,
|
||||
ny,
|
||||
text,
|
||||
fontSize,
|
||||
color,
|
||||
fontWeight,
|
||||
fontFamily,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
|
||||
'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)';
|
||||
}
|
||||
|
||||
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
|
||||
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
|
||||
/// so it restores (today the canvas always resets to 4000×4000).
|
||||
class SidecarScratchpad {
|
||||
const SidecarScratchpad({
|
||||
this.canvasWidth = 4000.0,
|
||||
this.canvasHeight = 4000.0,
|
||||
this.strokes = const [],
|
||||
});
|
||||
|
||||
final double canvasWidth;
|
||||
final double canvasHeight;
|
||||
|
||||
/// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
|
||||
final List<InkStroke> strokes;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'canvasWidth': canvasWidth,
|
||||
'canvasHeight': canvasHeight,
|
||||
'strokes': strokes.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
|
||||
SidecarScratchpad(
|
||||
canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
|
||||
canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
|
||||
strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SidecarScratchpad &&
|
||||
runtimeType == other.runtimeType &&
|
||||
canvasWidth == other.canvasWidth &&
|
||||
canvasHeight == other.canvasHeight &&
|
||||
_listEq(strokes, other.strokes);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));
|
||||
|
||||
@override
|
||||
String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
|
||||
'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
|
||||
}
|
||||
|
||||
/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
|
||||
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
|
||||
class SidecarScratchLink {
|
||||
const SidecarScratchLink({
|
||||
required this.link,
|
||||
this.scratchpad = const SidecarScratchpad(),
|
||||
});
|
||||
|
||||
/// Anchor geometry (reuses [ScratchLink] verbatim).
|
||||
final ScratchLink link;
|
||||
|
||||
/// The anchor's private scratchpad.
|
||||
final SidecarScratchpad scratchpad;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
...link.toJson(),
|
||||
'scratchpad': scratchpad.toJson(),
|
||||
};
|
||||
|
||||
factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
|
||||
SidecarScratchLink(
|
||||
link: ScratchLink.fromJson(json),
|
||||
scratchpad: json['scratchpad'] == null
|
||||
? const SidecarScratchpad()
|
||||
: SidecarScratchpad.fromJson(
|
||||
json['scratchpad'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SidecarScratchLink &&
|
||||
runtimeType == other.runtimeType &&
|
||||
link == other.link &&
|
||||
scratchpad == other.scratchpad;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(link, scratchpad);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
|
||||
}
|
||||
|
||||
/// The whole sidecar: all annotations for one source file.
|
||||
///
|
||||
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
|
||||
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
|
||||
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
|
||||
/// the scratchpad).
|
||||
class BadnoteSidecar {
|
||||
BadnoteSidecar({
|
||||
this.version = kBadnoteSidecarVersion,
|
||||
this.sourceFile,
|
||||
this.docType,
|
||||
this.title,
|
||||
this.pageCount,
|
||||
this.rotation = 0,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
Map<int, List<EditorStroke>>? strokes,
|
||||
Map<int, List<SidecarHighlight>>? highlights,
|
||||
Map<int, List<SidecarText>>? texts,
|
||||
List<Bookmark>? bookmarks,
|
||||
List<SidecarScratchLink>? scratchLinks,
|
||||
Map<int, String>? legacyAnnotations,
|
||||
this.ocrText,
|
||||
this.pageText,
|
||||
this.legacyId,
|
||||
this.background,
|
||||
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
||||
texts = texts ?? <int, List<SidecarText>>{},
|
||||
bookmarks = bookmarks ?? <Bookmark>[],
|
||||
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
|
||||
legacyAnnotations = legacyAnnotations ?? <int, String>{};
|
||||
|
||||
/// Schema version (`badnoteSidecarVersion`).
|
||||
final int version;
|
||||
|
||||
/// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
|
||||
final String? sourceFile;
|
||||
|
||||
/// `pdf` / `pptx` / `notebook` etc.
|
||||
final String? docType;
|
||||
|
||||
/// Display title for a standalone (non-file-backed) notebook (`docType ==
|
||||
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
|
||||
final String? title;
|
||||
|
||||
final int? pageCount;
|
||||
final int rotation;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
/// Page index → committed [EditorStroke]s (normalized page coords).
|
||||
final Map<int, List<EditorStroke>> strokes;
|
||||
|
||||
/// Page index → highlighted text rects (normalized).
|
||||
final Map<int, List<SidecarHighlight>> highlights;
|
||||
|
||||
/// Page index → typed-text annotations (normalized position, page-relative
|
||||
/// font size). PDF editor only for now (note text is a later increment).
|
||||
final Map<int, List<SidecarText>> texts;
|
||||
|
||||
final List<Bookmark> bookmarks;
|
||||
final List<SidecarScratchLink> scratchLinks;
|
||||
|
||||
/// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
|
||||
/// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
|
||||
/// only by the one-time SQLite→sidecar migration so no legacy data is silently
|
||||
/// dropped; the live editor ignores it. Empty for all freshly authored
|
||||
/// sidecars.
|
||||
final Map<int, String> legacyAnnotations;
|
||||
|
||||
/// Searchable text recovered from this notebook's handwriting via local OCR
|
||||
/// (Phase 6 search index). Persisted in the sidecar — the source of truth —
|
||||
/// so the vault-scan search index can find handwritten notes WITHOUT the
|
||||
/// (rebuildable, per-device) SQLite cache. Typed text already lives in the
|
||||
/// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
|
||||
/// the notebook has no handwriting or OCR hasn't run.
|
||||
final String? ocrText;
|
||||
|
||||
/// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook
|
||||
/// (a PDF), captured ONCE at import time so the vault-scan search index covers
|
||||
/// the document — not just the user's annotations. It is either the PDF's
|
||||
/// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no
|
||||
/// text layer — the result of a background OCR pass over the rendered pages.
|
||||
/// Pages are joined with `\f` (form feed) but the index treats it as a flat
|
||||
/// blob. Null when the document has not been indexed yet (back-compat: an old
|
||||
/// sidecar simply omits the field) or has no extractable/recognized text. This
|
||||
/// is distinct from [ocrText], which holds ONLY handwriting OCR.
|
||||
final String? pageText;
|
||||
|
||||
/// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
|
||||
/// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
|
||||
/// idempotent (a re-run recognizes an already-migrated item by this id even if
|
||||
/// its folder name collided). Null for all freshly authored sidecars.
|
||||
final String? legacyId;
|
||||
|
||||
/// The page-background template for a standalone notebook, stored as the
|
||||
/// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
|
||||
/// here so the storage model stays UI-decoupled; the editor decodes it via
|
||||
/// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
|
||||
final String? background;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'badnoteSidecarVersion': version,
|
||||
if (sourceFile != null) 'sourceFile': sourceFile,
|
||||
if (docType != null) 'docType': docType,
|
||||
if (title != null) 'title': title,
|
||||
if (pageCount != null) 'pageCount': pageCount,
|
||||
'rotation': rotation,
|
||||
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||
'strokes': {
|
||||
for (final entry in strokes.entries)
|
||||
entry.key.toString():
|
||||
entry.value.map((s) => s.toJson()).toList(),
|
||||
},
|
||||
'highlights': {
|
||||
for (final entry in highlights.entries)
|
||||
entry.key.toString():
|
||||
entry.value.map((h) => h.toJson()).toList(),
|
||||
},
|
||||
if (texts.isNotEmpty)
|
||||
'texts': {
|
||||
for (final entry in texts.entries)
|
||||
entry.key.toString():
|
||||
entry.value.map((t) => t.toJson()).toList(),
|
||||
},
|
||||
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
|
||||
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
|
||||
if (legacyAnnotations.isNotEmpty)
|
||||
'legacyAnnotations': {
|
||||
for (final entry in legacyAnnotations.entries)
|
||||
entry.key.toString(): entry.value,
|
||||
},
|
||||
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
||||
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
|
||||
if (legacyId != null) 'legacyId': legacyId,
|
||||
if (background != null) 'background': background,
|
||||
};
|
||||
|
||||
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
|
||||
Map<int, List<T>> decodePageMap<T>(
|
||||
Object? raw,
|
||||
T Function(Map<String, dynamic>) item,
|
||||
) {
|
||||
final out = <int, List<T>>{};
|
||||
if (raw is Map) {
|
||||
raw.forEach((key, value) {
|
||||
final pageIndex = int.tryParse(key.toString());
|
||||
if (pageIndex == null || value is! List) return;
|
||||
out[pageIndex] = value
|
||||
.map((e) => item(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return BadnoteSidecar(
|
||||
version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
|
||||
kBadnoteSidecarVersion,
|
||||
sourceFile: json['sourceFile'] as String?,
|
||||
docType: json['docType'] as String?,
|
||||
title: json['title'] as String?,
|
||||
pageCount: (json['pageCount'] as num?)?.toInt(),
|
||||
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
|
||||
createdAt: json['createdAt'] == null
|
||||
? null
|
||||
: DateTime.tryParse(json['createdAt'] as String),
|
||||
updatedAt: json['updatedAt'] == null
|
||||
? null
|
||||
: DateTime.tryParse(json['updatedAt'] as String),
|
||||
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
|
||||
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
|
||||
texts: decodePageMap(json['texts'], SidecarText.fromJson),
|
||||
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
|
||||
.map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
legacyAnnotations: () {
|
||||
final raw = json['legacyAnnotations'];
|
||||
final out = <int, String>{};
|
||||
if (raw is Map) {
|
||||
raw.forEach((key, value) {
|
||||
final page = int.tryParse(key.toString());
|
||||
if (page != null && value is String) out[page] = value;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}(),
|
||||
ocrText: json['ocrText'] as String?,
|
||||
pageText: json['pageText'] as String?,
|
||||
legacyId: json['legacyId'] as String?,
|
||||
background: json['background'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _listEq<T>(List<T> a, List<T> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
160
lib/storage/notebook_manifest.dart
Normal file
160
lib/storage/notebook_manifest.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// lib/storage/notebook_manifest.dart
|
||||
//
|
||||
// OneNote-style notebook container: a vault folder with `notebook.json` that
|
||||
// lists members (blank notes + imported PDF/PPTX/DOCX). Each member still uses
|
||||
// its own sidecar; this file is only the table of contents.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Filename of the notebook container manifest inside a vault folder.
|
||||
const String kNotebookManifestName = 'notebook.json';
|
||||
|
||||
/// Default annotation font family (matches app UI theme).
|
||||
const String kAnnotationFontFamily = 'IBM Plex Sans';
|
||||
|
||||
/// Kind of a notebook member.
|
||||
enum NotebookMemberKind {
|
||||
note,
|
||||
pdf,
|
||||
pptx,
|
||||
ppt,
|
||||
docx,
|
||||
}
|
||||
|
||||
NotebookMemberKind? notebookMemberKindFromExt(String ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return NotebookMemberKind.pdf;
|
||||
case 'pptx':
|
||||
return NotebookMemberKind.pptx;
|
||||
case 'ppt':
|
||||
return NotebookMemberKind.ppt;
|
||||
case 'docx':
|
||||
return NotebookMemberKind.docx;
|
||||
case 'note':
|
||||
case 'notebook':
|
||||
return NotebookMemberKind.note;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String notebookMemberKindToExt(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => 'note',
|
||||
NotebookMemberKind.pdf => 'pdf',
|
||||
NotebookMemberKind.pptx => 'pptx',
|
||||
NotebookMemberKind.ppt => 'ppt',
|
||||
NotebookMemberKind.docx => 'docx',
|
||||
};
|
||||
|
||||
/// One page/section inside a notebook container.
|
||||
class NotebookMember {
|
||||
const NotebookMember({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.relativePath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final NotebookMemberKind kind;
|
||||
|
||||
/// Path relative to the notebook folder (POSIX separators preferred).
|
||||
final String relativePath;
|
||||
|
||||
final String title;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'kind': kind.name,
|
||||
'path': relativePath,
|
||||
'title': title,
|
||||
};
|
||||
|
||||
factory NotebookMember.fromJson(Map<String, dynamic> json) {
|
||||
final kindName = (json['kind'] as String?) ?? 'note';
|
||||
final kind = NotebookMemberKind.values.firstWhere(
|
||||
(k) => k.name == kindName,
|
||||
orElse: () => NotebookMemberKind.note,
|
||||
);
|
||||
return NotebookMember(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
kind: kind,
|
||||
relativePath: (json['path'] as String?) ?? '',
|
||||
title: (json['title'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
NotebookMember copyWith({String? title, String? relativePath}) =>
|
||||
NotebookMember(
|
||||
id: id,
|
||||
kind: kind,
|
||||
relativePath: relativePath ?? this.relativePath,
|
||||
title: title ?? this.title,
|
||||
);
|
||||
}
|
||||
|
||||
/// Table of contents for a multi-document notebook folder.
|
||||
class NotebookManifest {
|
||||
const NotebookManifest({
|
||||
required this.title,
|
||||
required this.members,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
final int version;
|
||||
final String title;
|
||||
final List<NotebookMember> members;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'title': title,
|
||||
'members': members.map((m) => m.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory NotebookManifest.fromJson(Map<String, dynamic> json) {
|
||||
final raw = (json['members'] as List<dynamic>?) ?? const [];
|
||||
return NotebookManifest(
|
||||
version: (json['version'] as num?)?.toInt() ?? 1,
|
||||
title: (json['title'] as String?) ?? '',
|
||||
members: [
|
||||
for (final e in raw)
|
||||
NotebookMember.fromJson(e as Map<String, dynamic>),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
NotebookManifest copyWith({
|
||||
String? title,
|
||||
List<NotebookMember>? members,
|
||||
}) =>
|
||||
NotebookManifest(
|
||||
version: version,
|
||||
title: title ?? this.title,
|
||||
members: members ?? this.members,
|
||||
);
|
||||
|
||||
static File fileIn(String folderPath) =>
|
||||
File(p.join(folderPath, kNotebookManifestName));
|
||||
|
||||
static Future<NotebookManifest?> read(String folderPath) async {
|
||||
final file = fileIn(folderPath);
|
||||
if (!await file.exists()) return null;
|
||||
try {
|
||||
final map = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
return NotebookManifest.fromJson(map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> write(String folderPath, NotebookManifest manifest) async {
|
||||
final file = fileIn(folderPath);
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(manifest.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/storage/sidecar_store.dart
Normal file
84
lib/storage/sidecar_store.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
// lib/storage/sidecar_store.dart
|
||||
//
|
||||
// Atomic read/write for `<file>.badnote.json` sidecars (Phase 1 / §F.1 of
|
||||
// docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI.
|
||||
//
|
||||
// Write protocol (§F.1):
|
||||
// 1. Serialize to pretty JSON, write to `<target>.tmp` with flush:true.
|
||||
// 2. Before clobbering, copy the current good `<target>` to `<target>.bak`
|
||||
// (one-deep backup — cheap insurance against a corrupt write).
|
||||
// 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS /
|
||||
// POSIX), so a reader never observes a half-written sidecar.
|
||||
//
|
||||
// Read protocol: parse `<target>`; if it is missing OR fails to parse, fall back
|
||||
// to `<target>.bak`. If neither yields valid JSON, return null.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'badnote_sidecar.dart';
|
||||
|
||||
/// Stateless helper namespace for sidecar persistence.
|
||||
class SidecarStore {
|
||||
const SidecarStore._();
|
||||
|
||||
static const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
|
||||
|
||||
/// Suffix for the in-progress temp file.
|
||||
static const String tmpSuffix = '.tmp';
|
||||
|
||||
/// Suffix for the one-deep backup of the last good sidecar.
|
||||
static const String bakSuffix = '.bak';
|
||||
|
||||
/// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak`
|
||||
/// of the previous good file. Never leaves a partial sidecar at [target]:
|
||||
/// either the previous content (on failure before rename) or the new content.
|
||||
static Future<void> writeAtomic(File target, BadnoteSidecar sidecar) async {
|
||||
final json = _encoder.convert(sidecar.toJson());
|
||||
await writeAtomicJson(target, json);
|
||||
}
|
||||
|
||||
/// Lower-level variant for callers that already hold the JSON string.
|
||||
static Future<void> writeAtomicJson(File target, String json) async {
|
||||
await target.parent.create(recursive: true);
|
||||
|
||||
final tmp = File('${target.path}$tmpSuffix');
|
||||
await tmp.writeAsString(json, flush: true);
|
||||
|
||||
// Back up the previous good file before clobbering it.
|
||||
if (await target.exists()) {
|
||||
final bak = File('${target.path}$bakSuffix');
|
||||
try {
|
||||
await target.copy(bak.path);
|
||||
} catch (_) {
|
||||
// A failed backup must not block the write; the atomic rename below
|
||||
// still guarantees the new content lands intact.
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic on the same filesystem.
|
||||
await tmp.rename(target.path);
|
||||
}
|
||||
|
||||
/// Reads and parses the sidecar at [target], falling back to `<target>.bak`
|
||||
/// if the primary is missing or corrupt. Returns null if neither is readable.
|
||||
static Future<BadnoteSidecar?> read(File target) async {
|
||||
final primary = await _tryRead(target);
|
||||
if (primary != null) return primary;
|
||||
|
||||
final bak = File('${target.path}$bakSuffix');
|
||||
return _tryRead(bak);
|
||||
}
|
||||
|
||||
static Future<BadnoteSidecar?> _tryRead(File file) async {
|
||||
try {
|
||||
if (!await file.exists()) return null;
|
||||
final raw = await file.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return BadnoteSidecar.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user