feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled

Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.

- Multiple brushes, each remembering its OWN color (rnote-style):
  selecting a brush restores its color, changing color updates only
  that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
  delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
  preview and committed as generated PenStrokes (shape_geometry.dart)
  so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.

Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
This commit is contained in:
2026-06-24 20:38:18 +08:00
parent fd102b5703
commit 875dabcd89
18 changed files with 2104 additions and 62 deletions

View 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 01 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 24, 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).

View File

@@ -0,0 +1,53 @@
// 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): text/typing tool, bookmark-to-paragraph, search+OCR,
// templates, Windows Ink — later batches add kinds here.
/// 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,
}
/// 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,
}

View File

@@ -199,6 +199,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 {

View File

@@ -30,15 +30,31 @@ import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../engine/shape_geometry.dart';
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,
};
class PenCanvas extends StatefulWidget {
const PenCanvas({
@@ -49,10 +65,14 @@ class PenCanvas extends StatefulWidget {
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,
@@ -93,6 +113,10 @@ class PenCanvas extends StatefulWidget {
/// 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).
@@ -107,6 +131,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;
@@ -169,6 +209,17 @@ class _PenCanvasState extends State<PenCanvas> {
/// 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;
/// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
@@ -211,6 +262,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
@@ -371,9 +432,11 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
if (p != null) _livePoints.add(p);
if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
@@ -384,6 +447,24 @@ 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);
_updateLiveStroke();
}
@@ -397,14 +478,56 @@ 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;
}
_livePoints.add(p);
_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: _currentBrush,
));
}
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
@@ -416,11 +539,47 @@ class _PenCanvasState extends State<PenCanvas> {
);
}
_drawPointer = null;
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_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: _currentBrush,
);
});
}
/// Discard the in-progress stroke without committing (palm/2nd-finger).
void _cancelStroke() {
_drawPointer = null;
@@ -634,7 +793,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(
@@ -646,6 +806,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,
),
),
),
),
],
),
),

View File

@@ -32,6 +32,7 @@ import '../../models/scratch_link.dart';
import '../../screens/split_view_screen.dart';
import '../../services/database_service.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -47,9 +48,9 @@ import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'editor_tool.dart';
import 'ink_painters.dart' show buildStrokePath;
import 'input_diagnostics.dart';
import 'pen_canvas.dart' show CanvasTool;
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
import 'pinch_scale_solver.dart';
@@ -198,14 +199,48 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
bool _showPenDebug = false;
double _peakNorm = 0;
// Tool state.
CanvasTool _tool = CanvasTool.pen;
// Tool state. The single shared active-tool enum; the page-anchored
// select-text / place-link tools (below) are PDF-only and ride a different
// path (they disable pen capture), so they stay as their own booleans.
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
/// this increment (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
/// null. Selection is per-page (each page has its own stroke list).
({int page, int index})? _selected;
/// SHAPE tool: normalized start point + its page, while a shape drag is live.
({int page, Offset start})? _shapeDrag;
/// SELECT tool: last normalized drag position + page, to compute the
/// incremental translation; and whether the drag's undo snapshot was taken.
Offset? _selectLast;
bool _selectDragging = false;
/// rnote-style per-brush color memory: each brush (and the highlighter)
/// remembers its own color. Selecting a brush restores its color; picking a
/// color updates ONLY the active brush's entry. In-memory only for this
/// increment (TODO(brush-color-persist)).
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
/// else the selected pen brush).
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// When true the "select text" tool is active: pen capture is disabled so the
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
@@ -221,7 +256,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
static const _uuid = Uuid();
Color _color = Colors.black;
/// The active drawing color = the active brush's remembered color.
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
/// Whether the viewer currently has a non-empty text selection (drives the
@@ -240,13 +276,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Colors.orange,
];
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
/// False in select-text mode (pen reaches pdfrx text selection) and in
/// place-link mode (a tap drops an anchor via the page overlay).
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
/// True when the SELECT tool is active (and not in a page-anchored mode).
bool get _isSelect =>
_tool == EditorToolKind.select && _penCaptureEnabled;
/// True when the SHAPE tool is active.
bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;
@override
void initState() {
@@ -500,6 +543,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_eraseAt(hit.page, hit.normalized);
return;
}
if (_isSelect) {
_liveStrokePage = hit.page;
_selectLast = hit.normalized;
_selectDragging = false;
_selectAt(hit.page, hit.normalized);
return;
}
if (_isShape) {
_liveStrokePage = hit.page;
_shapeDrag = (page: hit.page, start: hit.normalized);
return;
}
_liveStrokePage = hit.page;
_livePoints
..clear()
@@ -516,6 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
return;
}
if (_isSelect) {
if (hit == null || hit.page != page) return;
_dragSelected(page, hit.normalized);
return;
}
if (_isShape) {
if (hit == null || hit.page != page) return;
_updateShapePreview(page, hit.normalized);
return;
}
// A stroke belongs to ONE page: ignore samples on a different page.
if (hit == null || hit.page != page) return;
_livePoints.add(PenPoint(
@@ -526,6 +591,100 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
}
/// SELECT down: hit-test the page's committed strokes (topmost first) and set
/// the selection (or clear it on empty space).
void _selectAt(int page, Offset normalized) {
final strokes = _strokesByPage[page];
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
final aspect = _pageAspect(page);
int? hitIndex;
if (strokes != null) {
for (var i = strokes.length - 1; i >= 0; i--) {
if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
aspect: aspect)) {
hitIndex = i;
break;
}
}
}
setState(() {
_selected = hitIndex == null ? null : (page: page, index: hitIndex);
});
_bumpOverlay();
}
/// SELECT drag: translate the selected stroke by the incremental delta,
/// recording ONE undo snapshot on the first delta of the drag.
void _dragSelected(int page, Offset normalized) {
final sel = _selected;
final last = _selectLast;
if (sel == null || last == null || sel.page != page) {
_selectLast = normalized;
return;
}
final dx = normalized.dx - last.dx;
final dy = normalized.dy - last.dy;
_selectLast = normalized;
if (dx == 0 && dy == 0) return;
_moveSelected(dx, dy, isDragStart: !_selectDragging);
_selectDragging = true;
}
/// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
/// records one undo snapshot via the existing per-page undo stack.
void _moveSelected(double dx, double dy, {required bool isDragStart}) {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list);
next[sel.index] = translateStroke(next[sel.index], dx, dy);
_strokesByPage[sel.page] = next;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// Delete the selected stroke (button or long-press) as one undo step.
void _deleteSelected() {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
_undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list)..removeAt(sel.index);
_strokesByPage[sel.page] = next;
_selected = null;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// SHAPE preview: rebuild the generated shape stroke from start→current and
/// publish it as the live stroke (drawn by the page overlay painter).
void _updateShapePreview(int page, Offset current) {
final drag = _shapeDrag;
if (drag == null || drag.page != page) return;
final pts = generateShapePoints(
_shapeKind,
PenPoint(drag.start.dx, drag.start.dy, 1.0),
PenPoint(current.dx, current.dy, 1.0),
);
_liveStrokeVN.value = _LiveStrokeData(
page,
PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: PenStrokeKind.pen,
brush: _currentBrush(),
),
);
}
void _updateLiveStroke() {
final page = _liveStrokePage;
if (page == null || _livePoints.isEmpty) return;
@@ -543,7 +702,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _endStroke({required bool commit}) {
final page = _liveStrokePage;
if (page != null && commit && !_isEraser && _livePoints.isNotEmpty) {
// SHAPE: on release, commit the generated shape stroke.
final drag = _shapeDrag;
if (_isShape && drag != null && commit) {
final end = _liveStrokeVN.value;
if (end != null && end.page == drag.page) {
_commitStroke(drag.page, end.stroke);
}
} else if (page != null &&
commit &&
!_isEraser &&
!_isSelect &&
!_isShape &&
_livePoints.isNotEmpty) {
// A single tap → tiny dot is allowed (perfect_freehand renders a dot for
// a 1-point stroke).
_commitStroke(
@@ -558,6 +729,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
);
}
_liveStrokePage = null;
_shapeDrag = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_liveStrokeVN.value = null;
_bumpOverlay();
@@ -600,21 +774,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
double _currentStrokeWidth() => _tool == CanvasTool.highlighter
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
PenStrokeKind _currentKind() => _tool == CanvasTool.highlighter
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen;
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
/// pen brush. Drives both the capture-time pressure warp and render geometry.
BrushKind _currentBrush() => _tool == CanvasTool.highlighter
BrushKind _currentBrush() => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
Color _currentColor() => _tool == CanvasTool.highlighter
Color _currentColor() => _tool == EditorToolKind.highlighter
? _color.withAlpha(0x80)
: _color;
@@ -757,11 +931,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_controller.goToPage(pageNumber: clamped + 1);
}
void _setTool(CanvasTool tool) {
void _setTool(EditorToolKind tool) {
setState(() {
_tool = tool;
_selectTextMode = false;
_placeLinkMode = false;
if (tool != EditorToolKind.select) _selected = null;
});
}
@@ -769,6 +944,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() {
_selectTextMode = true;
_placeLinkMode = false;
_selected = null;
});
}
@@ -966,6 +1142,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
pageIndex: pageIndex,
strokes: _strokesByPage[pageIndex] ?? const [],
highlights: _highlightsByPage[pageIndex] ?? const [],
selectedIndex:
(_selected != null && _selected!.page == pageIndex)
? _selected!.index
: null,
pageSize: pageRectInViewer.size,
thinning: _penConfig?.value.pressureSensitivity ??
kDefaultPenThinning,
@@ -1058,25 +1238,50 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen && !_selectTextMode,
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(CanvasTool.pen);
_setTool(EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter && !_selectTextMode,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
tooltip: l.toolHighlighter,
onPressed: () => _setTool(CanvasTool.highlighter),
onPressed: () => _setTool(EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _isEraser,
tooltip: l.toolEraser,
onPressed: () => _setTool(CanvasTool.eraser),
onPressed: () => _setTool(EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _isSelect,
tooltip: l.toolSelect,
onPressed: () => _setTool(EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _isShape,
tooltip: l.shapePicker,
labelFor: (s) => shapeLabel(s, l),
onActivate: () => _setTool(EditorToolKind.shape),
onSelected: (s) {
setState(() => _shapeKind = s);
_setTool(EditorToolKind.shape);
},
),
if (_isSelect && _selected != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: l.actionDeleteSelection,
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
// Text selection + highlight (real vector text).
@@ -1158,9 +1363,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color == c;
// Selected against the ACTIVE brush's remembered color; a tap updates only
// that brush's entry (rnote per-brush color memory). Inert in select mode.
final selected = _color == c && !_isSelect;
return GestureDetector(
onTap: () => setState(() => _color = c),
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 28,
@@ -1367,6 +1574,7 @@ class _PageOverlayPainter extends CustomPainter {
required this.highlights,
required this.pageSize,
required this.thinning,
this.selectedIndex,
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
/// Live stroke source, read at paint time. Only painted when its page matches
@@ -1378,6 +1586,10 @@ class _PageOverlayPainter extends CustomPainter {
final Size pageSize;
final double thinning;
/// SELECT tool: index into [strokes] of the selected stroke on this page, or
/// null. Drives the selection bounding-box overlay.
final int? selectedIndex;
@override
void paint(Canvas canvas, Size size) {
// 1. Text highlights (semi-transparent yellow), normalized → pixels.
@@ -1427,6 +1639,36 @@ class _PageOverlayPainter extends CustomPainter {
);
}
}
// 4. SELECT bounding box around the selected stroke (over everything).
final si = selectedIndex;
if (si != null && si >= 0 && si < strokes.length) {
final b = penStrokeBounds(strokes[si]);
if (b != null) {
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * size.width - padPx,
b.top * size.height - padPx,
b.right * size.width + padPx,
b.bottom * size.height + padPx,
);
final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true,
);
}
}
}
@override
@@ -1437,6 +1679,7 @@ class _PageOverlayPainter extends CustomPainter {
old.strokes.length != strokes.length ||
!identical(old.highlights, highlights) ||
old.highlights.length != highlights.length ||
old.selectedIndex != selectedIndex ||
old.pageSize != pageSize ||
old.thinning != thinning;
}

View File

@@ -14,12 +14,14 @@ import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.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 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -45,14 +47,40 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
CanvasTool _tool = CanvasTool.pen;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]; local state only for
/// this increment (not persisted — see TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// rnote-style per-brush color memory: each brush (and the highlighter)
/// remembers its own color. Selecting a brush restores its color; picking a
/// color updates ONLY the active brush's entry. In-memory only for this
/// increment (TODO(brush-color-persist)).
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
/// The brush whose color the color-dots edit: the highlighter when the
/// highlighter tool is active, otherwise the selected pen brush.
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// The active drawing color (the active brush's remembered color).
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
@@ -242,10 +270,46 @@ 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);
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
/// Set (or clear) the selected stroke from a SELECT-tool tap.
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
/// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta
/// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a
/// single undo step.
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(_strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokes = next;
_dirty = true;
});
}
/// Delete the selected stroke (button or long-press), as one undo step.
void _deleteSelected() {
final idx = _selectedStroke;
if (idx == null || idx < 0 || idx >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, idx),
..._strokes.sublist(idx + 1),
];
_selectedStroke = null;
_dirty = true;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -324,10 +388,14 @@ 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,
@@ -367,29 +435,56 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pen tool with brush picker (fountain / ballpoint / pencil).
// Pen tool with brush picker (fountain / ballpoint / pencil), each
// brush showing its own remembered color.
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
active: _tool == EditorToolKind.brush,
tooltip: 'Brush',
labelFor: brushLabelEn,
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
_tool = EditorToolKind.brush;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = CanvasTool.highlighter),
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),
),
// 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(
@@ -430,12 +525,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE brush's remembered color. A color tap updates
// only that brush's entry (rnote per-brush color memory).
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;
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
_tool = EditorToolKind.brush;
}
_brushColors[_activeColorBrush] = c;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import 'editor_tool.dart';
/// Localized display name for a brush (single source so all three editors agree).
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
@@ -28,6 +29,24 @@ String brushLabelEn(BrushKind kind) => switch (kind) {
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 = [
@@ -44,11 +63,23 @@ IconData brushIcon(BrushKind kind) => switch (kind) {
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,
};
/// 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,
@@ -56,6 +87,7 @@ class BrushPickerButton extends StatelessWidget {
required this.active,
required this.onSelected,
required this.labelFor,
required this.colorFor,
required this.tooltip,
});
@@ -70,6 +102,9 @@ class BrushPickerButton extends StatelessWidget {
/// 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
@@ -91,6 +126,17 @@ class BrushPickerButton extends StatelessWidget {
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),
@@ -107,13 +153,109 @@ class BrushPickerButton extends StatelessWidget {
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
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),
],
),
),
),
);
}

View File

@@ -16,12 +16,14 @@ 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/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../pdf/slide_export.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -52,13 +54,33 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
/// keeps the slide's aspect (no distortion). Null until loaded.
Map<int, Size>? _slideSizes;
CanvasTool _tool = CanvasTool.pen;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the PEN tool. Highlighter tool uses the highlighter
/// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter
/// brush; local state only (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// rnote-style per-brush color memory (see PenNoteScreen). In-memory only.
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
bool _needsCenter = true;
bool _showSlider = false;
@@ -189,6 +211,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
setState(() {
_slideIndex = clamped;
_needsCenter = true;
_selectedStroke = null; // selection is per-slide
});
}
@@ -280,10 +303,41 @@ 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);
// ── 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;
@@ -368,10 +422,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,
@@ -405,25 +463,50 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
active: _tool == EditorToolKind.brush,
tooltip: 'Brush',
labelFor: brushLabelEn,
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
_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(
@@ -466,12 +549,16 @@ 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;
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
_tool = EditorToolKind.brush;
}
_brushColors[_activeColorBrush] = c;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),

View File

@@ -0,0 +1,138 @@
// 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';
/// 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;
/// 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);
}

View File

@@ -51,6 +51,14 @@
"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",

View File

@@ -350,6 +350,54 @@ abstract class AppLocalizations {
/// **'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:

View File

@@ -140,6 +140,30 @@ class AppLocalizationsEn extends AppLocalizations {
@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';

View File

@@ -140,6 +140,30 @@ class AppLocalizationsZh extends AppLocalizations {
@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 => '撤销';

View File

@@ -42,6 +42,14 @@
"brushBallpoint": "圆珠笔",
"brushPencil": "铅笔",
"brushHighlighter": "荧光笔",
"toolSelect": "选择",
"toolShape": "形状",
"shapePicker": "形状",
"shapeLine": "直线",
"shapeRectangle": "矩形",
"shapeEllipse": "椭圆",
"shapeArrow": "箭头",
"actionDeleteSelection": "删除所选",
"actionUndo": "撤销",
"actionRedo": "重做",
"fingerDrawingOn": "手指书写:开",

View File

@@ -383,6 +383,9 @@ class _SplitViewState extends State<SplitViewScreen> {
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;

View File

@@ -0,0 +1,80 @@
// test/pen_brush_color_memory_test.dart
//
// Pins rnote-style per-brush color memory in the note editor: each brush
// remembers its OWN color, selecting a brush restores that brush's color (the
// PenCanvas receives it), and picking a color updates ONLY the active brush's
// entry — switching back to a different brush restores the other color.
//
// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots)
// and reads PenCanvas.color to assert the active drawing color.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/canvas/pen_canvas.dart';
import 'package:badnote/editor/canvas/pen_note_screen.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Color canvasColor(WidgetTester tester) =>
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
/// Pick the brush named [label] from the BrushPickerButton popup menu.
Future<void> selectBrush(WidgetTester tester, String label) async {
// The brush picker carries the 'Brush' tooltip.
await tester.tap(find.byTooltip('Brush'));
await tester.pumpAndSettle();
await tester.tap(find.text(label).last);
await tester.pumpAndSettle();
}
/// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an
/// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture
/// detector ancestor so the onTap fires.
Future<void> tapColorDot(WidgetTester tester, Color c) async {
final swatch = find.byWidgetPredicate((w) =>
w is AnimatedContainer &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).color == c &&
(w.decoration as BoxDecoration).shape == BoxShape.circle);
final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector));
await tester.tap(gd.first);
await tester.pump();
}
testWidgets('each brush remembers its own color; switching restores it',
(tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const ProviderScope(
child: MaterialApp(home: PenNoteScreen()),
));
await tester.pump(); // let PenConfig load
// Defaults from _brushColors: fountain pen = black, ballpoint = blue.
expect(canvasColor(tester), Colors.black,
reason: 'fountain pen starts black');
// Switch to the ballpoint brush → its remembered color (blue) becomes active.
await selectBrush(tester, 'Ballpoint');
expect(canvasColor(tester), Colors.blue,
reason: 'selecting ballpoint restores ITS remembered color');
// Change the ACTIVE (ballpoint) brush's color to red via a color dot.
await tapColorDot(tester, Colors.red);
expect(canvasColor(tester), Colors.red,
reason: 'color change applies to the active brush');
// Switch back to the fountain pen → its color is still black (unchanged).
await selectBrush(tester, 'Fountain pen');
expect(canvasColor(tester), Colors.black,
reason: 'fountain pen color was NOT affected by changing ballpoint');
// Back to ballpoint → it remembers the red we set.
await selectBrush(tester, 'Ballpoint');
expect(canvasColor(tester), Colors.red,
reason: 'ballpoint remembers its own updated color');
});
}

View File

@@ -0,0 +1,131 @@
// test/pen_select_move_test.dart
//
// Pins the SELECT tool's select + move + delete on the PenCanvas editors (note):
// * tapping a committed stroke selects it (onSelectStroke fires with its index);
// * dragging the selection translates the stroke's normalized points (the
// committed stroke list is replaced with shifted points);
// * the move is one undoable step (undo restores the original points).
//
// Drives the real PenNoteScreen with a pre-loaded stroke and a stylus gesture.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/canvas/pen_canvas.dart';
import 'package:badnote/editor/canvas/pen_note_screen.dart';
import 'package:badnote/editor/canvas/pen_stroke.dart';
import 'package:badnote/editor/engine/shape_geometry.dart';
import 'package:badnote/models/ink_point.dart';
import 'package:badnote/models/ink_stroke.dart';
import 'package:badnote/models/note.dart';
import 'package:badnote/models/pen_tool.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// A note with a single stroke crossing the page center so a center tap hits it.
Note noteWithStroke() => Note(
id: 'n1',
title: 'Test',
strokes: [
InkStroke(
id: 's1',
// Through the page center (kNoteLogicalPage = 1000 x 1414) so a
// center tap on the canvas hits the stroke.
points: const [
InkPoint(x: 300, y: 707, timestamp: 0),
InkPoint(x: 500, y: 707, timestamp: 0),
InkPoint(x: 700, y: 707, timestamp: 0),
],
tool: PenTool.pen,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
),
],
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
PenCanvas canvas(WidgetTester tester) =>
tester.widget<PenCanvas>(find.byType(PenCanvas));
List<PenStroke> strokes(WidgetTester tester) => canvas(tester).strokes;
/// Activate the SELECT tool via its toolbar button (the "Select" tooltip).
Future<void> activateSelect(WidgetTester tester) async {
await tester.tap(find.byTooltip('Select'));
await tester.pumpAndSettle();
}
testWidgets('tapping a stroke selects it, dragging moves it, undo restores',
(tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(ProviderScope(
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
));
await tester.pump(); // PenConfig load
expect(strokes(tester), hasLength(1));
final before = strokes(tester).single;
final beforeBounds = penStrokeBounds(before)!;
await activateSelect(tester);
expect(canvas(tester).tool, CanvasTool.select);
// Tap the stroke (page center) to select, then drag it down-right.
final center = tester.getCenter(find.byType(PenCanvas));
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
await tester.pump();
// selection should now be set.
expect(canvas(tester).selectedStrokeIndex, 0);
await g.moveBy(const Offset(40, 30));
await g.moveBy(const Offset(20, 10));
await g.up();
await tester.pump();
final after = strokes(tester).single;
final afterBounds = penStrokeBounds(after)!;
// The stroke translated: its bounds shifted right and down.
expect(afterBounds.left, greaterThan(beforeBounds.left),
reason: 'stroke moved right');
expect(afterBounds.top, greaterThan(beforeBounds.top),
reason: 'stroke moved down');
// Same number of points (a translate, not a redraw).
expect(after.points.length, before.points.length);
// Undo restores the original position (one undoable step).
await tester.tap(find.byTooltip('Undo'));
await tester.pump();
final undoneBounds = penStrokeBounds(strokes(tester).single)!;
expect(undoneBounds.left, closeTo(beforeBounds.left, 1e-6));
expect(undoneBounds.top, closeTo(beforeBounds.top, 1e-6));
});
testWidgets('delete-selection removes the selected stroke (undoable)',
(tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(ProviderScope(
child: MaterialApp(home: PenNoteScreen(note: noteWithStroke())),
));
await tester.pump();
await activateSelect(tester);
final center = tester.getCenter(find.byType(PenCanvas));
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
await g.up();
await tester.pump();
expect(canvas(tester).selectedStrokeIndex, 0);
// The delete button appears only with a live selection.
await tester.tap(find.byTooltip('Delete selection'));
await tester.pump();
expect(strokes(tester), isEmpty);
await tester.tap(find.byTooltip('Undo'));
await tester.pump();
expect(strokes(tester), hasLength(1));
});
}

View File

@@ -0,0 +1,132 @@
// test/shape_geometry_test.dart
//
// Pins the SHAPE-tool geometry (lib/editor/engine/shape_geometry.dart): each
// shape is generated as a normalized PenPoint polyline with the documented point
// counts (line→2, rect→5 closed, ellipse→kEllipseSamples+1), the shapes span the
// requested start→end box, and translateStroke/translatePoints shift every point
// without disturbing color/width/kind/brush (the SELECT-tool move primitive).
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/canvas/editor_tool.dart';
import 'package:badnote/editor/canvas/pen_stroke.dart';
import 'package:badnote/editor/engine/brush.dart';
import 'package:badnote/editor/engine/shape_geometry.dart';
void main() {
const a = PenPoint(0.2, 0.3, 1.0);
const b = PenPoint(0.8, 0.7, 1.0);
group('point counts', () {
test('line → exactly 2 points (start, end)', () {
final pts = generateShapePoints(ShapeKind.line, a, b);
expect(pts, hasLength(2));
expect(pts.first.x, closeTo(0.2, 1e-9));
expect(pts.first.y, closeTo(0.3, 1e-9));
expect(pts.last.x, closeTo(0.8, 1e-9));
expect(pts.last.y, closeTo(0.7, 1e-9));
});
test('rectangle → 5 points, closed (last == first)', () {
final pts = generateShapePoints(ShapeKind.rectangle, a, b);
expect(pts, hasLength(5));
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
// Axis-aligned box corners spanning the start/end bounds.
final xs = pts.map((p) => p.x).toSet();
final ys = pts.map((p) => p.y).toSet();
expect(xs, containsAll(<double>{0.2, 0.8}));
expect(ys, containsAll(<double>{0.3, 0.7}));
});
test('ellipse → kEllipseSamples + 1 points, closed', () {
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
expect(pts, hasLength(kEllipseSamples + 1));
expect(pts.first.x, closeTo(pts.last.x, 1e-9));
expect(pts.first.y, closeTo(pts.last.y, 1e-9));
// ~48 samples by spec.
expect(kEllipseSamples, 48);
});
test('arrow → shaft + 2 head barbs (6 points)', () {
final pts = generateShapePoints(ShapeKind.arrow, a, b);
// start, end, barb1, back-to-tip, barb2, back-to-tip = 6.
expect(pts, hasLength(6));
expect(pts[0].x, closeTo(0.2, 1e-9));
expect(pts[1].x, closeTo(0.8, 1e-9));
});
test('degenerate arrow (zero length) → just the 2 shaft points', () {
final pts = generateShapePoints(ShapeKind.arrow, a, a);
expect(pts, hasLength(2));
});
});
group('ellipse spans the start→end box', () {
test('points stay within the bounding box (inclusive)', () {
final pts = generateShapePoints(ShapeKind.ellipse, a, b);
for (final p in pts) {
expect(p.x, greaterThanOrEqualTo(0.2 - 1e-9));
expect(p.x, lessThanOrEqualTo(0.8 + 1e-9));
expect(p.y, greaterThanOrEqualTo(0.3 - 1e-9));
expect(p.y, lessThanOrEqualTo(0.7 + 1e-9));
}
});
});
group('translate (SELECT-tool move primitive)', () {
test('translatePoints shifts every point, preserves pressure', () {
const pts = [PenPoint(0.1, 0.2, 0.5), PenPoint(0.3, 0.4, null)];
final moved = translatePoints(pts, 0.05, -0.1);
expect(moved[0].x, closeTo(0.15, 1e-9));
expect(moved[0].y, closeTo(0.1, 1e-9));
expect(moved[0].pressure, 0.5);
expect(moved[1].x, closeTo(0.35, 1e-9));
expect(moved[1].pressure, isNull);
});
test('translateStroke shifts points and preserves metadata', () {
const stroke = PenStroke(
points: [PenPoint(0.1, 0.1, 1.0), PenPoint(0.2, 0.2, 1.0)],
color: 0xFF112233,
width: 0.01,
kind: PenStrokeKind.highlighter,
brush: BrushKind.pencil,
);
final moved = translateStroke(stroke, 0.1, 0.2);
expect(moved.points[0].x, closeTo(0.2, 1e-9));
expect(moved.points[0].y, closeTo(0.3, 1e-9));
expect(moved.points[1].x, closeTo(0.3, 1e-9));
expect(moved.color, 0xFF112233);
expect(moved.width, 0.01);
expect(moved.kind, PenStrokeKind.highlighter);
expect(moved.brush, BrushKind.pencil);
});
});
group('penStrokeBounds', () {
test('tight bounds over the points', () {
const stroke = PenStroke(
points: [PenPoint(0.2, 0.5, 1.0), PenPoint(0.8, 0.1, 1.0)],
color: 0xFF000000,
width: 0.01,
kind: PenStrokeKind.pen,
);
final bnds = penStrokeBounds(stroke)!;
expect(bnds.left, closeTo(0.2, 1e-9));
expect(bnds.right, closeTo(0.8, 1e-9));
expect(bnds.top, closeTo(0.1, 1e-9));
expect(bnds.bottom, closeTo(0.5, 1e-9));
});
test('empty stroke → null bounds', () {
const stroke = PenStroke(
points: [],
color: 0xFF000000,
width: 0.01,
kind: PenStrokeKind.pen,
);
expect(penStrokeBounds(stroke), isNull);
});
});
}