Compare commits
28 Commits
45a8931b64
...
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 |
@@ -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)
|
||||
|
||||
|
||||
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` 发回即可;无需录屏(可选)。
|
||||
@@ -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)}';
|
||||
|
||||
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,23 +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({
|
||||
@@ -47,13 +73,20 @@ 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,
|
||||
@@ -84,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).
|
||||
@@ -98,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;
|
||||
@@ -105,12 +165,25 @@ 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
|
||||
@@ -150,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;
|
||||
@@ -196,6 +287,16 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
? 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
|
||||
@@ -204,6 +305,16 @@ 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).
|
||||
///
|
||||
@@ -215,12 +326,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
if (!_isStylus(event.kind)) return null;
|
||||
final double? raw = _rawNormalizedPressure(event);
|
||||
if (raw == null) return null;
|
||||
return PressureCurve(floor: widget.pressureFloor, gamma: widget.pressureGamma)
|
||||
.apply(raw);
|
||||
// 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);
|
||||
@@ -287,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);
|
||||
}
|
||||
}
|
||||
@@ -308,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);
|
||||
@@ -330,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 -----------------------------------------------------
|
||||
@@ -338,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;
|
||||
@@ -351,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) {
|
||||
@@ -364,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;
|
||||
@@ -402,6 +681,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
color: _currentColor().toARGB32(),
|
||||
width: widget.strokeWidth,
|
||||
kind: _currentKind(),
|
||||
brush: _currentBrush,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -533,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).
|
||||
@@ -552,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,
|
||||
@@ -599,7 +879,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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(
|
||||
@@ -611,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;
|
||||
@@ -46,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;
|
||||
|
||||
@@ -106,15 +107,8 @@ 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
|
||||
@@ -129,6 +123,12 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
/// (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) {
|
||||
@@ -176,16 +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;
|
||||
|
||||
@@ -205,7 +227,6 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
|
||||
// any transient in the live matrix.
|
||||
_scaleStart = _lastAppliedScale;
|
||||
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
||||
_lastRawScale = details.scale;
|
||||
// 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;
|
||||
@@ -229,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,
|
||||
);
|
||||
}
|
||||
@@ -244,39 +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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
|
||||
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
|
||||
// scene point that was under the focal at gesture start stays under the
|
||||
// CURRENT focal (which also yields 2-finger pan for free). Closed form
|
||||
// for a pure scale+translate matrix — no inversion, no live read-back —
|
||||
// so an interleaved/transient matrix write can't survive into the next
|
||||
// frame: every frame is fully re-derived from clean inputs.
|
||||
// Absolute target scale, normalized against the baseline so a
|
||||
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
|
||||
// pinch_scale_solver.dart for the full rationale.
|
||||
final double targetScale = absolutePinchScale(
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -288,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);
|
||||
@@ -298,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 =
|
||||
@@ -306,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;
|
||||
@@ -351,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;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,19 @@ 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';
|
||||
@@ -36,36 +43,76 @@ class PenNoteScreen extends ConsumerStatefulWidget {
|
||||
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Live strokes in normalized coords (the canvas source of truth). Persisted
|
||||
/// back to the note as InkStroke via the adapter on save.
|
||||
List<PenStroke> _strokes = const [];
|
||||
/// Per-page live strokes in normalized coords (the canvas source of truth).
|
||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||
|
||||
/// Snapshot-before-change undo/redo of the stroke list.
|
||||
/// 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 = [];
|
||||
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
Color _color = Colors.black;
|
||||
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;
|
||||
|
||||
String? _noteId;
|
||||
/// 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 _penWidthFraction = 0.006;
|
||||
static const double _highlighterWidthFraction = 0.02;
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
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() {
|
||||
@@ -73,25 +120,101 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
PenInputService.instance.start();
|
||||
final note = widget.note;
|
||||
if (note != null) {
|
||||
_noteId = note.id;
|
||||
_notePath = note.id;
|
||||
_titleController.text = note.title;
|
||||
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
|
||||
// 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!);
|
||||
}
|
||||
|
||||
Future<void> _initPenConfig() async {
|
||||
final controller = await PenConfigController.load();
|
||||
/// 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) {
|
||||
controller.dispose();
|
||||
repo.dispose();
|
||||
return;
|
||||
}
|
||||
controller.addListener(_onPenConfigChanged);
|
||||
_repo = repo;
|
||||
setState(() {
|
||||
_penConfig = controller;
|
||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
||||
_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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,10 +222,23 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
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();
|
||||
@@ -162,8 +298,60 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert the live pen strokes back to InkStroke and write the note. Creates
|
||||
/// the note row on first save. Triggers local OCR for search indexing.
|
||||
/// 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);
|
||||
@@ -171,40 +359,53 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
final title = _titleController.text.trim().isEmpty
|
||||
? 'Untitled'
|
||||
: _titleController.text.trim();
|
||||
final inkStrokes = <InkStroke>[
|
||||
for (final s in _strokes)
|
||||
inkStrokeFromPen(s, kNoteLogicalPage,
|
||||
id: _uuid.v4(), createdAt: now),
|
||||
];
|
||||
|
||||
Note saved;
|
||||
if (_noteId == null) {
|
||||
// Lazily create the notebook folder + sidecar repo on first save.
|
||||
if (_repo == null) {
|
||||
final created = await notifier.createNote(title: title);
|
||||
saved = created.copyWith(strokes: inkStrokes, updatedAt: now);
|
||||
await notifier.updateNote(saved);
|
||||
_noteId = saved.id;
|
||||
} else {
|
||||
saved = (widget.note ?? await _noteById(_noteId!)).copyWith(
|
||||
title: title,
|
||||
strokes: inkStrokes,
|
||||
updatedAt: now,
|
||||
);
|
||||
await notifier.updateNote(saved);
|
||||
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);
|
||||
_runLocalOcr(saved);
|
||||
}
|
||||
|
||||
Future<Note> _noteById(String id) async {
|
||||
final notes = ref.read(noteListProvider).valueOrNull ?? const [];
|
||||
return notes.firstWhere((n) => n.id == id,
|
||||
orElse: () => Note(
|
||||
id: id,
|
||||
title: _titleController.text,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
));
|
||||
// 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) {
|
||||
@@ -235,9 +436,45 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||
}
|
||||
|
||||
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
: (_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) {
|
||||
@@ -276,13 +513,20 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Title pill (bottom-center).
|
||||
// Title + page chrome (bottom-center).
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _buildTitlePill(cs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPagePill(cs),
|
||||
const SizedBox(height: 8),
|
||||
_buildTitlePill(cs),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -317,22 +561,42 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
pageSize: pageSize,
|
||||
strokes: _strokes,
|
||||
transformationController: _transform,
|
||||
tool: _tool,
|
||||
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,
|
||||
_penConfig?.value.sideButton ?? PenButtonAction.select,
|
||||
eraserEndAction:
|
||||
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
||||
allowFingerDrawing: _allowFingerDrawing,
|
||||
onStrokeComplete: _commitStroke,
|
||||
onEraseStroke: _eraseStroke,
|
||||
// A white sheet with a soft shadow — the note "paper".
|
||||
pageWidget: Container(
|
||||
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: [
|
||||
@@ -343,6 +607,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: NoteBackgroundPainter(_background),
|
||||
size: Size.infinite,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -356,74 +624,175 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ToolButton(
|
||||
icon: Icons.edit_outlined,
|
||||
selected: _tool == CanvasTool.pen,
|
||||
tooltip: 'Pen',
|
||||
onPressed: () => setState(() => _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: _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),
|
||||
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,
|
||||
),
|
||||
],
|
||||
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 != CanvasTool.eraser;
|
||||
_tool != EditorToolKind.eraser &&
|
||||
_tool != EditorToolKind.select;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
}),
|
||||
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),
|
||||
@@ -465,4 +834,88 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,457 @@
|
||||
|
||||
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({
|
||||
|
||||
@@ -15,12 +15,17 @@ 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';
|
||||
@@ -51,27 +56,38 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
/// keeps the slide's aspect (no distortion). Null until loaded.
|
||||
Map<int, Size>? _slideSizes;
|
||||
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
Color _color = Colors.black;
|
||||
/// 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 _penWidthFraction = 0.006;
|
||||
static const double _highlighterWidthFraction = 0.02;
|
||||
static const Size _fallbackSlide = Size(1600, 900);
|
||||
|
||||
static const List<Color> _palette = [
|
||||
Colors.black,
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
];
|
||||
static const List<Color> _palette = kInkPalette;
|
||||
|
||||
int get _slideCount => widget.slideImagePaths.length;
|
||||
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
|
||||
@@ -102,15 +118,23 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
}
|
||||
|
||||
Future<void> _initPenConfig() async {
|
||||
final controller = await PenConfigController.load();
|
||||
final results = await Future.wait([
|
||||
PenConfigController.load(),
|
||||
PenSlotsController.load(),
|
||||
]);
|
||||
final config = results[0] as PenConfigController;
|
||||
final slots = results[1] as PenSlotsController;
|
||||
if (!mounted) {
|
||||
controller.dispose();
|
||||
config.dispose();
|
||||
slots.dispose();
|
||||
return;
|
||||
}
|
||||
controller.addListener(_onPenConfigChanged);
|
||||
config.addListener(_onPenConfigChanged);
|
||||
slots.addListener(_onPenSlotsChanged);
|
||||
setState(() {
|
||||
_penConfig = controller;
|
||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
||||
_penConfig = config;
|
||||
_penSlots = slots;
|
||||
_allowFingerDrawing = config.value.fingerDrawing;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,10 +142,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
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();
|
||||
}
|
||||
@@ -183,6 +213,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
setState(() {
|
||||
_slideIndex = clamped;
|
||||
_needsCenter = true;
|
||||
_selectedStroke = null; // selection is per-slide
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,14 +305,53 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
|
||||
}
|
||||
|
||||
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
||||
: (_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 Scaffold(
|
||||
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()),
|
||||
@@ -329,6 +399,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,9 +433,14 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
pageSize: pageSize,
|
||||
strokes: _currentStrokes,
|
||||
transformationController: _transform,
|
||||
tool: _tool,
|
||||
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,
|
||||
@@ -396,24 +472,56 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ToolButton(
|
||||
icon: Icons.edit_outlined,
|
||||
selected: _tool == CanvasTool.pen,
|
||||
tooltip: 'Pen',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||||
),
|
||||
// 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 == CanvasTool.highlighter,
|
||||
selected: _tool == EditorToolKind.highlighter,
|
||||
tooltip: 'Highlighter',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
selected: _tool == CanvasTool.eraser,
|
||||
selected: _tool == EditorToolKind.eraser,
|
||||
tooltip: 'Eraser',
|
||||
onPressed: () => setState(() => _tool = CanvasTool.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,
|
||||
@@ -431,6 +539,10 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
),
|
||||
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,
|
||||
@@ -455,13 +567,21 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
}
|
||||
|
||||
Widget _colorDot(Color c, ColorScheme cs) {
|
||||
final selected =
|
||||
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||
_tool != EditorToolKind.eraser &&
|
||||
_tool != EditorToolKind.select;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_color = c;
|
||||
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||
}),
|
||||
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),
|
||||
@@ -502,8 +622,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
||||
divisions: _slideCount > 1 ? _slideCount - 1 : null,
|
||||
onChanged: (v) => setState(() => _scrub = v),
|
||||
onChangeEnd: (v) {
|
||||
setState(() => _scrub = null);
|
||||
_goToSlide(v.round() - 1);
|
||||
final target = v.round() - 1;
|
||||
setState(() {
|
||||
_scrub = v;
|
||||
_slideIndex = target;
|
||||
});
|
||||
_goToSlide(target);
|
||||
setState(() {
|
||||
_scrub = null;
|
||||
_showSlider = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
// 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;
|
||||
|
||||
@@ -39,3 +45,59 @@ double absolutePinchScale({
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,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.
|
||||
@@ -25,7 +31,7 @@ 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 = kNaturalPressureGamma,
|
||||
this.palmRejectionMs = 150.0,
|
||||
|
||||
@@ -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,6 +8,10 @@
|
||||
// - [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).
|
||||
@@ -23,27 +27,86 @@ const double kNaturalPressureGamma = 0.7;
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.
|
||||
@@ -55,6 +56,12 @@ PenStroke? penStrokeFromInk(InkStroke s, Size page) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -265,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
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
"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",
|
||||
@@ -46,6 +64,19 @@
|
||||
"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",
|
||||
@@ -58,6 +89,26 @@
|
||||
"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" } }
|
||||
@@ -69,5 +120,135 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -128,6 +128,78 @@ abstract class AppLocalizations {
|
||||
/// **'Import PPT'**
|
||||
String get importPpt;
|
||||
|
||||
/// No description provided for @importFile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import file'**
|
||||
String get importFile;
|
||||
|
||||
/// No description provided for @createNotebook.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create notebook'**
|
||||
String get createNotebook;
|
||||
|
||||
/// No description provided for @newNotebookTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New notebook'**
|
||||
String get newNotebookTitle;
|
||||
|
||||
/// No description provided for @notebookTitleHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notebook title'**
|
||||
String get notebookTitleHint;
|
||||
|
||||
/// No description provided for @create.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create'**
|
||||
String get create;
|
||||
|
||||
/// No description provided for @untitledNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Untitled'**
|
||||
String get untitledNote;
|
||||
|
||||
/// No description provided for @noNotesYetHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No ink notes yet — tap + to create one'**
|
||||
String get noNotesYetHint;
|
||||
|
||||
/// No description provided for @noDocumentsYet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No documents yet — tap Import file'**
|
||||
String get noDocumentsYet;
|
||||
|
||||
/// No description provided for @processingImport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Importing…'**
|
||||
String get processingImport;
|
||||
|
||||
/// No description provided for @importFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t import that file: {error}'**
|
||||
String importFailed(String error);
|
||||
|
||||
/// No description provided for @convertNeedsLibreOffice.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.'**
|
||||
String get convertNeedsLibreOffice;
|
||||
|
||||
/// No description provided for @unsupportedFileType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unsupported file type: {ext}'**
|
||||
String unsupportedFileType(String ext);
|
||||
|
||||
/// No description provided for @penCanvasBeta.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -320,6 +392,84 @@ abstract class AppLocalizations {
|
||||
/// **'Eraser'**
|
||||
String get toolEraser;
|
||||
|
||||
/// No description provided for @brushPicker.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Brush'**
|
||||
String get brushPicker;
|
||||
|
||||
/// No description provided for @brushFountainPen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fountain pen'**
|
||||
String get brushFountainPen;
|
||||
|
||||
/// No description provided for @brushBallpoint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ballpoint'**
|
||||
String get brushBallpoint;
|
||||
|
||||
/// No description provided for @brushPencil.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pencil'**
|
||||
String get brushPencil;
|
||||
|
||||
/// No description provided for @brushHighlighter.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Highlighter'**
|
||||
String get brushHighlighter;
|
||||
|
||||
/// No description provided for @toolSelect.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select'**
|
||||
String get toolSelect;
|
||||
|
||||
/// No description provided for @toolShape.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shape'**
|
||||
String get toolShape;
|
||||
|
||||
/// No description provided for @shapePicker.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Shape'**
|
||||
String get shapePicker;
|
||||
|
||||
/// No description provided for @shapeLine.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Line'**
|
||||
String get shapeLine;
|
||||
|
||||
/// No description provided for @shapeRectangle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Rectangle'**
|
||||
String get shapeRectangle;
|
||||
|
||||
/// No description provided for @shapeEllipse.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ellipse'**
|
||||
String get shapeEllipse;
|
||||
|
||||
/// No description provided for @shapeArrow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Arrow'**
|
||||
String get shapeArrow;
|
||||
|
||||
/// No description provided for @actionDeleteSelection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete selection'**
|
||||
String get actionDeleteSelection;
|
||||
|
||||
/// No description provided for @actionUndo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -392,6 +542,90 @@ abstract class AppLocalizations {
|
||||
/// **'Highlight selection'**
|
||||
String get actionHighlightSelection;
|
||||
|
||||
/// No description provided for @toolRemoveHighlight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove highlight (tap a highlight)'**
|
||||
String get toolRemoveHighlight;
|
||||
|
||||
/// No description provided for @toolPlaceScratchLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Place scratch link'**
|
||||
String get toolPlaceScratchLink;
|
||||
|
||||
/// No description provided for @toolText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Text (tap or double-click to add)'**
|
||||
String get toolText;
|
||||
|
||||
/// No description provided for @textPlaceholder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Type…'**
|
||||
String get textPlaceholder;
|
||||
|
||||
/// No description provided for @scratchLinkDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete scratch link?'**
|
||||
String get scratchLinkDeleteTitle;
|
||||
|
||||
/// No description provided for @scratchLinkDeleteBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This removes the anchor and its private scratchpad.'**
|
||||
String get scratchLinkDeleteBody;
|
||||
|
||||
/// No description provided for @toolAddBookmark.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add bookmark (here or at selection)'**
|
||||
String get toolAddBookmark;
|
||||
|
||||
/// No description provided for @toolBookmarks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bookmarks'**
|
||||
String get toolBookmarks;
|
||||
|
||||
/// No description provided for @bookmarksTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bookmarks'**
|
||||
String get bookmarksTitle;
|
||||
|
||||
/// No description provided for @bookmarksEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No bookmarks yet.'**
|
||||
String get bookmarksEmpty;
|
||||
|
||||
/// No description provided for @bookmarkDefaultLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Page {page}'**
|
||||
String bookmarkDefaultLabel(int page);
|
||||
|
||||
/// No description provided for @bookmarkPageLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Page {page}'**
|
||||
String bookmarkPageLabel(int page);
|
||||
|
||||
/// No description provided for @bookmarkDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete bookmark?'**
|
||||
String get bookmarkDeleteTitle;
|
||||
|
||||
/// No description provided for @bookmarkDeleteBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This removes the saved location.'**
|
||||
String get bookmarkDeleteBody;
|
||||
|
||||
/// No description provided for @failedToOpenPdf.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -409,6 +643,576 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'{current} / {total}'**
|
||||
String pageOfPages(int current, int total);
|
||||
|
||||
/// No description provided for @libraryTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Library'**
|
||||
String get libraryTab;
|
||||
|
||||
/// No description provided for @boardTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stickies'**
|
||||
String get boardTab;
|
||||
|
||||
/// No description provided for @shellTagline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ink · Annotate · Know'**
|
||||
String get shellTagline;
|
||||
|
||||
/// No description provided for @notesSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notes'**
|
||||
String get notesSection;
|
||||
|
||||
/// No description provided for @documentsSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Documents'**
|
||||
String get documentsSection;
|
||||
|
||||
/// No description provided for @emptyLibraryTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing here yet'**
|
||||
String get emptyLibraryTitle;
|
||||
|
||||
/// No description provided for @emptyLibraryBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create a note, or import PDF / PPT / Word'**
|
||||
String get emptyLibraryBody;
|
||||
|
||||
/// No description provided for @diagnosticsSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Diagnostics'**
|
||||
String get diagnosticsSection;
|
||||
|
||||
/// No description provided for @diagnosticsExport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export diagnostic pack'**
|
||||
String get diagnosticsExport;
|
||||
|
||||
/// No description provided for @diagnosticsExportHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reproduce on Surface, export, and send the zip back'**
|
||||
String get diagnosticsExportHint;
|
||||
|
||||
/// No description provided for @diagnosticsToggle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Input diagnostics overlay'**
|
||||
String get diagnosticsToggle;
|
||||
|
||||
/// No description provided for @penSettingsUnified.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pen & ink'**
|
||||
String get penSettingsUnified;
|
||||
|
||||
/// No description provided for @board.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Board'**
|
||||
String get board;
|
||||
|
||||
/// No description provided for @boardTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sticky Board'**
|
||||
String get boardTitle;
|
||||
|
||||
/// No description provided for @boardOpen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sticky note board'**
|
||||
String get boardOpen;
|
||||
|
||||
/// No description provided for @boardAddCard.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add card'**
|
||||
String get boardAddCard;
|
||||
|
||||
/// No description provided for @boardNewCardText.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New note'**
|
||||
String get boardNewCardText;
|
||||
|
||||
/// No description provided for @boardDeleteCard.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete card'**
|
||||
String get boardDeleteCard;
|
||||
|
||||
/// No description provided for @boardDeleteCardTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete this card?'**
|
||||
String get boardDeleteCardTitle;
|
||||
|
||||
/// No description provided for @boardBacklinks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Linked from'**
|
||||
String get boardBacklinks;
|
||||
|
||||
/// No description provided for @boardNoBacklinks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing links here yet'**
|
||||
String get boardNoBacklinks;
|
||||
|
||||
/// No description provided for @boardDanglingLink.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No card named \"{target}\"'**
|
||||
String boardDanglingLink(String target);
|
||||
|
||||
/// No description provided for @close.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close'**
|
||||
String get close;
|
||||
|
||||
/// No description provided for @vaultSetupTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose your vault'**
|
||||
String get vaultSetupTitle;
|
||||
|
||||
/// No description provided for @vaultSetupHeadline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pick a folder for your notebooks'**
|
||||
String get vaultSetupHeadline;
|
||||
|
||||
/// No description provided for @vaultSetupBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.'**
|
||||
String get vaultSetupBody;
|
||||
|
||||
/// No description provided for @vaultChooseFolder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose folder'**
|
||||
String get vaultChooseFolder;
|
||||
|
||||
/// No description provided for @vaultMissingTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your vault folder is missing'**
|
||||
String get vaultMissingTitle;
|
||||
|
||||
/// No description provided for @vaultMissingBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.'**
|
||||
String get vaultMissingBody;
|
||||
|
||||
/// No description provided for @vaultPickFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t open the folder picker: {error}'**
|
||||
String vaultPickFailed(String error);
|
||||
|
||||
/// No description provided for @vaultNotWritable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That folder isn\'t writable. Please choose another.'**
|
||||
String get vaultNotWritable;
|
||||
|
||||
/// No description provided for @vaultSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vault'**
|
||||
String get vaultSection;
|
||||
|
||||
/// No description provided for @vaultFolderLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vault folder'**
|
||||
String get vaultFolderLabel;
|
||||
|
||||
/// No description provided for @vaultNoneSelected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No folder selected'**
|
||||
String get vaultNoneSelected;
|
||||
|
||||
/// No description provided for @vaultChangeFolder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Change vault folder'**
|
||||
String get vaultChangeFolder;
|
||||
|
||||
/// No description provided for @vaultUpdated.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Vault folder updated'**
|
||||
String get vaultUpdated;
|
||||
|
||||
/// No description provided for @syncSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync (WebDAV)'**
|
||||
String get syncSection;
|
||||
|
||||
/// No description provided for @syncServerUrl.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server URL'**
|
||||
String get syncServerUrl;
|
||||
|
||||
/// No description provided for @syncServerUrlHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'https://dav.example.com/remote.php/dav/files/me'**
|
||||
String get syncServerUrlHint;
|
||||
|
||||
/// No description provided for @syncUsername.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Username'**
|
||||
String get syncUsername;
|
||||
|
||||
/// No description provided for @syncPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password'**
|
||||
String get syncPassword;
|
||||
|
||||
/// No description provided for @syncRemoteFolder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remote folder'**
|
||||
String get syncRemoteFolder;
|
||||
|
||||
/// No description provided for @syncRemoteFolderHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BadNote'**
|
||||
String get syncRemoteFolderHint;
|
||||
|
||||
/// No description provided for @syncSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get syncSave;
|
||||
|
||||
/// No description provided for @syncSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync settings saved'**
|
||||
String get syncSaved;
|
||||
|
||||
/// No description provided for @syncTestConnection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Test connection'**
|
||||
String get syncTestConnection;
|
||||
|
||||
/// No description provided for @syncTestOk.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connection OK'**
|
||||
String get syncTestOk;
|
||||
|
||||
/// No description provided for @syncTestFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connection failed: {error}'**
|
||||
String syncTestFailed(String error);
|
||||
|
||||
/// No description provided for @syncNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync now'**
|
||||
String get syncNow;
|
||||
|
||||
/// No description provided for @syncRunning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Syncing…'**
|
||||
String get syncRunning;
|
||||
|
||||
/// No description provided for @syncNeverRun.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Never synced'**
|
||||
String get syncNeverRun;
|
||||
|
||||
/// No description provided for @syncLastRun.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last synced: {when}'**
|
||||
String syncLastRun(String when);
|
||||
|
||||
/// No description provided for @syncResultSummary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts'**
|
||||
String syncResultSummary(int uploaded, int downloaded, int conflicts);
|
||||
|
||||
/// No description provided for @syncFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync failed: {error}'**
|
||||
String syncFailed(String error);
|
||||
|
||||
/// No description provided for @syncAuto.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sync automatically on launch'**
|
||||
String get syncAuto;
|
||||
|
||||
/// No description provided for @syncCredentialsNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Credentials are stored locally in plain text. Use a dedicated app password.'**
|
||||
String get syncCredentialsNote;
|
||||
|
||||
/// No description provided for @syncNotConfigured.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Enter a server URL to enable sync.'**
|
||||
String get syncNotConfigured;
|
||||
|
||||
/// No description provided for @settingsDefaults.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Defaults'**
|
||||
String get settingsDefaults;
|
||||
|
||||
/// No description provided for @settingsAppearance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Appearance'**
|
||||
String get settingsAppearance;
|
||||
|
||||
/// No description provided for @settingsAbout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About'**
|
||||
String get settingsAbout;
|
||||
|
||||
/// No description provided for @settingsDefaultTool.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default tool'**
|
||||
String get settingsDefaultTool;
|
||||
|
||||
/// No description provided for @settingsDefaultColor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default color'**
|
||||
String get settingsDefaultColor;
|
||||
|
||||
/// No description provided for @settingsDefaultWidth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default stroke width'**
|
||||
String get settingsDefaultWidth;
|
||||
|
||||
/// No description provided for @settingsPressureCurve.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pressure curve'**
|
||||
String get settingsPressureCurve;
|
||||
|
||||
/// No description provided for @settingsClearConfirmBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This resets pen defaults and appearance. Notes and documents are not affected.'**
|
||||
String get settingsClearConfirmBody;
|
||||
|
||||
/// No description provided for @serverSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'BadNote Server'**
|
||||
String get serverSection;
|
||||
|
||||
/// No description provided for @serverUrl.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server URL'**
|
||||
String get serverUrl;
|
||||
|
||||
/// No description provided for @serverUrlHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'http://192.168.1.10:8080'**
|
||||
String get serverUrlHint;
|
||||
|
||||
/// No description provided for @serverUsername.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Username'**
|
||||
String get serverUsername;
|
||||
|
||||
/// No description provided for @serverPassword.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Password'**
|
||||
String get serverPassword;
|
||||
|
||||
/// No description provided for @serverSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save & sign in'**
|
||||
String get serverSave;
|
||||
|
||||
/// No description provided for @serverTest.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Test connection'**
|
||||
String get serverTest;
|
||||
|
||||
/// No description provided for @serverTestOk.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connected · API {version}'**
|
||||
String serverTestOk(String version);
|
||||
|
||||
/// No description provided for @serverTestFail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connection failed: {error}'**
|
||||
String serverTestFail(String error);
|
||||
|
||||
/// No description provided for @serverLoggedIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Signed in'**
|
||||
String get serverLoggedIn;
|
||||
|
||||
/// No description provided for @serverHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.'**
|
||||
String get serverHint;
|
||||
|
||||
/// No description provided for @boardEmptyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No sticky notes yet'**
|
||||
String get boardEmptyTitle;
|
||||
|
||||
/// No description provided for @boardEmptyBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.'**
|
||||
String get boardEmptyBody;
|
||||
|
||||
/// No description provided for @relativeJustNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Just now'**
|
||||
String get relativeJustNow;
|
||||
|
||||
/// No description provided for @relativeMinutesAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{n}m ago'**
|
||||
String relativeMinutesAgo(int n);
|
||||
|
||||
/// No description provided for @relativeHoursAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{n}h ago'**
|
||||
String relativeHoursAgo(int n);
|
||||
|
||||
/// No description provided for @relativeYesterday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Yesterday'**
|
||||
String get relativeYesterday;
|
||||
|
||||
/// No description provided for @diagExported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Diagnostic pack exported ({bytes} bytes)\nPath copied'**
|
||||
String diagExported(int bytes);
|
||||
|
||||
/// No description provided for @diagExportFail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Export failed: {error}'**
|
||||
String diagExportFail(String error);
|
||||
|
||||
/// No description provided for @processingOcr.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Processing OCR…'**
|
||||
String get processingOcr;
|
||||
|
||||
/// No description provided for @notebooksSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notebooks'**
|
||||
String get notebooksSection;
|
||||
|
||||
/// No description provided for @addBlankPage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Blank page'**
|
||||
String get addBlankPage;
|
||||
|
||||
/// No description provided for @importIntoNotebook.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import into notebook'**
|
||||
String get importIntoNotebook;
|
||||
|
||||
/// No description provided for @notebookMembersEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No pages yet'**
|
||||
String get notebookMembersEmpty;
|
||||
|
||||
/// No description provided for @memberCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} items'**
|
||||
String memberCount(int count);
|
||||
|
||||
/// No description provided for @textFontSmall.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'S'**
|
||||
String get textFontSmall;
|
||||
|
||||
/// No description provided for @textFontMedium.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'M'**
|
||||
String get textFontMedium;
|
||||
|
||||
/// No description provided for @textFontLarge.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'L'**
|
||||
String get textFontLarge;
|
||||
|
||||
/// No description provided for @textBold.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bold'**
|
||||
String get textBold;
|
||||
|
||||
/// No description provided for @textDragHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Drag to move'**
|
||||
String get textDragHint;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -23,6 +23,47 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@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)';
|
||||
|
||||
@@ -125,6 +166,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@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';
|
||||
|
||||
@@ -161,6 +241,53 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@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';
|
||||
@@ -173,4 +300,324 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -23,6 +23,47 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@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 => '手写画布(测试版)';
|
||||
|
||||
@@ -125,6 +166,45 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@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 => '撤销';
|
||||
|
||||
@@ -161,6 +241,52 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@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';
|
||||
@@ -173,4 +299,317 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
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 => '拖动移动';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
"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": "打开",
|
||||
@@ -37,6 +49,19 @@
|
||||
"toolPen": "钢笔",
|
||||
"toolHighlighter": "荧光笔",
|
||||
"toolEraser": "橡皮擦",
|
||||
"brushPicker": "笔刷",
|
||||
"brushFountainPen": "钢笔",
|
||||
"brushBallpoint": "圆珠笔",
|
||||
"brushPencil": "铅笔",
|
||||
"brushHighlighter": "荧光笔",
|
||||
"toolSelect": "选择",
|
||||
"toolShape": "形状",
|
||||
"shapePicker": "形状",
|
||||
"shapeLine": "直线",
|
||||
"shapeRectangle": "矩形",
|
||||
"shapeEllipse": "椭圆",
|
||||
"shapeArrow": "箭头",
|
||||
"actionDeleteSelection": "删除所选",
|
||||
"actionUndo": "撤销",
|
||||
"actionRedo": "重做",
|
||||
"fingerDrawingOn": "手指书写:开",
|
||||
@@ -49,7 +74,151 @@
|
||||
"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}"
|
||||
"pageOfPages": "{current} / {total}",
|
||||
"libraryTab": "库",
|
||||
"boardTab": "便利贴",
|
||||
"shellTagline": "手写 · 批注 · 知识",
|
||||
"notesSection": "笔记",
|
||||
"documentsSection": "文档",
|
||||
"emptyLibraryTitle": "还没有内容",
|
||||
"emptyLibraryBody": "新建笔记,或导入 PDF / PPT / Word",
|
||||
"diagnosticsSection": "诊断",
|
||||
"diagnosticsExport": "导出诊断包",
|
||||
"diagnosticsExportHint": "在 Surface 上复现问题后导出,发回给开发者分析",
|
||||
"diagnosticsToggle": "输入诊断叠加层",
|
||||
"penSettingsUnified": "笔与墨迹",
|
||||
"board": "便利贴板",
|
||||
"boardTitle": "便利贴板",
|
||||
"boardOpen": "便利贴板",
|
||||
"boardAddCard": "添加便利贴",
|
||||
"boardNewCardText": "新便利贴",
|
||||
"boardDeleteCard": "删除便利贴",
|
||||
"boardDeleteCardTitle": "删除这张便利贴?",
|
||||
"boardBacklinks": "哪些链接到这里",
|
||||
"boardNoBacklinks": "暂无其他便利贴链接到这里",
|
||||
"boardDanglingLink": "没有名为“{target}”的便利贴",
|
||||
"close": "关闭",
|
||||
"vaultSetupTitle": "选择笔记库",
|
||||
"vaultSetupHeadline": "为你的笔记本选择一个文件夹",
|
||||
"vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。",
|
||||
"vaultChooseFolder": "选择文件夹",
|
||||
"vaultMissingTitle": "笔记库文件夹不见了",
|
||||
"vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。",
|
||||
"vaultPickFailed": "无法打开文件夹选择器:{error}",
|
||||
"vaultNotWritable": "该文件夹不可写,请另选一个。",
|
||||
"vaultSection": "笔记库",
|
||||
"vaultFolderLabel": "笔记库文件夹",
|
||||
"vaultNoneSelected": "尚未选择文件夹",
|
||||
"vaultChangeFolder": "更改笔记库文件夹",
|
||||
"vaultUpdated": "笔记库文件夹已更新",
|
||||
"syncSection": "同步(WebDAV)",
|
||||
"syncServerUrl": "服务器地址",
|
||||
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
|
||||
"syncUsername": "用户名",
|
||||
"syncPassword": "密码",
|
||||
"syncRemoteFolder": "远程文件夹",
|
||||
"syncRemoteFolderHint": "BadNote",
|
||||
"syncSave": "保存",
|
||||
"syncSaved": "同步设置已保存",
|
||||
"syncTestConnection": "测试连接",
|
||||
"syncTestOk": "连接成功",
|
||||
"syncTestFailed": "连接失败:{error}",
|
||||
"@syncTestFailed": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"syncNow": "立即同步",
|
||||
"syncRunning": "同步中…",
|
||||
"syncNeverRun": "尚未同步",
|
||||
"syncLastRun": "上次同步:{when}",
|
||||
"@syncLastRun": {
|
||||
"placeholders": { "when": { "type": "String" } }
|
||||
},
|
||||
"syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}",
|
||||
"@syncResultSummary": {
|
||||
"placeholders": {
|
||||
"uploaded": { "type": "int" },
|
||||
"downloaded": { "type": "int" },
|
||||
"conflicts": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"syncFailed": "同步失败:{error}",
|
||||
"@syncFailed": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"syncAuto": "启动时自动同步",
|
||||
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
|
||||
"syncNotConfigured": "请输入服务器地址以启用同步。",
|
||||
"settingsDefaults": "默认笔迹",
|
||||
"settingsAppearance": "外观",
|
||||
"settingsAbout": "关于",
|
||||
"settingsDefaultTool": "默认工具",
|
||||
"settingsDefaultColor": "默认颜色",
|
||||
"settingsDefaultWidth": "默认线宽",
|
||||
"settingsPressureCurve": "压感曲线",
|
||||
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
|
||||
"serverSection": "BadNote 服务器",
|
||||
"serverUrl": "服务器地址",
|
||||
"serverUrlHint": "http://192.168.1.10:8080",
|
||||
"serverUsername": "用户名",
|
||||
"serverPassword": "密码",
|
||||
"serverSave": "保存并登录",
|
||||
"serverTest": "测试连接",
|
||||
"serverTestOk": "连接成功 · API {version}",
|
||||
"@serverTestOk": {
|
||||
"placeholders": { "version": { "type": "String" } }
|
||||
},
|
||||
"serverTestFail": "连接失败:{error}",
|
||||
"@serverTestFail": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"serverLoggedIn": "已登录",
|
||||
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR;日常笔记仍完全离线。",
|
||||
"boardEmptyTitle": "还没有便利贴",
|
||||
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
|
||||
"relativeJustNow": "刚刚",
|
||||
"relativeMinutesAgo": "{n} 分钟前",
|
||||
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||
"relativeHoursAgo": "{n} 小时前",
|
||||
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
|
||||
"relativeYesterday": "昨天",
|
||||
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
|
||||
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||
"diagExportFail": "导出失败:{error}",
|
||||
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||
"processingOcr": "正在识别文字…",
|
||||
"notebooksSection": "笔记本",
|
||||
"addBlankPage": "空白页",
|
||||
"importIntoNotebook": "导入到笔记本",
|
||||
"notebookMembersEmpty": "还没有页面",
|
||||
"memberCount": "{count} 项",
|
||||
"@memberCount": {
|
||||
"placeholders": { "count": { "type": "int" } }
|
||||
},
|
||||
"textFontSmall": "小",
|
||||
"textFontMedium": "中",
|
||||
"textFontLarge": "大",
|
||||
"textBold": "粗体",
|
||||
"textDragHint": "拖动移动"
|
||||
}
|
||||
|
||||
173
lib/main.dart
173
lib/main.dart
@@ -2,21 +2,25 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_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();
|
||||
@@ -27,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);
|
||||
@@ -54,8 +84,8 @@ class BadNoteApp extends ConsumerWidget {
|
||||
return MaterialApp(
|
||||
title: 'BadNote',
|
||||
themeMode: settings.themeMode,
|
||||
theme: _theme(lightScheme),
|
||||
darkTheme: _theme(darkScheme),
|
||||
theme: AppTheme.fromScheme(lightScheme),
|
||||
darkTheme: AppTheme.fromScheme(darkScheme),
|
||||
// i18n: follows the OS language (en / zh) via the system locale.
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
@@ -64,17 +94,128 @@ class BadNoteApp extends ConsumerWidget {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const HomeScreen(),
|
||||
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,35 +1,46 @@
|
||||
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 '../providers/search_provider.dart';
|
||||
import '../editor/canvas/pen_editor_screen.dart';
|
||||
import '../services/pdf_service.dart';
|
||||
import '../editor/canvas/office_document_screen.dart';
|
||||
import '../services/pptx_service.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) {
|
||||
@@ -38,42 +49,40 @@ class HomeScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l.appTitle),
|
||||
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: l.settings,
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
tooltip: l.importPdf,
|
||||
onPressed: () => _importPdf(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.slideshow),
|
||||
tooltip: l.importPpt,
|
||||
onPressed: () => _importPptx(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: l.search,
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
||||
},
|
||||
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()),
|
||||
@@ -81,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(
|
||||
@@ -90,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -123,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(
|
||||
@@ -134,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) =>
|
||||
@@ -155,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(
|
||||
@@ -164,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(
|
||||
@@ -184,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: (_) => PenNoteScreen(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: (_) => PenEditorScreen(pdfPath: filePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importPptx(BuildContext context) async {
|
||||
final pptxService = PptxService();
|
||||
final filePath = await pptxService.openPptxFile();
|
||||
if (filePath == null || !context.mounted) return;
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
|
||||
}
|
||||
|
||||
final slideImages = await pptxService.convertToImages(filePath);
|
||||
final extractedText = await pptxService.extractText(filePath);
|
||||
|
||||
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: (_) => PenSlideScreen(
|
||||
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(
|
||||
@@ -244,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,
|
||||
),
|
||||
@@ -260,19 +416,13 @@ class HomeScreen extends ConsumerWidget {
|
||||
FilledButton.icon(
|
||||
onPressed: () => _createAndOpenNote(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(AppLocalizations.of(context).newNote),
|
||||
label: Text(AppLocalizations.of(context).createNotebook),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPdf(context),
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
label: Text(AppLocalizations.of(context).importPdf),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _importPptx(context),
|
||||
icon: const Icon(Icons.slideshow),
|
||||
label: Text(AppLocalizations.of(context).importPpt),
|
||||
onPressed: () => _importFile(context, ref),
|
||||
icon: const Icon(Icons.file_open),
|
||||
label: Text(AppLocalizations.of(context).importFile),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -280,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});
|
||||
@@ -295,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;
|
||||
@@ -327,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)
|
||||
@@ -466,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;
|
||||
@@ -492,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),
|
||||
@@ -527,22 +704,34 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
||||
);
|
||||
}
|
||||
|
||||
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen
|
||||
// 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: (_) => PenEditorScreen(pdfPath: document.filePath),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// PPT/PPTX: convert to images then push PenSlideScreen
|
||||
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();
|
||||
@@ -551,7 +740,7 @@ 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;
|
||||
}
|
||||
@@ -567,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(
|
||||
@@ -599,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(
|
||||
@@ -631,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) {
|
||||
@@ -647,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(
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import '../providers/search_provider.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();
|
||||
|
||||
@@ -1,16 +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,
|
||||
@@ -53,10 +61,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
|
||||
content: const Text(
|
||||
'This will reset pen defaults and appearance settings. '
|
||||
'Notes and documents are not affected.',
|
||||
),
|
||||
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
@@ -84,18 +89,34 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context).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>(
|
||||
@@ -115,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(
|
||||
@@ -150,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,
|
||||
@@ -163,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>(
|
||||
@@ -212,15 +233,18 @@ 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>(
|
||||
@@ -247,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(
|
||||
@@ -282,7 +306,37 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
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(
|
||||
@@ -320,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,33 +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 '../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
|
||||
@@ -37,56 +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) --
|
||||
// The scratchpad is an infinite WORLD: strokes are stored in absolute world
|
||||
// pixels ([InkStroke], unchanged persistence format), and rendered through the
|
||||
// performant PenCanvas by normalizing against the CURRENT world size. When the
|
||||
// world auto-expands, the stored world coords don't move — only the
|
||||
// normalization divisor grows — so ink stays put with zero drift.
|
||||
// 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();
|
||||
|
||||
/// Pan/zoom transform for the scratchpad world (PenCanvas drives this).
|
||||
final TransformationController _scratchTransform = TransformationController();
|
||||
|
||||
/// Set once the initial view has been framed onto existing ink.
|
||||
bool _scratchCentered = false;
|
||||
|
||||
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
|
||||
|
||||
/// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and
|
||||
/// text fall back to pen (the pen-first scratchpad is freehand).
|
||||
CanvasTool get _canvasTool => switch (_currentTool) {
|
||||
PenTool.eraser => CanvasTool.eraser,
|
||||
PenTool.highlighter => CanvasTool.highlighter,
|
||||
_ => CanvasTool.pen,
|
||||
};
|
||||
// -- Tool state (new Material You brush palette) --
|
||||
CanvasTool _tool = CanvasTool.pen;
|
||||
BrushKind _penBrush = BrushKind.fountainPen;
|
||||
Color _color = Colors.black;
|
||||
|
||||
// -- 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;
|
||||
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;
|
||||
@@ -94,34 +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(() {
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
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() {
|
||||
@@ -132,16 +162,23 @@ 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 --
|
||||
|
||||
/// PenCanvas committed a stroke (normalized to the current world). Convert it
|
||||
/// to absolute world coords for storage.
|
||||
void _onStrokeComplete(PenStroke pen) {
|
||||
final stroke = inkStrokeFromPen(pen, _worldSize,
|
||||
id: _uuid.v4(), createdAt: DateTime.now());
|
||||
@@ -153,8 +190,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
_scheduleSave();
|
||||
}
|
||||
|
||||
/// PenCanvas erased through stroke [index] (into [_strokes]); [replacements]
|
||||
/// are the surviving sub-strokes (normalized) — convert back to world coords.
|
||||
void _onErase(int index, List<PenStroke> replacements) {
|
||||
if (index < 0 || index >= _strokes.length) return;
|
||||
setState(() {
|
||||
@@ -229,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)',
|
||||
@@ -314,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()}',
|
||||
@@ -332,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(
|
||||
@@ -340,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) =>
|
||||
@@ -401,7 +378,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Right pane: Infinite scratchpad
|
||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||
],
|
||||
);
|
||||
@@ -413,72 +389,121 @@ 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;
|
||||
});
|
||||
},
|
||||
/// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPdfPane() {
|
||||
// Read-only reference PDF on the same engine (pdfrx) as the rest of the
|
||||
// app, opened at the anchor's page.
|
||||
return PdfViewer.file(
|
||||
widget.filePath,
|
||||
controller: _pdfController,
|
||||
params: PdfViewerParams(
|
||||
onViewerReady: (document, controller) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pageCount = document.pages.length;
|
||||
});
|
||||
final target = widget.initialPage.clamp(0, _pageCount - 1);
|
||||
if (target > 0) {
|
||||
controller.goToPage(pageNumber: target + 1);
|
||||
}
|
||||
},
|
||||
onPageChanged: (pageNumber) {
|
||||
if (pageNumber == null || !mounted) return;
|
||||
final idx = pageNumber - 1;
|
||||
if (idx != _currentPage) setState(() => _currentPage = idx);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScratchpadPane() {
|
||||
// Render the world through the performant PenCanvas: strokes normalized
|
||||
// against the current world size; toolbar width is in world pixels, so the
|
||||
// pen-canvas fraction is width / worldWidth.
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// On first layout, frame the view so existing ink is actually visible
|
||||
// (otherwise identity shows only the empty top-left corner of the huge
|
||||
// world). Empty scratchpad falls back to a comfortable 1:1 near origin.
|
||||
if (!_scratchCentered) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
@@ -493,10 +518,11 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
pageSize: _worldSize,
|
||||
strokes: penStrokesFromInk(_strokes, _worldSize),
|
||||
transformationController: _scratchTransform,
|
||||
tool: _canvasTool,
|
||||
color: _currentColor,
|
||||
strokeWidth: _currentStrokeWidth / _canvasWidth,
|
||||
// The world is huge, so allow zooming further out to survey it.
|
||||
tool: _activeTool,
|
||||
brush: _penBrush,
|
||||
color: _color,
|
||||
strokeWidth: _strokeWidth,
|
||||
eraserRadius: kDefaultEraserRadius,
|
||||
minScale: 0.1,
|
||||
maxScale: 8.0,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
@@ -508,9 +534,8 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Position the scratchpad so existing ink is on-screen. Fits the strokes'
|
||||
/// world bounding box into [pane] (with padding, scale clamped); for an empty
|
||||
/// scratchpad, shows the top-left working area at 1:1.
|
||||
/// 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) {
|
||||
@@ -534,9 +559,10 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
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 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;
|
||||
@@ -548,11 +574,3 @@ class _SplitViewState extends State<SplitViewScreen> {
|
||||
..setTranslationRaw(tx, ty, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A marker linking a scratchpad position to a specific PDF page.
|
||||
class _PageLink {
|
||||
final int pageNumber;
|
||||
final Offset position;
|
||||
|
||||
const _PageLink({required this.pageNumber, required this.position});
|
||||
}
|
||||
|
||||
158
lib/screens/vault_setup_screen.dart
Normal file
158
lib/screens/vault_setup_screen.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/vault_service.dart';
|
||||
|
||||
/// First-run (and re-prompt) gate that asks the user to pick a vault root
|
||||
/// folder — the single folder under which all notebooks will live, like an
|
||||
/// Obsidian vault.
|
||||
///
|
||||
/// On a successful, writable selection the chosen path is persisted via
|
||||
/// [VaultService] and [onVaultReady] is invoked so the host can proceed to the
|
||||
/// home screen. When [missing] is true the screen shows a "your vault folder is
|
||||
/// missing" message instead of the first-run copy (the saved folder no longer
|
||||
/// exists).
|
||||
class VaultSetupScreen extends StatefulWidget {
|
||||
const VaultSetupScreen({
|
||||
super.key,
|
||||
required this.vaultService,
|
||||
required this.onVaultReady,
|
||||
this.missing = false,
|
||||
});
|
||||
|
||||
final VaultService vaultService;
|
||||
|
||||
/// Called after a valid, writable vault root has been persisted.
|
||||
final VoidCallback onVaultReady;
|
||||
|
||||
/// Whether a previously-chosen folder went missing (changes the copy).
|
||||
final bool missing;
|
||||
|
||||
@override
|
||||
State<VaultSetupScreen> createState() => _VaultSetupScreenState();
|
||||
}
|
||||
|
||||
class _VaultSetupScreenState extends State<VaultSetupScreen> {
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _pickFolder() async {
|
||||
final l = AppLocalizations.of(context);
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// getDirectoryPath is Desktop/Windows supported by file_picker.
|
||||
// lockParentWindow makes the native Windows dialog modal.
|
||||
final path = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: l.vaultSetupTitle,
|
||||
lockParentWindow: true,
|
||||
);
|
||||
if (path == null) {
|
||||
// User cancelled the native dialog.
|
||||
if (mounted) setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
if (!await _isWritable(path)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = l.vaultNotWritable;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await widget.vaultService.setVaultRoot(path);
|
||||
if (mounted) widget.onVaultReady();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = l.vaultPickFailed(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe writability by creating and deleting a temp file in [path]. The
|
||||
/// native folder dialog can hand back a read-only location on Windows, so we
|
||||
/// validate before committing it as the vault.
|
||||
Future<bool> _isWritable(String path) async {
|
||||
final probe = File(p.join(path, '.badnote-write-probe'));
|
||||
try {
|
||||
await probe.writeAsString('ok', flush: true);
|
||||
await probe.delete();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
widget.missing
|
||||
? Icons.folder_off_outlined
|
||||
: Icons.folder_special_outlined,
|
||||
size: 56,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
widget.missing ? l.vaultMissingTitle : l.vaultSetupHeadline,
|
||||
style: textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.missing ? l.vaultMissingBody : l.vaultSetupBody,
|
||||
style: textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
style: textTheme.bodyMedium
|
||||
?.copyWith(color: colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: _busy ? null : _pickFolder,
|
||||
icon: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.folder_open),
|
||||
label: Text(l.vaultChooseFolder),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
lib/services/badnote_server_client.dart
Normal file
139
lib/services/badnote_server_client.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
|
||||
// WebDAV remains the primary NAS sync path; this client covers health, auth,
|
||||
// vault manifest assist, and OCR job submit/poll.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BadNoteServerConfig {
|
||||
const BadNoteServerConfig({
|
||||
this.baseUrl = '',
|
||||
this.username = '',
|
||||
this.password = '',
|
||||
this.token = '',
|
||||
this.userId = '',
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String username;
|
||||
final String password;
|
||||
final String token;
|
||||
final String userId;
|
||||
|
||||
bool get isConfigured => baseUrl.trim().isNotEmpty;
|
||||
bool get isLoggedIn => token.isNotEmpty;
|
||||
|
||||
Uri? uri(String path) {
|
||||
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
if (base.isEmpty) return null;
|
||||
final p = path.startsWith('/') ? path : '/$path';
|
||||
return Uri.parse('$base$p');
|
||||
}
|
||||
|
||||
BadNoteServerConfig copyWith({
|
||||
String? baseUrl,
|
||||
String? username,
|
||||
String? password,
|
||||
String? token,
|
||||
String? userId,
|
||||
}) =>
|
||||
BadNoteServerConfig(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
username: username ?? this.username,
|
||||
password: password ?? this.password,
|
||||
token: token ?? this.token,
|
||||
userId: userId ?? this.userId,
|
||||
);
|
||||
|
||||
static const _kBase = 'badnote.server.baseUrl';
|
||||
static const _kUser = 'badnote.server.username';
|
||||
static const _kPass = 'badnote.server.password';
|
||||
static const _kToken = 'badnote.server.token';
|
||||
static const _kUid = 'badnote.server.userId';
|
||||
|
||||
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
|
||||
return BadNoteServerConfig(
|
||||
baseUrl: prefs.getString(_kBase) ?? '',
|
||||
username: prefs.getString(_kUser) ?? '',
|
||||
password: prefs.getString(_kPass) ?? '',
|
||||
token: prefs.getString(_kToken) ?? '',
|
||||
userId: prefs.getString(_kUid) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(SharedPreferences prefs) async {
|
||||
await prefs.setString(_kBase, baseUrl);
|
||||
await prefs.setString(_kUser, username);
|
||||
await prefs.setString(_kPass, password);
|
||||
await prefs.setString(_kToken, token);
|
||||
await prefs.setString(_kUid, userId);
|
||||
}
|
||||
}
|
||||
|
||||
class BadNoteServerClient {
|
||||
BadNoteServerClient(this.config, {http.Client? httpClient})
|
||||
: _http = httpClient ?? http.Client();
|
||||
|
||||
BadNoteServerConfig config;
|
||||
final http.Client _http;
|
||||
|
||||
Map<String, String> get _authHeaders => {
|
||||
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
|
||||
};
|
||||
|
||||
Future<Map<String, dynamic>> health() async {
|
||||
final uri = config.uri('/api/v1/health');
|
||||
if (uri == null) throw StateError('server URL not set');
|
||||
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('health ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<BadNoteServerConfig> registerOrLogin({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final registerUri = config.uri('/api/v1/auth/register');
|
||||
if (registerUri == null) throw StateError('server URL not set');
|
||||
var res = await _http.post(
|
||||
registerUri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
if (res.statusCode == 409) {
|
||||
final loginUri = config.uri('/api/v1/auth/login')!;
|
||||
res = await _http.post(
|
||||
loginUri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'username': username, 'password': password}),
|
||||
);
|
||||
}
|
||||
if (res.statusCode != 200 && res.statusCode != 201) {
|
||||
throw StateError('auth ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
final body = jsonDecode(res.body) as Map<String, dynamic>;
|
||||
config = config.copyWith(
|
||||
username: username,
|
||||
password: password,
|
||||
token: body['token'] as String? ?? '',
|
||||
userId: body['user_id'] as String? ?? '',
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> vaultManifest() async {
|
||||
final uri = config.uri('/api/v1/vault/manifest');
|
||||
if (uri == null) throw StateError('server URL not set');
|
||||
final res = await _http.get(uri, headers: _authHeaders);
|
||||
if (res.statusCode != 200) {
|
||||
throw StateError('manifest ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
335
lib/storage/sqlite_to_sidecar_migrator.dart
Normal file
335
lib/storage/sqlite_to_sidecar_migrator.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
// lib/storage/sqlite_to_sidecar_migrator.dart
|
||||
//
|
||||
// Phase 5 of the file-based storage plan (docs/plans/2026-06-24-file-based-
|
||||
// storage.md §B): a ONE-TIME, ADDITIVE, IDEMPOTENT migration of legacy SQLite
|
||||
// data into vault sidecars. A user upgrading from the SQLite era has existing
|
||||
// documents/notes/ink/scratchpads/bookmarks that the new editors no longer
|
||||
// write to; this lifts that data into `<file>.badnote.json` sidecars so nothing
|
||||
// is lost.
|
||||
//
|
||||
// Guarantees (§B / acceptance):
|
||||
// * NEVER loses data. The legacy DB is renamed to `*.premigration`, never
|
||||
// deleted, BEFORE the migration flag is flipped (so a failed run loses
|
||||
// nothing and the caller can retry).
|
||||
// * Additive: only WRITES sidecars + COPIES source files into the vault. Reads
|
||||
// the legacy DB read-only.
|
||||
// * Idempotent / re-runnable: a target sidecar that already exists is skipped,
|
||||
// so a half-finished run resumes on relaunch and a completed run is a no-op.
|
||||
// * Missing-source-graceful: if a legacy document's source file is gone, its
|
||||
// annotations (the precious part) are still migrated into a notebook folder;
|
||||
// the absence is recorded in [MigrationReport.missingSources], not fatal.
|
||||
//
|
||||
// Mapping (table → sidecar field):
|
||||
// documents → a notebook folder + `<file>.badnote.json`
|
||||
// ink (host page) → sidecar.strokes[pageIndex] (EditorStroke JSON)
|
||||
// bookmarks → sidecar.bookmarks (Bookmark JSON)
|
||||
// scratch_links → sidecar.scratchLinks[].link (ScratchLink JSON)
|
||||
// scratchpads → sidecar.scratchLinks[].scratchpad (InkStroke JSON, abs px)
|
||||
// annotations → sidecar.legacyAnnotations (raw blob, never dropped)
|
||||
// highlights → none in legacy data (always empty)
|
||||
// notes + strokes → a standalone notebook folder + `notebook.badnote.json`
|
||||
// (strokes normalized onto page 0 as EditorStroke, exactly
|
||||
// as the runtime note editor persists them)
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import '../editor/engine/stroke_model.dart';
|
||||
import '../editor/notebook/ink_stroke_adapter.dart';
|
||||
import '../models/document.dart' as doc;
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/vault_service.dart';
|
||||
import 'badnote_sidecar.dart';
|
||||
import 'sidecar_store.dart';
|
||||
|
||||
/// Outcome of one [SqliteToSidecarMigrator.run] call. Carries counts + the
|
||||
/// notebook/source paths touched so callers (and tests) can assert coverage and
|
||||
/// surface a brief summary.
|
||||
class MigrationReport {
|
||||
MigrationReport({
|
||||
this.documentsMigrated = 0,
|
||||
this.documentsSkipped = 0,
|
||||
this.notesMigrated = 0,
|
||||
this.notesSkipped = 0,
|
||||
List<String>? missingSources,
|
||||
this.legacyDbFound = false,
|
||||
this.legacyDbPreservedPath,
|
||||
}) : missingSources = missingSources ?? <String>[];
|
||||
|
||||
/// File-backed documents written as new sidecars this run.
|
||||
int documentsMigrated;
|
||||
|
||||
/// File-backed documents skipped because their sidecar already existed.
|
||||
int documentsSkipped;
|
||||
|
||||
/// Standalone notes written as new `notebook.badnote.json` this run.
|
||||
int notesMigrated;
|
||||
|
||||
/// Standalone notes skipped because their sidecar already existed.
|
||||
int notesSkipped;
|
||||
|
||||
/// Filenames of legacy documents whose source file no longer existed on disk
|
||||
/// (annotations were still migrated into a notebook folder without a file).
|
||||
final List<String> missingSources;
|
||||
|
||||
/// Whether a legacy DB file was actually found and opened.
|
||||
bool legacyDbFound;
|
||||
|
||||
/// Where the legacy DB ended up (its `*.premigration` path), or null if there
|
||||
/// was no legacy DB to preserve.
|
||||
String? legacyDbPreservedPath;
|
||||
|
||||
bool get didAnything =>
|
||||
documentsMigrated > 0 || notesMigrated > 0;
|
||||
|
||||
@override
|
||||
String toString() => 'MigrationReport(documentsMigrated: $documentsMigrated, '
|
||||
'documentsSkipped: $documentsSkipped, notesMigrated: $notesMigrated, '
|
||||
'notesSkipped: $notesSkipped, missingSources: ${missingSources.length}, '
|
||||
'legacyDbFound: $legacyDbFound)';
|
||||
}
|
||||
|
||||
/// One-time SQLite → sidecar migrator. Construct with the target [VaultService]
|
||||
/// and (optionally) an explicit legacy DB path for tests; call [run] once.
|
||||
class SqliteToSidecarMigrator {
|
||||
SqliteToSidecarMigrator(this._vault, {String? legacyDbPath})
|
||||
: _legacyDbPathOverride = legacyDbPath;
|
||||
|
||||
final VaultService _vault;
|
||||
final String? _legacyDbPathOverride;
|
||||
|
||||
/// Suffix the legacy DB is renamed to so it is preserved (never destroyed).
|
||||
static const String premigrationSuffix = '.premigration';
|
||||
|
||||
/// Logical page a legacy free-ink note was drawn on (its `InkStroke`s are in
|
||||
/// absolute pixels on this rect). Migrated strokes are normalized against it,
|
||||
/// exactly as the runtime note editor does on load.
|
||||
static const _noteLogicalPage = kNoteLogicalPage;
|
||||
|
||||
/// Run the migration. Safe to call when there is nothing to migrate (fresh
|
||||
/// install → no legacy DB → no-op). Returns a [MigrationReport].
|
||||
///
|
||||
/// Throws [StateError] if the vault root is not valid (callers must gate on a
|
||||
/// valid vault first).
|
||||
Future<MigrationReport> run() async {
|
||||
if (!await _vault.vaultRootValid()) {
|
||||
throw StateError('Cannot migrate: vault root is not valid.');
|
||||
}
|
||||
|
||||
final report = MigrationReport();
|
||||
|
||||
final legacyPath = _legacyDbPathOverride ?? await DatabaseService.legacyDbPath();
|
||||
final legacyFile = File(legacyPath);
|
||||
if (!await legacyFile.exists()) {
|
||||
// Fresh install (or already migrated + renamed): nothing to do.
|
||||
return report;
|
||||
}
|
||||
report.legacyDbFound = true;
|
||||
|
||||
// sqflite ffi must be initialised before opening (the migrator may run on
|
||||
// desktop before any DatabaseService.getInstance call).
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
sqfliteFfiInit();
|
||||
}
|
||||
final db = await databaseFactoryFfi.openDatabase(
|
||||
legacyPath,
|
||||
options: OpenDatabaseOptions(readOnly: true, singleInstance: false),
|
||||
);
|
||||
try {
|
||||
await _migrateDocuments(db, report);
|
||||
await _migrateNotes(db, report);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
// Preserve the legacy DB as `*.premigration` (never delete). Done AFTER a
|
||||
// successful pass so a crash mid-migration leaves the original in place for
|
||||
// a clean retry. Idempotent: if already renamed on a prior run, skip.
|
||||
final preserved = File('$legacyPath$premigrationSuffix');
|
||||
if (!await preserved.exists()) {
|
||||
await legacyFile.rename(preserved.path);
|
||||
}
|
||||
report.legacyDbPreservedPath = preserved.path;
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
Future<void> _migrateDocuments(Database db, MigrationReport report) async {
|
||||
final documents = await DatabaseService.rawAllDocuments(db);
|
||||
// Robust idempotency: a set of legacy ids already migrated, recovered by
|
||||
// scanning every existing sidecar's `legacyId`. Re-running recognizes
|
||||
// already-migrated items even if folder names collided.
|
||||
final migratedIds = await _migratedLegacyIds();
|
||||
for (final document in documents) {
|
||||
if (migratedIds.contains(document.id)) {
|
||||
report.documentsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
final sourceExists = await File(document.filePath).exists();
|
||||
|
||||
// Locate/create the notebook folder. Reuse VaultService.createNotebook
|
||||
// (folder + file copy) when the source exists; otherwise make an
|
||||
// annotations-only folder so the precious ink is never lost.
|
||||
final String vaultSourcePath;
|
||||
if (sourceExists) {
|
||||
vaultSourcePath = await _vault.createNotebook(document.filePath);
|
||||
} else {
|
||||
report.missingSources.add(document.filename);
|
||||
vaultSourcePath = await _ensureNotebookForMissingSource(
|
||||
document.filename,
|
||||
);
|
||||
}
|
||||
|
||||
final sidecarFile = File('$vaultSourcePath$kVaultSidecarSuffix');
|
||||
final sidecar = await _buildDocumentSidecar(db, document, vaultSourcePath);
|
||||
await SidecarStore.writeAtomic(sidecarFile, sidecar);
|
||||
migratedIds.add(document.id);
|
||||
report.documentsMigrated++;
|
||||
}
|
||||
}
|
||||
|
||||
Future<BadnoteSidecar> _buildDocumentSidecar(
|
||||
Database db,
|
||||
doc.Document document,
|
||||
String vaultSourcePath,
|
||||
) async {
|
||||
final documentId = document.id;
|
||||
final strokes = await DatabaseService.rawStrokesByPage(db, documentId);
|
||||
final bookmarks = await DatabaseService.rawBookmarks(db, documentId);
|
||||
final legacyAnnotations =
|
||||
await DatabaseService.rawLegacyAnnotations(db, documentId);
|
||||
|
||||
final links = await DatabaseService.rawScratchLinks(db, documentId);
|
||||
final scratchLinks = <SidecarScratchLink>[];
|
||||
for (final link in links) {
|
||||
// The scratchpad row is keyed by the ANCHOR id (see saveScratchpad).
|
||||
final pad = await DatabaseService.rawScratchpad(db, link.id);
|
||||
scratchLinks.add(SidecarScratchLink(
|
||||
link: link,
|
||||
scratchpad: SidecarScratchpad(strokes: pad),
|
||||
));
|
||||
}
|
||||
|
||||
return BadnoteSidecar(
|
||||
sourceFile: p.basename(vaultSourcePath),
|
||||
docType: document.docType,
|
||||
pageCount: document.pageCount,
|
||||
rotation: document.rotation,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
strokes: strokes,
|
||||
bookmarks: bookmarks,
|
||||
scratchLinks: scratchLinks,
|
||||
legacyAnnotations: legacyAnnotations,
|
||||
legacyId: documentId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateNotes(Database db, MigrationReport report) async {
|
||||
final notes = await DatabaseService.rawAllNotes(db);
|
||||
final migratedIds = await _migratedLegacyIds();
|
||||
for (final note in notes) {
|
||||
// Idempotent: recognize an already-migrated note by its legacy id stored
|
||||
// in some existing `notebook.badnote.json` (folder name may have collided
|
||||
// / been de-duped, so a path guess is unreliable — the id is canonical).
|
||||
if (migratedIds.contains(note.id)) {
|
||||
report.notesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the standalone notebook folder + initial sidecar (title), then
|
||||
// overwrite the sidecar with the migrated strokes on page 0.
|
||||
final notePath = await _vault.createEmptyNotebook(note.title);
|
||||
|
||||
// Legacy note strokes are InkStroke in ABSOLUTE px on the note's logical
|
||||
// page. Normalize them the same way the runtime note editor does on load
|
||||
// (penStrokeFromInk → EditorStroke.fromPenStroke) so the migrated note
|
||||
// renders identically.
|
||||
final editorStrokes = <EditorStroke>[
|
||||
for (final InkStroke s in note.strokes)
|
||||
if (_toEditor(s) case final EditorStroke es) es,
|
||||
];
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final sidecar = BadnoteSidecar(
|
||||
docType: 'notebook',
|
||||
title: note.title.trim().isEmpty ? null : note.title.trim(),
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt.isAfter(now) ? now : note.updatedAt,
|
||||
strokes: editorStrokes.isEmpty ? null : {0: editorStrokes},
|
||||
legacyId: note.id,
|
||||
);
|
||||
await SidecarStore.writeAtomic(
|
||||
File('$notePath$kVaultSidecarSuffix'),
|
||||
sidecar,
|
||||
);
|
||||
migratedIds.add(note.id);
|
||||
report.notesMigrated++;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a legacy note [InkStroke] (absolute px) to a normalized
|
||||
/// [EditorStroke] via the exact runtime chain. Returns null for non-freehand
|
||||
/// strokes (shapes/text), which the pen canvas cannot represent.
|
||||
EditorStroke? _toEditor(InkStroke s) {
|
||||
final pen = penStrokeFromInk(s, _noteLogicalPage);
|
||||
if (pen == null) return null;
|
||||
return EditorStroke.fromPenStroke(pen, id: s.id);
|
||||
}
|
||||
|
||||
/// Scan every notebook sidecar already in the vault and collect the `legacyId`
|
||||
/// values, so the migration can recognize already-migrated documents/notes on
|
||||
/// a re-run regardless of any folder-name de-duplication. A fresh vault yields
|
||||
/// an empty set.
|
||||
Future<Set<String>> _migratedLegacyIds() async {
|
||||
final root = _vault.vaultRoot;
|
||||
final ids = <String>{};
|
||||
if (root == null || root.isEmpty) return ids;
|
||||
final dir = Directory(root);
|
||||
if (!await dir.exists()) return ids;
|
||||
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
if (entity is! Directory) continue;
|
||||
if (p.basename(entity.path).startsWith('.')) continue;
|
||||
await for (final file in entity.list(followLinks: false)) {
|
||||
if (file is! File) continue;
|
||||
if (!file.path.endsWith(kVaultSidecarSuffix)) continue;
|
||||
// Skip .bak/.tmp variants (they don't end in the suffix anyway).
|
||||
final sidecar = await SidecarStore.read(file);
|
||||
final id = sidecar?.legacyId;
|
||||
if (id != null) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Ensure an annotations-only notebook folder exists for a legacy document
|
||||
/// whose source file is GONE. Returns the synthetic source path the sidecar
|
||||
/// keys off (`<folder>/<filename>`), so the sidecar lands at
|
||||
/// `<folder>/<filename>.badnote.json` — identical to the file-backed case but
|
||||
/// with no copied file. Idempotent on re-run.
|
||||
Future<String> _ensureNotebookForMissingSource(String filename) async {
|
||||
final root = _vault.vaultRoot!;
|
||||
final baseName = _sanitize(p.basenameWithoutExtension(filename));
|
||||
final folder = Directory(p.join(root, baseName.isEmpty ? 'Untitled' : baseName));
|
||||
final syntheticSource = p.join(folder.path, filename);
|
||||
if (await File('$syntheticSource$kVaultSidecarSuffix').exists()) {
|
||||
return syntheticSource; // already migrated
|
||||
}
|
||||
await folder.create(recursive: true);
|
||||
return syntheticSource;
|
||||
}
|
||||
|
||||
/// Mirror of VaultService._sanitizeFolderName (kept private there) so the
|
||||
/// missing-source notebook folder lands at the same name the source-backed
|
||||
/// path would have used.
|
||||
static String _sanitize(String name) => name
|
||||
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'[. ]+$'), '');
|
||||
}
|
||||
110
lib/theme/app_theme.dart
Normal file
110
lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// BadNote design tokens — ink-desk academic aesthetic.
|
||||
/// Warm paper field, graphite ink, single copper accent. No Inter / no purple.
|
||||
abstract final class AppTokens {
|
||||
static const Color paper = Color(0xFFF3EDE3);
|
||||
static const Color paperDark = Color(0xFF1C1A17);
|
||||
static const Color ink = Color(0xFF1F1B16);
|
||||
static const Color inkMuted = Color(0xFF6B6358);
|
||||
static const Color rule = Color(0xFFD9CFC0);
|
||||
static const Color copper = Color(0xFFB45A2A);
|
||||
static const Color copperSoft = Color(0xFFE8C4AE);
|
||||
static const Color sticky = Color(0xFFF6E7A5);
|
||||
|
||||
static const double radiusSm = 6;
|
||||
static const double radiusMd = 12;
|
||||
static const double chromePad = 16;
|
||||
}
|
||||
|
||||
abstract final class AppTheme {
|
||||
static ThemeData light() {
|
||||
final base = ColorScheme.fromSeed(
|
||||
seedColor: AppTokens.copper,
|
||||
brightness: Brightness.light,
|
||||
surface: AppTokens.paper,
|
||||
primary: AppTokens.copper,
|
||||
onPrimary: Colors.white,
|
||||
onSurface: AppTokens.ink,
|
||||
secondary: AppTokens.inkMuted,
|
||||
);
|
||||
return _build(base, Brightness.light);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
final base = ColorScheme.fromSeed(
|
||||
seedColor: AppTokens.copperSoft,
|
||||
brightness: Brightness.dark,
|
||||
surface: AppTokens.paperDark,
|
||||
primary: AppTokens.copperSoft,
|
||||
onSurface: AppTokens.paper,
|
||||
);
|
||||
return _build(base, Brightness.dark);
|
||||
}
|
||||
|
||||
static ThemeData fromScheme(ColorScheme scheme) =>
|
||||
_build(scheme, scheme.brightness);
|
||||
|
||||
static ThemeData _build(ColorScheme scheme, Brightness brightness) {
|
||||
final display = GoogleFonts.sourceSerif4TextTheme(
|
||||
ThemeData(brightness: brightness).textTheme,
|
||||
);
|
||||
final ui = GoogleFonts.ibmPlexSansTextTheme(
|
||||
ThemeData(brightness: brightness).textTheme,
|
||||
);
|
||||
final merged = ui.copyWith(
|
||||
displayLarge: display.displayLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
displayMedium: display.displayMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
displaySmall: display.displaySmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
headlineLarge: display.headlineLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
headlineMedium: display.headlineMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
headlineSmall: display.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
titleLarge: display.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
textTheme: merged,
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: false,
|
||||
backgroundColor: scheme.surface,
|
||||
foregroundColor: scheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0.5,
|
||||
titleTextStyle: display.titleLarge?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
indicatorColor: AppTokens.copper.withValues(alpha: 0.18),
|
||||
labelTextStyle: WidgetStatePropertyAll(
|
||||
ui.labelMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
indicatorColor: AppTokens.copper.withValues(alpha: 0.18),
|
||||
selectedIconTheme: IconThemeData(color: scheme.primary),
|
||||
unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTokens.radiusMd),
|
||||
side: BorderSide(color: AppTokens.rule.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
dividerColor: AppTokens.rule,
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
import 'color_preset_bar.dart';
|
||||
|
||||
/// Shared annotation toolbar used by note editor, PDF annotator, and PPT annotator.
|
||||
class AnnotationToolbar extends StatelessWidget {
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final bool filled;
|
||||
final PressureCurveType pressureCurveType;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool canUndo;
|
||||
final bool canRedo;
|
||||
final ValueChanged<PenTool> onToolChanged;
|
||||
final ValueChanged<Color> onColorChanged;
|
||||
final ValueChanged<double> onStrokeWidthChanged;
|
||||
final ValueChanged<bool> onFilledChanged;
|
||||
final ValueChanged<PressureCurveType> onPressureCurveChanged;
|
||||
final ValueChanged<StabilizationLevel> onStabilizationChanged;
|
||||
final VoidCallback? onUndo;
|
||||
final VoidCallback? onRedo;
|
||||
final VoidCallback? onPreviousPage;
|
||||
final VoidCallback? onNextPage;
|
||||
final String? pageInfo;
|
||||
final InteractionMode interactionMode;
|
||||
final ValueChanged<InteractionMode>? onInteractionModeChanged;
|
||||
final double? zoomLevel;
|
||||
final VoidCallback? onZoomIn;
|
||||
final VoidCallback? onZoomOut;
|
||||
final VoidCallback? onZoomFitWidth;
|
||||
final String? zoomLabel;
|
||||
|
||||
const AnnotationToolbar({
|
||||
super.key,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
this.filled = false,
|
||||
required this.pressureCurveType,
|
||||
required this.stabilizationLevel,
|
||||
required this.canUndo,
|
||||
required this.canRedo,
|
||||
required this.onToolChanged,
|
||||
required this.onColorChanged,
|
||||
required this.onStrokeWidthChanged,
|
||||
required this.onFilledChanged,
|
||||
required this.onPressureCurveChanged,
|
||||
required this.onStabilizationChanged,
|
||||
this.onUndo,
|
||||
this.onRedo,
|
||||
this.onPreviousPage,
|
||||
this.onNextPage,
|
||||
this.pageInfo,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.onInteractionModeChanged,
|
||||
this.zoomLevel,
|
||||
this.onZoomIn,
|
||||
this.onZoomOut,
|
||||
this.onZoomFitWidth,
|
||||
this.zoomLabel,
|
||||
});
|
||||
|
||||
static const _toolDefinitions = [
|
||||
_ToolDef(PenTool.pen, Icons.edit, 'Pen'),
|
||||
_ToolDef(PenTool.marker, Icons.highlight, 'Marker'),
|
||||
_ToolDef(PenTool.highlighter, Icons.border_color, 'Highlighter'),
|
||||
_ToolDef(PenTool.eraser, Icons.auto_fix_normal, 'Eraser'),
|
||||
_ToolDef(PenTool.rectangle, Icons.rectangle_outlined, 'Rectangle'),
|
||||
_ToolDef(PenTool.ellipse, Icons.circle_outlined, 'Ellipse'),
|
||||
_ToolDef(PenTool.line, Icons.horizontal_rule, 'Line'),
|
||||
_ToolDef(PenTool.arrow, Icons.arrow_right_alt, 'Arrow'),
|
||||
_ToolDef(PenTool.text, Icons.text_fields, 'Text'),
|
||||
];
|
||||
|
||||
bool get _isShapeTool {
|
||||
return currentTool == PenTool.rectangle || currentTool == PenTool.ellipse;
|
||||
}
|
||||
|
||||
void _showColorPicker(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
Color pickerColor = currentColor;
|
||||
return AlertDialog(
|
||||
title: const Text('Pick a color'),
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onColorChanged(pickerColor);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Row 1: Mode toggle + Tools + color presets + stroke width + undo/redo
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// Pen/Navigate mode toggle
|
||||
if (onInteractionModeChanged != null) ...[
|
||||
Tooltip(
|
||||
message: interactionMode == InteractionMode.draw
|
||||
? 'Drawing mode'
|
||||
: 'Navigate mode',
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final newMode = interactionMode == InteractionMode.draw
|
||||
? InteractionMode.navigate
|
||||
: InteractionMode.draw;
|
||||
onInteractionModeChanged!(newMode);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: interactionMode == InteractionMode.draw
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
interactionMode == InteractionMode.draw
|
||||
? Icons.edit
|
||||
: Icons.pan_tool,
|
||||
size: 20,
|
||||
color: interactionMode == InteractionMode.draw
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
for (final def in _toolDefinitions) ...[
|
||||
_ToolButton(
|
||||
icon: def.icon,
|
||||
label: def.label,
|
||||
isSelected: currentTool == def.tool,
|
||||
onPressed: () => onToolChanged(def.tool),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
// Filled toggle for shape tools
|
||||
if (_isShapeTool) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: filled ? 'Filled' : 'Outline',
|
||||
child: GestureDetector(
|
||||
onTap: () => onFilledChanged(!filled),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
filled ? Icons.square : Icons.square_outlined,
|
||||
size: 16,
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
ColorPresetBar(
|
||||
selectedColor: currentColor,
|
||||
onColorSelected: onColorChanged,
|
||||
onOpenFullPicker: () => _showColorPicker(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Slider(
|
||||
value: currentStrokeWidth,
|
||||
min: 1.0,
|
||||
max: 20.0,
|
||||
divisions: 19,
|
||||
label: currentStrokeWidth.toStringAsFixed(1),
|
||||
onChanged: onStrokeWidthChanged,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo),
|
||||
tooltip: 'Undo',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: canUndo ? onUndo : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo),
|
||||
tooltip: 'Redo',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: canRedo ? onRedo : null,
|
||||
),
|
||||
// Page navigation (optional, for PDF/PPT)
|
||||
if (onPreviousPage != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_before),
|
||||
tooltip: 'Previous page',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onPreviousPage,
|
||||
),
|
||||
if (pageInfo != null)
|
||||
Text(pageInfo!, style: const TextStyle(fontSize: 12)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_next),
|
||||
tooltip: 'Next page',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onNextPage,
|
||||
),
|
||||
],
|
||||
// Zoom controls (optional)
|
||||
if (onZoomIn != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.zoom_out),
|
||||
tooltip: 'Zoom out',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomOut,
|
||||
),
|
||||
if (zoomLabel != null)
|
||||
Text(zoomLabel!, style: const TextStyle(fontSize: 11)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.zoom_in),
|
||||
tooltip: 'Zoom in',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomIn,
|
||||
),
|
||||
if (onZoomFitWidth != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.fit_screen),
|
||||
tooltip: 'Fit to width',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomFitWidth,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Row 2: Pressure curve + stabilization selectors
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.touch_app, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Pressure:',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_buildSegmentedButton<PressureCurveType>(
|
||||
context: context,
|
||||
options: const {
|
||||
PressureCurveType.linear: 'Lin',
|
||||
PressureCurveType.soft: 'Soft',
|
||||
PressureCurveType.hard: 'Hard',
|
||||
},
|
||||
selected: pressureCurveType,
|
||||
onChanged: onPressureCurveChanged,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Icon(Icons.gesture, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Smooth:',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_buildSegmentedButton<StabilizationLevel>(
|
||||
context: context,
|
||||
options: const {
|
||||
StabilizationLevel.none: 'Off',
|
||||
StabilizationLevel.light: 'Low',
|
||||
StabilizationLevel.medium: 'Med',
|
||||
StabilizationLevel.heavy: 'High',
|
||||
},
|
||||
selected: stabilizationLevel,
|
||||
onChanged: onStabilizationChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedButton<T>({
|
||||
required BuildContext context,
|
||||
required Map<T, String> options,
|
||||
required T selected,
|
||||
required ValueChanged<T> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.entries.map((entry) {
|
||||
final isSelected = entry.key == selected;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(entry.key),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolDef {
|
||||
final PenTool tool;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _ToolDef(this.tool, this.icon, this.label);
|
||||
}
|
||||
|
||||
class _ToolButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _ToolButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A row of preset color circles with a palette icon to open the full picker.
|
||||
class ColorPresetBar extends StatelessWidget {
|
||||
final Color selectedColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
final VoidCallback onOpenFullPicker;
|
||||
|
||||
const ColorPresetBar({
|
||||
super.key,
|
||||
required this.selectedColor,
|
||||
required this.onColorSelected,
|
||||
required this.onOpenFullPicker,
|
||||
});
|
||||
|
||||
static const List<Color> presetColors = [
|
||||
Colors.black,
|
||||
Color(0xFFE53935), // red
|
||||
Color(0xFF1E88E5), // blue
|
||||
Color(0xFF43A047), // green
|
||||
Color(0xFFFB8C00), // orange
|
||||
Color(0xFF8E24AA), // purple
|
||||
Color(0xFF6D4C41), // brown
|
||||
Colors.white,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final color in presetColors) ...[
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: () => onColorSelected(color),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selectedColor == color
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: selectedColor == color ? 2.5 : 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: onOpenFullPicker,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade400, width: 1.5),
|
||||
),
|
||||
child: const Icon(Icons.palette, size: 14, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
/// Controls whether the canvas accepts drawing input or passes events through.
|
||||
enum InteractionMode { draw, navigate }
|
||||
|
||||
class InkCanvas extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
const InkCanvas({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.pressureCurve = PressureCurve.linear,
|
||||
this.stabilizationLevel = StabilizationLevel.none,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InkCanvas> createState() => _InkCanvasState();
|
||||
}
|
||||
|
||||
class _InkCanvasState extends State<InkCanvas> {
|
||||
final List<InkPoint> _currentPoints = [];
|
||||
bool _isDrawing = false;
|
||||
PenTool? _activeTool;
|
||||
StrokeStabilizer? _stabilizer;
|
||||
|
||||
/// Start point for shape tools.
|
||||
InkPoint? _shapeStart;
|
||||
|
||||
/// Whether the active tool is a shape tool (needs only 2 points).
|
||||
bool get _isShapeTool {
|
||||
final t = _activeTool ?? widget.tool;
|
||||
return t == PenTool.rectangle ||
|
||||
t == PenTool.ellipse ||
|
||||
t == PenTool.line ||
|
||||
t == PenTool.arrow;
|
||||
}
|
||||
|
||||
/// Whether the active tool is the text tool.
|
||||
bool get _isTextTool {
|
||||
return (_activeTool ?? widget.tool) == PenTool.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InkCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
}
|
||||
|
||||
InputDeviceKind _mapKind(PointerDeviceKind kind) {
|
||||
switch (kind) {
|
||||
case PointerDeviceKind.touch:
|
||||
return InputDeviceKind.touch;
|
||||
case PointerDeviceKind.mouse:
|
||||
return InputDeviceKind.mouse;
|
||||
case PointerDeviceKind.stylus:
|
||||
return InputDeviceKind.stylus;
|
||||
case PointerDeviceKind.invertedStylus:
|
||||
return InputDeviceKind.invertedStylus;
|
||||
case PointerDeviceKind.trackpad:
|
||||
return InputDeviceKind.trackpad;
|
||||
case PointerDeviceKind.unknown:
|
||||
return InputDeviceKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
InkPoint _makePoint(PointerEvent event) {
|
||||
return InkPoint(
|
||||
x: event.localPosition.dx,
|
||||
y: event.localPosition.dy,
|
||||
pressure: event.pressure,
|
||||
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
||||
timestamp: event.timeStamp.inMicroseconds,
|
||||
pointerDeviceKind: _mapKind(event.kind),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
|
||||
// In navigate mode, no drawing at all — pass all events through.
|
||||
if (widget.interactionMode == InteractionMode.navigate) return;
|
||||
|
||||
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
|
||||
if (event.kind == PointerDeviceKind.touch) return;
|
||||
|
||||
_isDrawing = true;
|
||||
_activeTool = widget.tool;
|
||||
|
||||
if (event.kind == PointerDeviceKind.invertedStylus) {
|
||||
_activeTool = PenTool.eraser;
|
||||
}
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// Text tool: record position, handled on pointer up
|
||||
_shapeStart = point;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape tool: record start point
|
||||
_shapeStart = point;
|
||||
_stabilizer?.reset();
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(point);
|
||||
});
|
||||
} else {
|
||||
// Freehand tools (pen, marker, highlighter)
|
||||
_stabilizer?.reset();
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// No preview for text tool
|
||||
return;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape preview: keep only start + current
|
||||
setState(() {
|
||||
if (_currentPoints.length >= 2) {
|
||||
_currentPoints[1] = point;
|
||||
} else {
|
||||
_currentPoints.add(point);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Freehand
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
_isDrawing = false;
|
||||
|
||||
final activeTool = _activeTool ?? widget.tool;
|
||||
|
||||
if (activeTool == PenTool.eraser) {
|
||||
// Nothing to finalize
|
||||
} else if (_isTextTool) {
|
||||
if (_shapeStart != null) {
|
||||
_showTextDialog(_shapeStart!);
|
||||
}
|
||||
} else if (_isShapeTool) {
|
||||
// Shape: finalize with start + end points
|
||||
if (_currentPoints.length >= 2) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
filled: widget.filled,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
} else if (_currentPoints.isNotEmpty) {
|
||||
// Freehand
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: activeTool == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_shapeStart = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showTextDialog(InkPoint position) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Text'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Enter text...'),
|
||||
maxLines: null,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: [position],
|
||||
tool: PenTool.text,
|
||||
color: widget.color.toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
textContent: text,
|
||||
fontSize: widget.strokeWidth * 7,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _eraseAt(InkPoint point) {
|
||||
final eraserRadius = widget.strokeWidth * 3;
|
||||
|
||||
// Collect all (strokeId, replacements) pairs before invoking any callback,
|
||||
// to avoid ConcurrentModificationError when the parent's onErase triggers
|
||||
// a setState that mutates widget.strokes mid-iteration.
|
||||
final toErase = <(String, List<InkStroke>)>[];
|
||||
|
||||
for (final stroke in widget.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
|
||||
final erasedIndices = <int>{};
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
final p = stroke.points[i];
|
||||
final dx = p.x - point.x;
|
||||
final dy = p.y - point.y;
|
||||
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
|
||||
erasedIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (erasedIndices.isEmpty) continue;
|
||||
|
||||
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
|
||||
}
|
||||
|
||||
for (final (strokeId, replacements) in toErase) {
|
||||
widget.onErase?.call(strokeId, replacements);
|
||||
}
|
||||
}
|
||||
|
||||
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
|
||||
final segments = <List<InkPoint>>[];
|
||||
List<InkPoint> currentSegment = [];
|
||||
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
if (erasedIndices.contains(i)) {
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
} else {
|
||||
currentSegment.add(stroke.points[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
}
|
||||
|
||||
final replacements = <InkStroke>[];
|
||||
for (final segment in segments) {
|
||||
if (segment.length >= 2) {
|
||||
replacements.add(
|
||||
InkStroke(
|
||||
id: _generateId(),
|
||||
points: segment,
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
Color _getColorForTool(PenTool tool) {
|
||||
switch (tool) {
|
||||
case PenTool.marker:
|
||||
return widget.color.withAlpha(77);
|
||||
case PenTool.highlighter:
|
||||
return const Color(0x80FFFF00);
|
||||
case PenTool.pen:
|
||||
case PenTool.eraser:
|
||||
case PenTool.rectangle:
|
||||
case PenTool.ellipse:
|
||||
case PenTool.line:
|
||||
case PenTool.arrow:
|
||||
case PenTool.text:
|
||||
return widget.color;
|
||||
}
|
||||
}
|
||||
|
||||
String _generateId() {
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
child: CustomPaint(
|
||||
painter: _InkPainter(
|
||||
strokes: widget.strokes,
|
||||
currentPoints: _currentPoints,
|
||||
currentTool: _activeTool ?? widget.tool,
|
||||
currentColor: _getColorForTool(_activeTool ?? widget.tool),
|
||||
currentStrokeWidth:
|
||||
(_activeTool ?? widget.tool) == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
pressureCurve: widget.pressureCurve,
|
||||
filled: widget.filled,
|
||||
viewportBounds: widget.viewportBounds,
|
||||
),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InkPainter extends CustomPainter {
|
||||
final List<InkStroke> strokes;
|
||||
final List<InkPoint> currentPoints;
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final bool filled;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
_InkPainter({
|
||||
required this.strokes,
|
||||
required this.currentPoints,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
required this.pressureCurve,
|
||||
required this.filled,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
|
||||
if (stroke.points.isEmpty) return false;
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (viewportBounds != null &&
|
||||
!_strokeInViewport(stroke, viewportBounds!)) {
|
||||
continue;
|
||||
}
|
||||
_drawStroke(
|
||||
canvas,
|
||||
stroke.points,
|
||||
stroke.tool,
|
||||
Color(stroke.color),
|
||||
stroke.strokeWidth,
|
||||
true,
|
||||
stroke.filled,
|
||||
stroke.textContent,
|
||||
stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
|
||||
_drawStroke(
|
||||
canvas,
|
||||
currentPoints,
|
||||
currentTool,
|
||||
currentColor,
|
||||
currentStrokeWidth,
|
||||
false,
|
||||
filled,
|
||||
null,
|
||||
14.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawStroke(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
bool strokeFilled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
) {
|
||||
if (points.isEmpty) return;
|
||||
|
||||
switch (tool) {
|
||||
case PenTool.pen:
|
||||
case PenTool.marker:
|
||||
case PenTool.highlighter:
|
||||
case PenTool.eraser:
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
break;
|
||||
case PenTool.rectangle:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.ellipse:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.line:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawLine(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.arrow:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawArrow(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.text:
|
||||
if (textContent != null && textContent.isNotEmpty) {
|
||||
_drawText(canvas, points, textContent, fontSize, color);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _drawFreehand(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
) {
|
||||
final pfPoints = points
|
||||
.map(
|
||||
(p) => pf.PointVector(
|
||||
p.x,
|
||||
p.y,
|
||||
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
||||
? 0.0
|
||||
: 0.7;
|
||||
|
||||
final outlinePoints = pf.getStroke(
|
||||
pfPoints,
|
||||
options: pf.StrokeOptions(
|
||||
size: strokeWidth,
|
||||
thinning: thinning,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
// 2.x defaults: no taper + capped ends (was taperStart/End:0 +
|
||||
// capStart/End:true in 1.0.4).
|
||||
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
||||
isComplete: isComplete,
|
||||
),
|
||||
);
|
||||
|
||||
if (outlinePoints.isEmpty) return;
|
||||
|
||||
final path = Path();
|
||||
path.moveTo(outlinePoints[0].dx, outlinePoints[0].dy);
|
||||
|
||||
for (int i = 1; i < outlinePoints.length; i++) {
|
||||
path.lineTo(outlinePoints[i].dx, outlinePoints[i].dy);
|
||||
}
|
||||
path.close();
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void _drawRect(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
void _drawOval(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
}
|
||||
|
||||
void _drawLine(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawArrow(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final p1 = Offset(points[0].x, points[0].y);
|
||||
final p2 = Offset(points[1].x, points[1].y);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Main line
|
||||
canvas.drawLine(p1, p2, paint);
|
||||
|
||||
// Arrowhead
|
||||
final dx = p2.dx - p1.dx;
|
||||
final dy = p2.dy - p1.dy;
|
||||
final angle = atan2(dy, dx);
|
||||
final arrowLength = strokeWidth * 5;
|
||||
const arrowAngle = pi / 6; // 30 degrees
|
||||
|
||||
final arrowP1 = Offset(
|
||||
p2.dx - arrowLength * cos(angle - arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle - arrowAngle),
|
||||
);
|
||||
final arrowP2 = Offset(
|
||||
p2.dx - arrowLength * cos(angle + arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle + arrowAngle),
|
||||
);
|
||||
|
||||
canvas.drawLine(p2, arrowP1, paint);
|
||||
canvas.drawLine(p2, arrowP2, paint);
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
String text,
|
||||
double fontSize,
|
||||
Color color,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _InkPainter oldDelegate) {
|
||||
if (strokes.length != oldDelegate.strokes.length) return true;
|
||||
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
|
||||
for (int i = 0; i < strokes.length; i++) {
|
||||
final a = strokes[i], b = oldDelegate.strokes[i];
|
||||
if (a.id != b.id ||
|
||||
a.color != b.color ||
|
||||
a.strokeWidth != b.strokeWidth ||
|
||||
a.tool != b.tool) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return currentTool != oldDelegate.currentTool;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
@@ -448,7 +448,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
@@ -1370,7 +1370,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
|
||||
@@ -49,6 +49,14 @@ dependencies:
|
||||
# Settings persistence
|
||||
shared_preferences: ^2.3.0
|
||||
|
||||
# WebDAV sync: a minimal client (PROPFIND/GET/PUT/MKCOL) is hand-rolled over
|
||||
# `package:http` (HttpWebDavClient) — both were already transitive deps. We
|
||||
# avoid the `webdav_client` package because it drags in `dio`; `http` works on
|
||||
# Windows and keeps the real adapter thin so the sync algorithm stays testable
|
||||
# against a fake client.
|
||||
http: ^1.2.0
|
||||
xml: ^6.5.0
|
||||
|
||||
# Cross-platform file picker
|
||||
file_picker: ^8.0.0
|
||||
|
||||
@@ -59,6 +67,7 @@ dependencies:
|
||||
flutter_onnxruntime: ^1.8.0
|
||||
pdfrx: ^2.4.4
|
||||
dynamic_color: ^1.8.1
|
||||
archive: ^4.0.9
|
||||
|
||||
# Pin sqlite3 to the exact version whose native binaries are vendored under
|
||||
# vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
# BadNote Server (Optional)
|
||||
# BadNote Server (Self-hosted companion)
|
||||
|
||||
This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it.
|
||||
Optional FastAPI backend for multi-device vault assist and deferred OCR.
|
||||
The Flutter app stays local-first: notes work fully offline. This server is
|
||||
for **your NAS / VPS**, not a hosted cloud product.
|
||||
|
||||
The Flutter client is local-first:
|
||||
## Architecture (v2 / API v1)
|
||||
|
||||
- Notes and documents are stored in SQLite on device
|
||||
- OCR runs locally via Windows built-in OCR
|
||||
- Full-text search uses on-device FTS5
|
||||
```
|
||||
Client vault (files + *.badnote.json)
|
||||
│
|
||||
├─ WebDAV (NAS) ───────────── file sync (existing)
|
||||
│
|
||||
└─ BadNote Server /api/v1 ─── assist layer
|
||||
├─ /auth JWT register/login
|
||||
├─ /vault manifest + PUT/GET/DELETE (tombstones)
|
||||
└─ /ocr upload ink PNG → job queue → EasyOCR worker
|
||||
```
|
||||
|
||||
## Why this exists
|
||||
**Source of truth = vault files**, not the legacy `notes.strokes_json` tables.
|
||||
Legacy routers remain under `/api/legacy/*` (and old `/api/notes` paths) for
|
||||
experiments only — new clients must use `/api/v1`.
|
||||
|
||||
This server was an early experiment for:
|
||||
### Storage layout
|
||||
|
||||
- Multi-device note sync (push/pull)
|
||||
- Server-side OCR with EasyOCR
|
||||
- JWT authentication
|
||||
```
|
||||
data/
|
||||
badnote_server.db # users
|
||||
.jwt_secret # if BADNOTE_JWT_SECRET unset
|
||||
vaults/<user_id>/files/ # mirrors client vault
|
||||
storage/ocr_blobs/… # uploaded ink rasters
|
||||
queue/{pending,processing,done,failed}/
|
||||
```
|
||||
|
||||
These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing.
|
||||
|
||||
## Running (if you want to experiment)
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd server
|
||||
@@ -28,25 +42,43 @@ pip install -r requirements.txt
|
||||
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
API docs: http://localhost:8080/docs
|
||||
- Health: `GET /api/v1/health`
|
||||
- OpenAPI: http://localhost:8080/docs
|
||||
|
||||
The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only
|
||||
if you want to run it:
|
||||
### OCR worker (optional, heavy)
|
||||
|
||||
```bash
|
||||
pip install -r requirements-ocr.txt
|
||||
python -m badnote_server.ocr.worker
|
||||
```
|
||||
|
||||
### Security notes
|
||||
### Security
|
||||
|
||||
- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once
|
||||
and persisted to `<data>/.jwt_secret` so tokens survive restarts.
|
||||
- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is
|
||||
permissive (`*`, without credentials) for local development.
|
||||
- Set `BADNOTE_JWT_SECRET` in production.
|
||||
- Restrict CORS with `BADNOTE_CORS_ORIGINS`.
|
||||
- Prefer HTTPS reverse proxy (Caddy/Nginx) in front of uvicorn.
|
||||
|
||||
### Env
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|----------|---------|---------|
|
||||
| `BADNOTE_HOST` / `PORT` | `0.0.0.0` / `8080` | Bind |
|
||||
| `BADNOTE_DB_PATH` | `./data/badnote_server.db` | Users DB |
|
||||
| `BADNOTE_VAULT_PATH` | `./data/vaults` | Per-user vault trees |
|
||||
| `BADNOTE_STORAGE_PATH` | `./data/storage` | Blobs |
|
||||
| `BADNOTE_QUEUE_PATH` | `./data/queue` | OCR jobs |
|
||||
| `BADNOTE_JWT_SECRET` | persisted file | Signing key |
|
||||
| `BADNOTE_CORS_ORIGINS` | `*` | Allowed origins |
|
||||
|
||||
## Client
|
||||
|
||||
In BadNote → Settings → **BadNote Server**, set base URL (e.g.
|
||||
`http://192.168.1.10:8080`), register/login, then **Test connection**.
|
||||
Vault file sync via the API is additive to WebDAV; OCR upload is opt-in when
|
||||
online/charging (future client job).
|
||||
|
||||
## Status
|
||||
|
||||
- Kept for reference and future optional sync work
|
||||
- Not part of the primary development path
|
||||
- No guarantee of API compatibility with future client versions
|
||||
- **v1 vault + health + OCR enqueue**: implemented
|
||||
- **Wiki / semantic search**: stubbed for later (`501` reserved)
|
||||
- Legacy notes push/pull: deprecated, not used by current Flutter app
|
||||
|
||||
@@ -68,6 +68,9 @@ class Settings:
|
||||
jwt_secret: str = _resolve_jwt_secret()
|
||||
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
|
||||
cors_origins: list[str] = _resolve_cors_origins()
|
||||
# Per-user vault trees (notebook folders + sidecars), independent of legacy
|
||||
# notes.strokes_json storage.
|
||||
vault_path: str = os.environ.get("BADNOTE_VAULT_PATH", "./data/vaults")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user