Compare commits

..

47 Commits

Author SHA1 Message Date
307161f465 fix: restore PDF pen capture and overhaul sticky/pens/pages
All checks were successful
CI / Windows build (push) Successful in 9m55s
Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 02:38:58 +08:00
ad9b1b46db docs: note tip-velocity physics is wired at capture
All checks were successful
CI / Windows build (push) Successful in 18m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:01:53 +08:00
f31dd0fb52 fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled
Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:01:01 +08:00
85af037b7d fix: coalesce pinch updates and stop live zoom write-back
All checks were successful
CI / Windows build (push) Successful in 9m55s
Surface Aug6 diag showed sDrop=0 but ~220 same-ms dual ZOOM frames and √2 cur ping-pong from reading currentZoom back into pinch state. Flush once per microtask and embed gitSha in diagnostic meta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 03:44:10 +08:00
4f6fb69dee fix: soft-clamp pinch zoom and Krita-inspired brush opacity
All checks were successful
CI / Windows build (push) Successful in 10m28s
Hard SDROP avalanches froze lastRaw while zoom still crawled; soft-clamp
and re-anchor instead. Ballpoint is near-solid, pencil uses soft √p without
multiply stacking; PDF ink falls back to nearest page during zoom settle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:51:15 +08:00
2b1c6ba7e0 feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:27:35 +08:00
4a6fe7d05e fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
All checks were successful
CI / Windows build (push) Successful in 8m19s
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape
strokes, expand the ink palette, and replace scratch-link split view with
an on-page sticky that shares the sidecar repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:52:15 +08:00
198da00ecd feat: vault-aligned server v1 + UX polish
All checks were successful
CI / Windows build (push) Successful in 7m47s
Redesign the optional FastAPI companion around vault files (manifest /
PUT/GET/DELETE + OCR jobs) instead of legacy strokes_json notes. Wire a
client Server settings panel for health/login. Polish shell UX: l10n for
settings/home/board, sticky-board empty state, and a narrow-screen
diagnostics FAB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:04:48 +08:00
d346cc2670 feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:27 +08:00
3cabc7e074 feat(sync): WebDAV vault sync
All checks were successful
CI / Windows build (push) Successful in 12m32s
Two-way sync of the vault folder to a user-configured WebDAV server,
so annotations (which travel with the file) sync with the file.

- WebDavSyncService.syncNow: per-file decision — local-only uploads,
  remote-only downloads, and when BOTH sides changed since the last
  sync it keeps the loser as <file>.conflict-<mtime> on both sides
  (last-write-wins by mtime) so no data is ever lost. Creates dirs as
  needed; deletes are conservative.
- The decision logic is pure and unit-tested against a fake WebDAV
  client; the real client is a thin http adapter (no dio dependency).
- Settings: WebDAV URL / user / password / remote folder, Test
  connection, Sync now (with status + last-synced), and an auto-sync
  toggle (default OFF).

Real server round-trips are device/server-validated. Credentials are in
SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
2026-06-25 01:35:13 +08:00
e939759458 feat(search): index PDF text, OCR scanned PDFs on import
All checks were successful
CI / Windows build (push) Successful in 15m50s
Search now covers handwriting, the PDF text layer, AND scanned
(rasterized) PDFs.

- PdfTextIndexer runs at import: sums the embedded text layer across
  pages; if present it stores that as the document body, otherwise the
  PDF is rasterized and its rendered pages are OCR'd in the background.
  The result lands in the sidecar `pageText` field (distinct from
  `ocrText`, the handwriting OCR). Idempotent (skips a sidecar that
  already has pageText); degrades gracefully with no OCR engine.
- pdfrx_page_text_source abstracts text/render so it's testable.
- VaultSearchIndex now harvests title + typed text + handwriting OCR +
  PDF pageText, so search finds notes, typed PDFs and scanned PDFs.

analyze clean, 409 tests green.
2026-06-25 00:23:19 +08:00
20add27a30 feat(pdf): typed-text tool (Windows-Ink friendly)
Some checks failed
CI / Windows build (push) Has been cancelled
Add a text-annotation tool to the PDF editor. With the text tool a
pen-tap, or a mouse double-click, drops a text box at that normalized
page point and focuses a real Flutter TextField — so the OS IME and the
Windows-Ink handwriting panel feed it (device-validated). Tapping an
existing box re-opens it; clearing it deletes it.

- SidecarText {nx, ny, text, fontSize (page-relative), color} per page,
  glued under zoom; stored in the sidecar `texts` field (back-compat
  missing -> none), saved via scheduleTextsSave and loaded on open.
- Rendered in pageOverlaysBuilder at the scaled position.

PDF editor only for now (note text later). analyze clean, 397 tests.
2026-06-25 00:11:45 +08:00
1d5ba05bb8 feat(pdf): paragraph-precise bookmarks
Some checks failed
CI / Windows build (push) Has been cancelled
Add a bookmark tool to the PDF editor. A bookmark anchors to a precise
location: when text is selected it captures the selection's normalized
rect + the start char index in the page text (the true paragraph
anchor); with no selection it falls back to the tapped page + point.

- Bookmark model gains optional normalized anchor rect + charIndex +
  label (all absent from JSON when null, so old sidecars still load).
- Bookmarks persist in the sidecar (scheduleBookmarkUpsert) and load on
  open; a bookmarks panel lists them and tapping one jumps to its page.
  Delete is persisted.

Scoped to the PDF editor (note bookmarks later); scroll-to-anchor is
page-level for now. analyze clean, 391 tests green.
2026-06-25 00:00:16 +08:00
c800295c12 feat(note): page background templates (rnote-style)
Some checks failed
CI / Windows build (push) Has been cancelled
A blank note can show a page-background template painted behind the
ink, picked from the toolbar and persisted per notebook.

- NoteBackground: blank / dots / ruled / grid / cornell, drawn in
  page space (scales with zoom), subtle grey. Cornell = left margin +
  bottom summary rule over a ruled body.
- Stored as the enum name in the notebook sidecar (back-compat:
  missing/unknown -> blank), saved/loaded via SidecarRepository so it
  restores on reopen.
- Picker added to the note tool palette.

PDF backgrounds skipped (PDFs have their own page content). analyze
clean, 386 tests green.
2026-06-24 23:47:29 +08:00
46589a4c87 feat(pen): persist brush kind so it survives reload
Some checks failed
CI / Windows build (push) Has been cancelled
Closes TODO(brush-persist). EditorStroke now serializes its brush as
the stable BrushKind name; sidecars written before this field, and any
unknown name, load as fountainPen (back-compat). PenStroke<->EditorStroke
carry brush both ways, so a ballpoint/highlighter/pencil stroke keeps
its opacity/blend after a document is closed and reopened.

Note: InkStroke (the note/scratchpad world-coord format) has no brush
field, so notes derive brush from the tool — highlighter is preserved,
ballpoint/pencil collapse to fountainPen on reload (TODO: extend
InkStroke). PDF documents persist brush fully. analyze clean,
379 tests green.
2026-06-24 23:40:40 +08:00
6c2dd71b82 feat(pen): brush opacity + highlighter multiply
Some checks failed
CI / Windows build (push) Has been cancelled
Honor each brush's opacity/blend so the brushes feel distinct
(closes TODO(brush-opacity)).

- Shared paint resolver: a stroke's color alpha is multiplied by its
  brush opacity; ballpoint/pencil opacity is tied to pressure
  (per-stroke average this increment) so a ballpoint reads lighter
  than a solid fountain pen.
- Highlighter paints with BlendMode.multiply and draws once, so
  cross-stroke overlap darkens like a real marker while self-overlap
  doesn't.
- Applied across BOTH render paths (PenCanvas static/live painters and
  the PDF _PageOverlayPainter).

Pencil paper-grain texture still deferred (TODO brush-texture); brush
kind is not yet serialized (TODO brush-persist — next). analyze clean,
tests green.
2026-06-24 23:27:11 +08:00
24d13642fd feat(storage): app-pause flush + vault search index
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 6 (final storage phase).

- SidecarRepositoryRegistry tracks every open repo; SidecarFlushObserver
  (a WidgetsBindingObserver in main) flushes them all on
  inactive/hidden/paused/detached, awaiting each flush — the last
  strokes can't be lost on app close, not just on the 800ms timer.
- VaultSearchIndex rebuilds by scanning vault sidecars (the source of
  truth) — note titles, OCR text and document names — and search_provider
  queries it, so search spans notes + PDFs. Rebuilt on launch / after
  import.

The vault file-based storage migration (Phases 0-6) is complete:
annotations travel with the file, picked vault folder, atomic autosave,
one Import-file entry, SQLite migrated to sidecars. analyze clean,
tests green.
2026-06-24 23:19:21 +08:00
4886f1b2df feat(storage): one-time SQLite to sidecar migration
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 5. On first launch with a valid vault, migrate legacy SQLite
data into vault sidecars so nothing is lost on upgrade.

- SqliteToSidecarMigrator: documents (+ per-page ink, scratch-links
  + scratchpads, bookmarks) -> notebook folder + <file>.badnote.json;
  notes (+ strokes) -> notebook.badnote.json. Reuses existing JSON.
- Idempotent (skips already-migrated targets); missing source files
  still get their annotations migrated.
- DB relocates to <vault>/.badnote/index.sqlite; the legacy DB is
  renamed to .premigration ONLY after a successful pass, so a failed
  migration leaves data intact and the run-once flag unset.
- main.dart runs it once, gated on vaultMigrationDone.

Golden migrator tests (seeded legacy DB -> sidecars, idempotent
re-run, legacy preserved). analyze clean, tests green.
2026-06-24 23:05:10 +08:00
f4f0853eae feat(storage): notes are vault sidecar notebooks
All checks were successful
CI / Windows build (push) Successful in 12m55s
Phase 4. Standalone notes move off SQLite into the vault, like the
PDF annotations.

- "Create notebook" makes a vault folder with a notebook.badnote.json
  (BadnoteSidecar docType 'notebook' + a title field), opened via
  SidecarRepository.
- PenNoteScreen loads/saves its strokes (page 0) + title to that
  sidecar instead of the SQLite Note model.
- note_provider lists notes from a vault scan (VaultService.scanNotes
  = folders with notebook.badnote.json and no source file); the doc
  scan still excludes them. Delete removes the folder.

PDF/slide editors unchanged; pre-existing SQLite notes migrate in
Phase 5. analyze clean, tests green.
2026-06-24 22:48:18 +08:00
2f0fda5f95 feat(import): one Import-file entry + vault notebooks
All checks were successful
CI / Windows build (push) Successful in 14m14s
Phase 3. Import becomes a single top-level action beside "Create
notebook" and the library is vault-backed.

- VaultService.createNotebook copies a picked file into a fresh
  (de-duplicated) notebook folder under the vault; its sidecar lives
  beside it, so annotations travel with the file.
- Home screen: one "Import file" action with a multi-extension picker
  (pdf / docx / pptx); routes to the editor by extension.
- The document list is now a vault scan (folders with a source file),
  not the SQLite documents table — no cache, always correct.
- PPTX soffice detection fix; DOCX convert-on-import is best-effort
  and fails gracefully when LibreOffice is unavailable.

analyze clean, tests green.
2026-06-24 21:12:38 +08:00
978111eeff feat(storage): PDF editor persists to per-file sidecar
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 2 (core swap). The PDF editor and split-view scratchpad stop
writing SQLite and persist to a per-file sidecar
`<pdfPath>.badnote.json` (debounced, atomic temp+rename+.bak) — so
annotations travel with the file. The source path is the identity
(no more djb2 doc-id).

- SidecarRepository wraps the Phase-1 store with debounced autosave.
- pen_editor: per-page ink, scratch-links AND highlights now persist
  to the sidecar and restore on reopen (closes persist-highlights).
- New "un-highlight" tool: tap a stored highlight to remove it — the
  highlight could not be removed before.
- split_view: each anchor's scratchpad lives in the sidecar's
  scratchLinks[id].scratchpad, keyed by anchor id.

Note: pre-existing SQLite annotations are migrated later (Phase 5);
note/slide editors swap in Phase 4. analyze clean, tests green.
2026-06-24 21:03:28 +08:00
953c7b700f feat(storage): sidecar model + atomic store (lib only)
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 1 of the file-based storage plan. Pure library, no runtime
behavior change yet (editors still use SQLite).

- BadnoteSidecar: per-file annotation document (schema-versioned)
  holding per-page ink (EditorStroke JSON), text highlights,
  scratch-link anchors (ScratchLink JSON) each with its own
  scratchpad (InkStroke world-coord JSON), and bookmarks. Reuses the
  existing toJson formats — no parallel stroke format.
- SidecarStore.writeAtomic: temp-file + rename atomic write keeping a
  .bak; read() falls back to .bak on a missing/corrupt primary.

Round-trip + atomic-write + .bak-recovery tests. analyze clean,
322 tests green.
2026-06-24 20:53:01 +08:00
9fcac47ef2 feat(vault): pick a notebook vault folder on first run
Some checks failed
CI / Windows build (push) Has been cancelled
Phase 0 of the file-based storage plan (docs/plans/
2026-06-24-file-based-storage.md). Foundation only — no editor or
DB change yet.

- VaultService (SharedPreferences): stores the vault root path,
  vaultRootValid() = path set AND directory exists.
- VaultSetupScreen: first-run folder picker (file_picker, Windows).
- main.dart gates HomeScreen behind a valid vault, re-prompting if
  the saved folder is gone.
- Settings: a Vault section to change the folder.

Editors still use SQLite; later phases move annotations into
per-file sidecars under the vault. analyze clean, tests green.
2026-06-24 20:45:16 +08:00
875dabcd89 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.
2026-06-24 20:38:18 +08:00
fd102b5703 fix(pdf): live ink follows pen + stop zoom jump
All checks were successful
CI / Windows build (push) Successful in 12m51s
Two critical PDF-editor bugs.

1. Live ink only appeared after lifting the pen. The page overlay
   painter captured the live stroke as a build-time snapshot, so
   per-move repaints redrew stale (null) data until commit. Route
   the live stroke through a ValueNotifier the painter reads at
   paint time (repaint: merge(overlayRepaint, liveStrokeVN)).

2. Pinch-zoom jumped on Windows touch. pdfrx's internal forked
   InteractiveViewer scales with an unguarded scaleStart*details.scale
   that pops on a touch-count blip or one-frame spike. Take over the
   pinch: scaleEnabled:false (pdfrx keeps 1-finger scroll + wheel),
   a glitch-guarded ScaleGestureRecognizer drives focal zoom via the
   pdfrx controller, reusing absolutePinchScale + the re-baseline /
   per-frame-clamp / focal-jump guards already proven on the note
   canvas.

Zoom + pen feel are device-validated. analyze clean, tests green.
2026-06-24 20:18:22 +08:00
9bb5c483d6 feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled
Replace the rejected standalone sticky-card board with the real
feature: place a link anchor anywhere on a PDF page, tap it to open
split view whose right pane is THAT anchor's own infinite scratchpad
(keyed by anchor id) — like a paper sticky-note tab.

- ScratchLink model + scratch_links table (id, doc, page, nx, ny).
- PDF editor: "place link" tool drops/loads/shows tappable markers;
  tap opens SplitViewScreen for that anchor; long-press deletes.
- SplitViewScreen rebuilt on pdfrx (was syncfusion), right scratchpad
  keyed by scratchLinkId, new brush palette (was AnnotationToolbar).
- Remove board_screen + its test + the home board entry.

analyze clean, tests green.
2026-06-24 20:02:12 +08:00
f757701391 feat(board): sticky-note board with backlinks
All checks were successful
CI / Windows build (push) Successful in 20m32s
Wire the F7 双链 + 无限便利贴 model (Board/LinkGraph) into a
reachable screen. Previously the model existed but had no UI and
no entry point.

board_screen.dart: an infinite InteractiveViewer canvas of
draggable, editable sticky cards. Card text renders [[links]] as
tappable chips that pan to the target card (dangling links styled
apart). A backlinks panel lists "linked from" via backlinksOf.
"Add card" FAB drops a card at the viewport center.

Persistence: a board_cards table (DB v7), one row per card,
debounced 800ms like the ink editors, loaded on open — boards
survive restart. Entry added to the home screen app bar
(dashboard_customize icon).

Ink-on-cards, multi-board management and link autocomplete are
deferred (TODO board-ink / board-multi / board-link-autocomplete).
analyze clean, 285 tests green.
2026-06-24 17:15:39 +08:00
0feca74278 feat(pen): extensible brush model (4 brushes)
All checks were successful
CI / Windows build (push) Successful in 14m54s
Replace the 2-tool ink system with a data-driven, Krita-style
BrushProfile (lib/editor/engine/brush.dart). Adding a brush is a
const map entry, not render-path branching.

Four presets from the rnote/krita spec:
- fountain pen: quadratic (p^2) pressure, wide dynamic width
- ballpoint:    near-constant width (thinning 0.15)
- highlighter:  flat width, square caps
- pencil:       sqrt(p) pressure, moderate width

Pressure is pre-warped per brush via PressureCurve(gamma) before
perfect_freehand; geometry fields (thinning/streamline/smoothing/
caps) flow through the shared stroke recipe so the PDF overlay and
the note/slide PenCanvas both honor the brush. Brush kind is now
persisted on the stroke model. Picker added to all three toolbars.

Opacity/multiply and pencil grain are carried as data but not yet
composited (TODO brush-opacity / brush-texture); this increment is
width + pressure-curve differentiation. analyze clean, 283 tests.
2026-06-24 11:13:43 +08:00
45a8931b64 docs(pen): rnote + krita brush algorithm spec
All checks were successful
CI / Windows build (push) Successful in 17m28s
Source-grounded spec for the pen-engine rebuild (P1):
rnote PressureCurve (quadratic Pow2), Catmull-Rom -> cubic
bezier smoothing, Google ink-stroke-modeler spring params,
Krita ballpoint vs fountain-pen sensor sets, and concrete
perfect_freehand option sets per brush.
2026-06-24 02:32:55 +08:00
db6e3842c7 feat(pdf): rebuild editor on vector PdfViewer
Replace the single-page PdfPageView bitmap with a pdfrx
PdfViewer: real vector text, continuous scroll, native
pinch-zoom (no custom zoom solver, so no zoom-jump here).

Ink is glued per-page via pageOverlaysBuilder; the pen is
captured at the viewer level by PenCaptureRegion while touch
falls through to scroll / pinch / text-select.

Add select-text -> highlight via PdfTextSelectionParams: the
selection's fragment rects are stored as normalized page rects
and drawn under the ink. In-memory only for now.

Per-page persistence, undo/redo, tools, colors, thumbnails and
pen settings are reused verbatim. analyze clean, 270 tests green.
2026-06-24 02:32:41 +08:00
f41df2033f feat(editors): expose pen settings on note + slide
All checks were successful
CI / Windows build (push) Successful in 13m39s
The note and slide palettes lacked the settings gear the PDF editor has,
so width / pressure / eraser-size+mode / palm-rejection were unreachable
there (the user's 'toolbar 少了很多东西'). Add the gear to both; it opens
the existing rich pen-settings sheet.

flutter analyze: 0 issues.
2026-06-23 16:55:31 +08:00
df389177e6 fix(split): frame scratchpad on existing ink
Some checks failed
CI / Windows build (push) Has been cancelled
The pen-first scratchpad opened at identity transform, showing only the
empty top-left corner of the 4000x4000 world — so existing ink (drawn
elsewhere) was off-screen and the pane looked blank ("草稿纸根本没看到").

On first layout, fit the strokes' world bounding box into the pane (padded,
scale clamped 0.15-1.5) so saved ink is immediately visible; an empty
scratchpad falls back to a 1:1 view near the origin.

flutter analyze: 0 issues.
2026-06-23 16:52:35 +08:00
8908f42f76 feat(windows): disable pen tap / press-hold visual feedback
On pen-down the OS drew the "Windows Ink" tap ripple / press-and-hold ring
under the nib — ugly and laggy-looking while writing. Set the tablet input
service's MicrosoftTabletPenServiceProperty on both the top-level window and
the Flutter child (where WM_POINTER lands) with the disable flags
(PENTAPFEEDBACK, PRESSANDHOLD, PENBARRELFEEDBACK, TOUCHUIFORCEON/OFF, FLICKS)
so the pen draws instantly with no OS animation.

Native-only change (windows/runner/flutter_window.cpp); built by CI.
2026-06-23 16:52:25 +08:00
eae4493954 fix(zoom): stop re-baseline scale oscillation
Device log showed the applied scale oscillating ~1.4x every frame while
the raw pinch was smooth (cur 1.116->0.797->1.074, raw ~0.46). Root cause:
on a pointer-count re-baseline (Windows touch flickers 2<->1<->2 mid-pinch)
the code set _scaleStart = matrix.getMaxScaleOnAxis() — a read-back captured
at a glitchy instant — so the absolute map K = scaleStart/rawScaleAtBaseline
jumped frame to frame.

Fix: anchor the re-baseline to the CLEAN tracked _lastAppliedScale instead
of the live matrix read-back, so the displayed scale is continuous across
the re-baseline regardless of any matrix transient. The math is already
covered by the pinch_scale_solver "same-instant re-baseline" test; this just
feeds it the right value.

flutter analyze: 0. pinch_scale_solver + pen_zoom: pass.
2026-06-23 16:52:14 +08:00
0ae2671f9b feat(split): scratchpad inks on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 20m37s
The split-view scratchpad was the last surface on the old ink_canvas. Move
its inking engine to the performant PenCanvas while keeping the infinite
auto-expanding world.

Key idea: store strokes in absolute WORLD pixels (InkStroke — unchanged
saveScratchpad format) and render through PenCanvas by normalizing against
the CURRENT world size. When the world auto-expands, stored world coords do
not move — only the normalization divisor grows — so ink stays put with zero
drift (proven by the world-expand-stability test).

- Replace InteractiveViewer+SizedBox+InkCanvas with PenCanvas (own pan/zoom,
  minScale 0.1 to survey the big world); keep the AnnotationToolbar.
- Stroke callbacks go through ink_stroke_adapter; load filters to freehand
  so the canvas list stays 1:1 with the undo manager.
- Pen/highlighter/eraser map from the toolbar's PenTool; width is world px.

The left PDF-reference pane (SfPdfViewer, read-only) is unchanged.

Tests: world-expand stability added. flutter analyze: 0. Suite: 270/270.
2026-06-23 15:52:52 +08:00
ffb9e35755 feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s
PPT slides now annotate with the single performant inking engine
(PenCanvas) instead of the old ink_canvas, per "all note features on the
pen-first canvas".

- PenSlideScreen: per-slide normalized strokes over each slide image,
  prev/next + slider nav, undo/redo, shared M3 palette, and the pressure
  curve / eraser size+mode / palm rejection from the shared canvas.
- slide_export: pure, tested export geometry. Because strokes are now
  normalized to the page rect, the PDF exporter maps them straight into
  each slide's draw rect — fixing the old exporter's known ink
  misalignment (it guessed live-widget size).
- Route PPT import + open -> PenSlideScreen; delete the dead old
  ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for
  split_view, the last old-canvas screen.)

Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269.
2026-06-23 10:27:09 +08:00
dfe5f2a477 feat(note): rebuild note editor on the pen-first canvas
Some checks failed
CI / Windows build (push) Has been cancelled
Notes now use the single performant inking engine (PenCanvas) instead of
the old ink_canvas, per "all note features on the pen-first canvas".

- ink_stroke_adapter: pure InkStroke<->PenStroke bridge (normalize against
  a logical note page; drop non-freehand shapes/text). Round-trip tested.
- pen_palette_widgets: shared M3 ToolButton/PaletteDivider/RoundIconButton
  so PDF + note editors use identical chrome (PenEditorScreen migrated to
  them; its private copies deleted).
- PenNoteScreen: PenCanvas over a white logical page, undo/redo, title,
  save -> Note.strokes (+ local OCR for search). Pressure curve, eraser
  size/mode and palm rejection all inherited from the shared canvas.
- Route home (new/open) + search note hits -> PenNoteScreen; remove the
  now-redundant "Pen Canvas (beta)" spike button; delete the dead old
  note_editor_screen.

Tests: ink_stroke_adapter (5) + pen_note_screen widget (load + commit, 2).
flutter analyze: 0 issues. Full suite: 265/265.
2026-06-23 10:21:40 +08:00
3507e929b1 refactor: delete dead old PDF annotator screen
All checks were successful
CI / Windows build (push) Successful in 11m52s
Now that every PDF entry point (home import, home open, search jump)
routes to PenEditorScreen, the old SfPdfViewer-based annotator is
unreachable. Remove it and the two widgets it solely owned:
- screens/pdf_annotator_screen.dart (981 lines)
- widgets/page_thumbnail_sidebar.dart
- widgets/pdf_annotation_layer.dart

annotation_toolbar and ink_canvas stay (still used by the note/ppt/
split-view screens). No references remain to the deleted files.

flutter analyze: 0 issues. Full suite: 258/258.
2026-06-23 10:06:02 +08:00
96594fbe1b feat(route): search opens PDFs in pen editor too
The search-result document jump still opened the OLD PdfAnnotatorScreen,
the last live entry to it. Route it to PenEditorScreen instead, and add
an initialPage param to the editor so the jump lands on the hit's page
(clamped to the document range once it loads).

With this, PenEditorScreen is the ONLY reachable PDF surface; the old
annotator is now dead code (no remaining references).

flutter analyze: 0 issues. Full suite: 258/258.
2026-06-23 10:04:38 +08:00
a7d71e7cfd feat(i18n): localize settings + search screens
Some checks failed
CI / Windows build (push) Has been cancelled
Extend the en/zh localization to the two screens reached from the home
app bar (the "全都用 + 多语言" ask):
- settings: title, theme-mode segments (System/Light/Dark), seed-color
  description, color-picker + clear-data dialogs.
- search: hint, error, empty/no-results states, Notes/Documents section
  headers, page label.

New ARB keys regenerated; l10n_test now asserts the new keys resolve in
both English and Chinese.

flutter analyze: 0 issues. l10n_test: 3/3.
2026-06-23 10:03:02 +08:00
d1b265dc70 chore: clean analyzer to zero issues
Some checks failed
CI / Windows build (push) Has been cancelled
Whole-project `flutter analyze` exited 1 on 17 pre-existing info/warning
lints (no errors) in dev tools and test files. Clean them so analyze is
green:
- editor_repository_test: drop the redundant sqflite_common import; keep
  the used utils import with a transitive-dep ignore.
- pen_* widget tests: `(_, __)` wildcard params -> `(_, _)`.
- search_snippet_test / gen_bench_pdf / gen_dense_strokes: drop needless
  interpolation braces; remove an unused `Size` show and an unused local;
  mark the gen tool as print-allowed.

No runtime behavior changed. flutter analyze: No issues found. Affected
tests: 22/22 pass.
2026-06-23 09:57:04 +08:00
2c9f6037f7 test: fix gamma default + localized home
Some checks failed
CI / Windows build (push) Has been cancelled
Two tests asserted pre-refactor behavior:
- pen_config_test expected the old pressureGamma default of 1.0; it is
  now the natural curve (the pen-feel feature). Also assert the new
  eraser defaults.
- widget_test pumped HomeScreen without localization delegates, which
  the now-localized app bar requires. Provide the delegates.

Full suite: 258/258 pass.
2026-06-23 09:51:10 +08:00
4fb431727e feat(eraser): configurable size + stroke-eraser mode
Some checks failed
CI / Windows build (push) Has been cancelled
"优化橡皮擦工具,你优化在哪" — the eraser already did segment erase, but
the radius was a hardcoded const with no size control and no whole-stroke
mode. Add both, OneNote/Notability-style:

- PenConfig: eraserRadius (0.005-0.1, default 0.02) + eraserWholeStroke
  bool, with copyWith / JSON / setters.
- PenCanvas: uses widget.eraserRadius for the live hit area AND the cursor
  preview (they stay in sync); eraserWholeStroke=true removes the whole
  stroke on contact, false keeps the segment-split behavior.
- pen_editor threads both from PenConfig.
- pen-settings: new Eraser section — size slider + "Stroke Eraser" switch.

Tests: pen_eraser_mode_widget proves point-eraser keeps the untouched ends
while stroke-eraser deletes the whole stroke from the same pass.

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:47:58 +08:00
299b9546a8 feat(pen): apply pressure-response curve for natural feel
"手写笔就是一个带压感的手指,没有特殊适配" — correct. The new editor fed
RAW LINEAR stylus pressure into perfect_freehand, and the pressureGamma
config (a slider in pen-settings) was read ONLY by that slider's UI and
NEVER applied to a stroke. Dead wiring, like the rest.

Wire it for real:
- PenCanvas applies PressureCurve(floor, gamma) at capture, so stored
  pressure carries the feel and live + PDF export replay identically.
- Natural defaults: gamma 0.7 (light touches register more width, rnote/
  OneNote-like) + floor 0.12 (thin strokes keep body, not scratchy).
- pen_editor threads PenConfig.pressureGamma into the canvas — the slider
  now actually changes stroke width.
- pen_config: natural default + one-time migration of the legacy inert
  gamma 1.0, guarded by a marker so a deliberate 1.0 still sticks.

Tests: pen_config_gamma_migration (4) + pressure_curve (6) pass; pen
widget regressions green. flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:43:56 +08:00
1d70c029b3 feat(i18n): add English + Chinese localization
All checks were successful
CI / Windows build (push) Successful in 12m34s
The app had zero localization ("软件多语言做了吗" — no). Add Flutter's
official gen-l10n pipeline and localize the core flow the user sees.

- pubspec: flutter_localizations + intl + generate: true
- l10n.yaml + lib/l10n/app_en.arb + app_zh.arb (37 strings)
- main.dart: localizationsDelegates + supportedLocales (follows OS locale)
- pen editor: all tool tooltips, page pill, error states localized
- home: app bar actions + empty-state buttons localized

Proven end-to-end: l10n_test pumps the same widget under Locale('en')
and Locale('zh') and asserts English vs Chinese strings resolve.

flutter analyze: 0 issues. l10n_test: 3/3 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:35:26 +08:00
f48e43f13d fix(zoom): kill re-baseline pinch pop
Device log showed a single-frame scale pop (cur 0.504->0.694, a
+38% jump UP while the pinch was still shrinking).

Root cause: the absolute mapping targetScale = scaleStart *
details.scale is only valid when details.scale is 1.0 at the
moment scaleStart is captured. That holds at gesture start, but
on a mid-gesture re-baseline (a finger blips 2->1->2, routine on
Windows touch) a fresh scaleStart got multiplied by the
recognizer's still-cumulative details.scale, popping the zoom
then snapping back.

Fix: track rawScaleAtBaseline and normalize details.scale against
it so the cumulative reads 1.0 at every baseline. Extracted
absolutePinchScale() pure solver + 5 unit tests covering the
exact re-baseline scenario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:30:41 +08:00
5db364d1fc feat(route): open PDFs in pen-first editor
The night's pen-first rebuild (PenEditorScreen + PenCanvas + zoom fix +
eraser + M3 tool palette + render cache) was unreachable from the running
app: home_screen opened the OLD PdfAnnotatorScreen, so the user saw zero
change. Wire both PDF-open sites (import + open-existing) to PenEditorScreen,
making the entire editor/* stack LIVE on the real PDF path.

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:28:18 +08:00
163 changed files with 33737 additions and 6000 deletions

View File

@@ -0,0 +1,33 @@
# PUA Loop — status (BadNote 整体重构)
## Oracle: `flutter analyze lib/editor && flutter test` → GREEN, exit 0, 145/145.
## Delivered this loop (all committed + pushed, 6829076..eedb52d)
P0 completion + P0.5 automatable layer + bonus pure cores:
- 6829076 zoom absolute-snapshot fix + pen streamline (P0/live)
- a48c0e7 input_arbiter (P0 step4)
- d500872 export single-source recipe / R7 (P0 step7)
- f64e656 SaveScheduler tests (P0 step8)
- eca5141 live canvas → revision-gated ui.Picture cache (P0 step3)
- 07b543f PageTileCache DPI-bucketed (P0.5)
- 1a3d106 PageStackMetrics windowing (P0.5)
- 6b7cc14 PageDocumentSource + fit-to-width glue (P0.5)
- c03513d PdfrxPageDocumentSource + API source-pin (P0.5 step12/SF4)
- c31bfd3 navigation math current-page/scroll-clamp (P0.5)
- 852eb38 双链 link_graph pure core (F7)
- eedb52d pressure curve floor+gamma (F5)
## THE BLOCKER (honest)
The refactor's CRITICAL PATH is device validation, which only the user's Surface
can provide and which the plan ITSELF gates on:
- P0 step9: pen/palm/pinch on Surface (build eedb52d).
- P0.5 exit: crisp-at-4× + 60fps profile — no automatable acceptance test exists.
The remaining work (page_tile renderer, page_viewport WIDGET, perf bench) is
device/GPU-gated; writing it blind = a claim with no acceptance evidence.
## Options for the user
1. Device-test eedb52d (zoom/pen/render/export) → I wire the P0.5 pure pieces
into the viewport widget and push the P0.5 device gate.
2. Tell me to keep PRE-BUILDING unwired pure cores (F6 page-map, F8 snippet
extraction, more F5/F7) — real + tested, but NOT on the blocked critical path.
3. /pua:cancel-pua-loop to end the loop.

View File

@@ -0,0 +1,3 @@
{"iteration":0,"status":"init","verify_command":"flutter analyze","timestamp":"2026-06-23T01:25:48Z"}
{"iteration":1,"status":"continue","timestamp":"2026-06-23T01:57:58Z"}
{"iteration":2,"status":"continue","timestamp":"2026-06-23T02:07:19Z"}

View File

@@ -220,7 +220,13 @@ jobs:
HTTPS_PROXY: http://192.168.31.189:7890
http_proxy: http://192.168.31.189:7890
https_proxy: http://192.168.31.189:7890
run: flutter build windows --release
shell: powershell
run: |
$sha = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.Substring(0, [Math]::Min(12, $env:GITHUB_SHA.Length)) } else { "unknown" }
$built = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
flutter build windows --release `
--dart-define="BADNOTE_GIT_SHA=$sha" `
--dart-define="BADNOTE_BUILD_TIME=$built"
- name: Show build output
shell: powershell

View File

@@ -1,17 +1,20 @@
# BadNote
Local-first Surface Pen note-taking app with PDF/PPT annotation.
Local-first Surface Pen note-taking app with PDF / PPTX / DOCX annotation.
All notes, documents, search, and OCR run on your device. No server is required to use the app.
## Features
- Ink notes with Surface Pen (pressure, stabilizer, undo/redo)
- PDF and PPT import with page-level annotation
- Unified shell: Library · Sticky board · Search · Settings
- Ink notes with Surface Pen (pressure, predictor, undo/redo)
- PDF annotation + native OOXML PPTX/DOCX viewers (no LibreOffice required)
- Infinite sticky board with `[[wikilinks]]` / backlinks
- Full-text search over note titles, typed text, and OCR results
- **Local OCR** — pluggable, fully on-device. An embedded ONNX recognition
backend (cross-platform, CPU/iGPU) with a graceful fallback to the platform's
built-in OCR (Windows). See [Local OCR](#local-ocr).
- Always-on diagnostics + one-click diagnostic pack export (Settings)
- Optional self-hosted **BadNote Server** (`/api/v1`: vault assist + OCR jobs) — see [server/README.md](server/README.md)
- **Local OCR** — ONNX when bundled, else Windows WinRT
- WebDAV vault sync (NAS)
## Build (Windows)

7888
badnote_input_log-2.txt Normal file

File diff suppressed because it is too large Load Diff

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,45 @@
# Surface 验收清单(诊断包驱动)
AI 无法坐在 Surface 前时,用本清单 + **设置 → 诊断 → 导出诊断包** 闭环。
## 准备
1. `flutter build windows --release` 或 profile 安装包
2. 打开 BadNote → 设置 → 确认「导出诊断包」可用
3. 准备Surface Pen、一份 20+ 页 PDF、空白笔记
## MUST #3 — 笔 / 触控仲裁(约 1 分钟)
| 步骤 | 期望 |
|------|------|
| Pen 在 PDF 上书写 | 出墨,压感可见 |
| 单指上下滚 | 滚动页面,不画线 |
| 双指捏缩放 | 缩放,不画线 |
| 手掌搁在屏幕上同时用笔写 | 掌不画线palm |
导出诊断包。包内 `pen_events.json` 应出现 `arbiter` 行:`decision=draw`(笔)与 `decision=pan`(指)。
## W3 — 硬件笔按钮
| 步骤 | 期望 |
|------|------|
| 无悬停,直接用笔尾点按 | 擦除而非画线 |
| 按侧键(按你的笔设置) | 触发橡皮擦/平移/撤销 |
| 倾斜笔身书写 | `pen_events` / native summary 中 tilt 非全 0 |
查看 `meta.json``penNative``orPenFlags` / `orPtrFlags` 在按键时应有非零位;`historyCount` 可 >1。
## MUST #4 / #5 — 流畅profile
1. `flutter run --profile`
2. 打开大 PDF快速 fling + 捏缩放 30 秒
3. 导出诊断包 → `frame_samples.json``overBudget` 占比主观可接受;体感不掉帧
## 手感主观
- 快速甩笔:笔尖无明显拖尾/点状塌缩
- 缩放后页面不长时间白闪(若仍闪,在包内搜 `zoom` / `rebaseline`
## 回传
`badnote_diag_*.zip` 发回即可;无需录屏(可选)。

View File

@@ -0,0 +1,323 @@
# Pen/Ink Engine Spec — rnote + Krita algorithms, ported to Flutter/Dart
Concrete, implementable spec for a drawing-grade, Krita-compatible, extensible brush model
using the `perfect_freehand` Dart package plus a custom variable-width polygon path where
needed. All formulas are verbatim from primary source. Sources cited at the end.
---
## 1. rnote pressure → width (QUADRATIC + all PressureCurve options)
**Source:** `crates/rnote-compose/src/style/mod.rs``PressureCurve` enum and its `apply()` method.
Repo: https://github.com/flxzt/rnote
```rust
pub enum PressureCurve { Const = 0, Linear, Sqrt, Cbrt, Pow2, Pow3 } // default = Linear
pub fn apply(&self, width: f64, pressure: f64) -> f64 {
match self {
Self::Const => width, // w
Self::Linear => width * pressure, // w·p
Self::Sqrt => width * pressure.sqrt(), // w·p^0.5
Self::Cbrt => width * pressure.cbrt(), // w·p^(1/3)
Self::Pow2 => width * pressure.powi(2), // w·p^2 <-- QUADRATIC
Self::Pow3 => width * pressure.powi(3), // w·p^3
}
}
```
- `pressure ∈ [0,1]`; `width` = configured max stroke **width** (full width, not radius).
- It is a **pure power law** `width = baseWidth · p^n`, with `n ∈ {0, 1, 0.5, 1/3, 2, 3}`.
There are no other coefficients.
### The QUADRATIC the user wants = `Pow2`
```
width(p) = baseWidth · p² (p ∈ [0,1])
```
| Variant | Exponent | Formula | Feel |
|---------|----------|--------------------|----------------------------------------|
| Const | — | `w` | constant width (no pressure) |
| Linear | 1 | `w · p` | proportional (PF native model) |
| Sqrt | 0.5 | `w · √p` | thickens fast then plateaus (firm pen) |
| Cbrt | 1/3 | `w · p^(1/3)` | thickens very fast then plateaus |
| **Pow2**| **2** | **`w · p²`** | **thin at low p, ramps steeply (fountain/brush)** |
| Pow3 | 3 | `w · p³` | very thin until high p (expressive) |
Dart:
```dart
double rnoteWidth(double baseWidth, double p, PressureCurve c) => switch (c) {
PressureCurve.constc => baseWidth,
PressureCurve.linear => baseWidth * p,
PressureCurve.sqrt => baseWidth * math.sqrt(p),
PressureCurve.cbrt => baseWidth * math.pow(p, 1/3),
PressureCurve.pow2 => baseWidth * p * p, // QUADRATIC
PressureCurve.pow3 => baseWidth * p * p * p,
};
```
**Recommended floored variant** (real pens never reach zero width):
```
width(p) = baseWidth · (wMin + (1 - wMin) · p²), wMin ≈ 0.15 .. 0.35
```
---
## 2. rnote stroke building / smoothing (step by step)
Output unit — `Segment` (`crates/rnote-compose/src/penpath/segment.rs`):
```rust
enum Segment {
LineTo { end: Element }, // Element = { pos: Vec2, pressure: f64 }
QuadBezTo { cp: Vec2, end: Element },
CubBezTo { cp1: Vec2, cp2: Vec2, end: Element },
}
```
rnote has two pen builders. **Pick one to port.**
### 2a. Curved builder — uniform Catmull-Rom → cubic Bézier (RECOMMENDED FIRST; simple, deterministic)
**Source:** `crates/rnote-compose/src/builders/penpathcurvedbuilder.rs`
and `crates/rnote-compose/src/shapes/cubbez.rs::new_w_catmull_rom`.
Algorithm:
1. Buffer raw input `Element`s into a `Vec<Element>`.
2. **Start state:** emit plain `LineTo` segments until ≥ 4 points are buffered.
3. While ≥ 4 buffered points remain, take a **sliding window of 4 consecutive points**
`(p0, p1, p2, p3)` and emit ONE `CubBezTo` that draws the **middle span p1 → p2**.
Advance `i += 1` (windows overlap by 3 points → C1 continuity).
4. **Control points** (Catmull-Rom → cubic-Bézier conversion, **tension = 1.0 fixed, divisor = 6.0**):
```
cp1 = p1 + (p2 - p0) / (6.0 * tension)
cp2 = p2 - (p3 - p1) / (6.0 * tension)
// cubic Bézier: start = p1, cp1, cp2, end = p2
```
5. If the construction degenerates (coincident points), fall back to `LineTo`.
This is a **uniform (non-centripetal) Catmull-Rom spline expressed as a chain of cubic Béziers.**
The `1/6` factor is the standard Catmull-Rom→Bézier identity `cp = Pk ± (Pk+1 Pk1)/6`.
**There is NO separate streamline / position-averaging step in this builder** — all smoothing
comes from the spline. Per-point width still comes from each Element's pressure via §1.
Dart (per emitted cubic, tension = 1.0):
```dart
final cp1 = p1 + (p2 - p0) / 6.0;
final cp2 = p2 - (p3 - p1) / 6.0;
path.cubicTo(cp1.dx, cp1.dy, cp2.dx, cp2.dy, p2.dx, p2.dy);
```
### 2b. Modeled builder — Google ink-stroke-modeler spring-mass-damper ("physics" path)
**Source:** `crates/rnote-compose/src/builders/penpathmodeledbuilder.rs`, which wraps the
`ink-stroke-modeler-rs` crate (Rust binding of Google C++ `ink-stroke-modeler`).
The rendered tip is a **mass on a spring** anchored to the raw input, with drag — giving
smoothing plus the slight realistic "catch-up" lag of good ink.
Pipeline per input event (`Down`/`Move`/`Up`, each with pos + pressure + time):
1. **Wobble smoothing** — speed-gated moving average that kills high-frequency jitter (only when slow).
2. **Resampling** — upsample to a fixed output rate so curvature is even regardless of input rate.
3. **Position modeling** — spring-mass-damper integrates the tip toward each resampled anchor.
4. **Stylus-state modeling** — interpolate pressure/tilt onto resampled points (last N input samples).
5. **Prediction** — `predict()` extends the tip ahead of the latest real input to hide latency; cleared on `Up`.
6. Emit dense `Segment::LineTo` points (and prediction points while drawing).
rnote's `MODELER_PARAMS` (overrides on `ModelerParams::suggested()`):
- `sampling_min_output_rate = 120.0` Hz
- `sampling_max_outputs_per_call = 200`
- `sampling_end_of_stroke_stopping_distance = 0.01`
- `stylus_state_modeler_max_input_samples = 20`
Google `suggested()` defaults (`ink_stroke_modeler/params.cc`) — the actual spring constants:
- wobble_smoother: `timeout = 0.04 s`, `speed_floor = 1.31`, `speed_ceiling = 1.44`
- position_modeler: **`spring_mass_constant = 11/32400 ≈ 0.00033951`**, **`drag_constant = 72.0`**
- sampling: `min_output_rate = 180`, `end_of_stroke_stopping_distance = 0.001`, `end_of_stroke_max_iterations = 20`
- stylus_state_modeler: `max_input_samples = 20`
Spring update (Euler, fixed dt = 1/output_rate):
```
F = (x_anchor - x_tip)/spring_mass_constant - drag_constant * v_tip
v_tip += F * dt
x_tip += v_tip * dt
```
Higher `drag_constant` = more damping/lag; smaller `spring_mass_constant` = stiffer/snappier.
**Porting call:** ship 2a now (trivial, looks great for notes). Add 2b later as a "smooth mode"
tip filter for premium feel + latency hiding.
---
## 3. Krita: ballpoint vs fountain pen parameter sets
**Source:** Krita Manual 5.3 —
- Sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
- Opacity vs Flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
- Inking: https://docs.krita.org/en/tutorials/inking.html
**Model:** The Pixel brush stamps **dabs** along the stroke; each property (Size, Opacity, Flow,
Rotation, …) is driven by a **sensor** through an editable **response curve** (x = sensor 0..1 →
y = output multiplier 0..1).
**Sensors & ranges:** Pressure 0..1 (PressureIn = ratchet, ignores decreasing pressure);
Speed 0..1; Tilt-elevation 0°(flat)..90°(vertical); Tilt-direction 180°..+180° (azimuth);
Rotation; Fade (over brush-size lengths); Distance (px); Time (s).
**Opacity vs Flow** (multiply together since 4.2): Opacity = whole-stroke transparency
(clamped per stroke in *Wash* mode); Flow = per-dab transparency (in *Build-up* mode overlapping
dabs accumulate). Ink wants Flow=1 / Opacity=1 (solid); marker wants Flow≈0.5 build-up.
| Property | **Fountain pen** | **Ballpoint** |
|-----------------------|---------------------------------------------------|--------------------------------------------|
| Size sensor | Pressure (+ optional Tilt-elevation) | Pressure |
| Size curve | concave / ease-in, **γ ≈ 2 (≈ p²)** | nearly flat (constant) |
| Size output range | **0.15 → 1.0** of nominal | **0.90 → 1.0** (barely varies) |
| Opacity sensor | Pressure | Pressure |
| Opacity curve | slight concave γ ≈ 1.5 (or constant) | **linear γ ≈ 1**, range **0.6 → 1.0** |
| Flow | 1.0 | 1.0 |
| Tilt usage | Tilt-elevation → broaden Size; Tilt-direction → tip Rotation (calligraphic) | none |
| Net character | **strong pressure→width**, near-opaque, calligraphic edge | **near-constant width**, pressure→**opacity** (the ballpoint "tell") |
Optional ballpoint nicety: Speed→Opacity (faster = slightly lighter, mimics ink skipping).
---
## 4. perfect_freehand option sets + where it is insufficient
### What perfect_freehand actually computes (so the knobs are unambiguous)
**Source:** `getStrokeRadius.ts` — https://github.com/steveruizok/perfect-freehand
Per-point radius:
```
radius = size * easing( 0.5 - thinning * (0.5 - pressure) )
```
Default easing = identity (linear). Therefore:
- `p = 0 → radius = size * (0.5 - 0.5·thinning)`
- `p = 1 → radius = size * (0.5 + 0.5·thinning)`
- `p = 0.5 → radius = size * 0.5` (always)
⇒ PF's pressure→width is **strictly LINEAR** (rnote `Linear`), symmetric about `0.5·size`,
slope set by `thinning ∈ [-1,1]`. `size` = **diameter**. `streamline ∈ [0,1]` = EMA low-pass on
input positions. `smoothing ∈ [0,1]` = corner-softening on the **outline** polygon (not the
centerline). `simulatePressure:true` fakes pressure from velocity (slower = thicker).
**perfect_freehand Dart defaults:** `size=16, thinning=0.5, smoothing=0.5, streamline=0.5,
simulatePressure=true, isComplete=true, start.cap=true, end.cap=true, taperEnabled=false`.
Source: https://pub.dev/packages/perfect_freehand
### Getting rnote's quadratic out of PF: pre-warp the per-point pressure
PF is linear internally, but feed it warped pressure and the *width* curve becomes whatever you
want — **no custom polygon needed** for width-only brushes:
```dart
double warpPressure(double p, PressureMode m) => switch (m) {
PressureMode.linear => p,
PressureMode.quadratic => p * p, // rnote Pow2 — fountain pen
PressureMode.cubic => p * p * p, // rnote Pow3
PressureMode.sqrt => math.sqrt(p), // firm pen / pencil
};
// points.add(PointVector(x, y, warpPressure(rawPressure, mode)));
// StrokeOptions(thinning: ~0.9, simulatePressure: false);
```
### Where perfect_freehand is INSUFFICIENT → custom variable-width polygon (rnote-style)
| Need | PF enough? |
|---------------------------------------------------|-----------------------------------------------------|
| Linear pressure → width | ✅ via `thinning` |
| Quadratic / Sqrt pressure → width | ⚠️ pressure pre-warp (above), `thinning≈0.9`, `simulatePressure:false` |
| **Tilt → width or tip rotation** (calligraphy) | ❌ **custom polygon**: per point `w=f(pressure,tilt)`, normal `n=perp(tangent)`, emit `P ± n·w/2`, triangulate (rnote-style left/right offsetting) |
| Pressure → **opacity** (ballpoint/pencil/marker) | ❌ PF is geometry-only — render with per-segment / per-stroke alpha yourself |
| True spring-mass smoothing + latency prediction | ❌ PF `streamline` is only an EMA — port ink-stroke-modeler (§2b) or use §2a first |
| Per-point opacity along one stroke | ❌ split into short sub-strokes by pressure band, paint each with its own alpha |
### Concrete per-brush option sets (logical-px diameters; scale by zoom)
**Fountain pen** — strong pressure→width, soft taper, solid ink:
```dart
// per-point pressure pre-warped to p² (quadratic)
StrokeOptions(
size: 6.0, // tune 48
thinning: 0.9, // wide dynamic range
smoothing: 0.55,
streamline: 0.45, // smooth but responsive
simulatePressure: false,
start: StrokeEndOptions.start(taperEnabled: true, cap: true),
end: StrokeEndOptions.end(taperEnabled: true, cap: true),
);
// opacity = 1.0 (solid). Add Tilt → custom polygon only if you want calligraphic edge.
```
**Ballpoint** — near-constant width, pressure → opacity:
```dart
// raw per-point pressure (NOT warped); used for OPACITY, not width
StrokeOptions(
size: 2.2, // thin, fixed
thinning: 0.15, // almost no width variation
smoothing: 0.5,
streamline: 0.55, // ballpoints glide
simulatePressure: false,
);
// opacity = 0.55 + 0.45 * pressureAvg (per-stroke; or per-segment sub-strokes by pressure band)
```
**Highlighter** — flat width, translucent, build-up, blunt caps:
```dart
StrokeOptions(
size: 22.0, // broad
thinning: 0.0, // constant width
smoothing: 0.4,
streamline: 0.5,
simulatePressure: false,
start: StrokeEndOptions.start(cap: false), // square ends
end: StrokeEndOptions.end(cap: false),
);
// Paint: BlendMode.multiply (or .darken), color.withOpacity(0.35).
// Draw the WHOLE stroke once on pointer-up so self-overlap doesn't darken (Krita "Wash");
// cross-stroke overlap darkens via multiply = real marker.
```
**Pencil** — slight width + opacity from pressure, grainy:
```dart
// per-point pressure pre-warped to sqrt(p) (firm, quick-darkening)
StrokeOptions(
size: 3.0,
thinning: 0.5, // moderate width range
smoothing: 0.5,
streamline: 0.4, // scratchy -> less smoothing
simulatePressure: false, // if no real stylus pressure, set true for velocity-thinning
);
// opacity = 0.35 + 0.55 * pressure
// overlay a paper-noise texture via BlendMode.multiply for graphite grain (PF can't do texture)
```
### Krita-compatible, extensible brush model (recommended struct)
Mirror Krita's sensor→curve design, then translate to PF + your own opacity/compositing layer:
```
BrushProfile {
sizeBase, sizeSensor (pressure/tilt/speed), sizeCurve (power-law exponent or LUT), sizeRange (min,max),
opacitySensor, opacityCurve, opacityRange, flow,
tiltToWidth, tiltToRotation, // any of these -> custom variable-width polygon path
smoothingMode (catmullRom §2a | spring §2b),
pfThinning, pfStreamline, pfSmoothing, cap, taper, pressureWarp
}
```
- Width = `sizeBase · curve(sizeSensor)`; `curve` = power law for exact Krita/rnote parity (`p^n`).
- Width-only brushes (fountain/ballpoint/highlighter/pencil) go through PF via pressure pre-warp.
- Any brush with `tiltToWidth`/`tiltToRotation` switches to the custom variable-width polygon renderer.
- Opacity/flow are ALWAYS handled by your compositing layer, never by PF.
---
## Sources
- rnote `PressureCurve` + `apply`: `crates/rnote-compose/src/style/mod.rs` — https://github.com/flxzt/rnote
- rnote `Segment`: `crates/rnote-compose/src/penpath/segment.rs`
- rnote Catmull-Rom curved builder: `builders/penpathcurvedbuilder.rs`,
`shapes/cubbez.rs::new_w_catmull_rom` (`cp = P ± Δ/(6·tension)`, tension = 1.0)
- rnote modeled builder: `builders/penpathmodeledbuilder.rs` (wraps ink-stroke-modeler-rs)
- Google ink-stroke-modeler params (spring_mass = 11/32400, drag = 72.0, …):
https://github.com/google/ink-stroke-modeler/blob/main/ink_stroke_modeler/params.h and `params.cc`
- perfect-freehand radius `size·easing(0.5 thinning·(0.5 pressure))`: `getStrokeRadius.ts` —
https://github.com/steveruizok/perfect-freehand
- perfect_freehand Dart defaults: https://pub.dev/packages/perfect_freehand
- Krita sensors: https://docs.krita.org/en/reference_manual/brushes/brush_settings/tablet_sensors.html
- Krita opacity vs flow: https://docs.krita.org/en/reference_manual/brushes/brush_settings/opacity_and_flow.html
- Krita inking: https://docs.krita.org/en/tutorials/inking.html

View File

@@ -1,166 +1,13 @@
// integration_test/coordinate_assertion_test.dart
//
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
// this is a blocking gate.
//
// RUN (on a device/desktop with a display + working pdfium):
// flutter test integration_test/coordinate_assertion_test.dart
// or, on the Windows tablet via a driver:
// flutter drive --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
//
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
// resolve. If pdfium cannot render under the harness on a headless Linux box
// (no GL/surface), this test will time out at `_waitForReady`; that is an
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
// assertion logic below is correct and must not be weakened to force a pass.
// Coordinate gate previously used spike_editor_pane (retired).
// Re-run on Surface via docs/plans/surface-diagnostic-checklist.md
// once a PenEditorScreen-based harness is restored.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
void main() {
// Standard integration binding. This test drives zoom + reads geometry only;
// it does not inject pen events, so PenCaptureRegion stays transparent
// (currentPointerKind == null → never captures), which is exactly correct
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
// which the runner initializes first.)
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
pdfrxFlutterInitialize();
late File pdfFile;
setUpAll(() async {
pdfFile = await _writeTinyPdf();
});
tearDownAll(() async {
if (await pdfFile.exists()) await pdfFile.delete();
});
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
(tester) async {
final controller = PdfViewerController();
PdfDocument? readyDoc;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
pdfPath: pdfFile.path,
controller: controller,
onViewerReady: (doc, _) => readyDoc = doc,
),
),
),
);
// Wait for pdfrx to load + lay out the page.
final ready = await _waitForReady(tester, controller);
if (!ready) {
fail(
'pdfrx did not become ready (page layout unavailable). This is almost '
'certainly the headless-Linux pdfium limitation described in the file '
'header — run on the Windows tablet:\n'
' flutter drive --driver=test_driver/integration_test.dart '
'--target=integration_test/coordinate_assertion_test.dart',
);
}
expect(readyDoc, isNotNull);
// The page-center in DOCUMENT space is the layout rect center of page 1.
final pageRect = controller.layout.pageLayouts.first;
final pageCenterDoc = pageRect.center;
Future<void> assertCenterAtCurrentZoom(String label) async {
await tester.pumpAndSettle();
// Project the page-center document point to viewer-local (== screen,
// since the viewer fills the Scaffold body) coordinates.
final localCenter = controller.documentToLocal(pageCenterDoc);
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
// the page center within tolerance, proving the coordinate model maps
// normalized→document→screen consistently at this zoom.
final box = tester.renderObject<RenderBox>(
find.byType(SpikeEditorPane),
);
final globalCenter = box.localToGlobal(localCenter);
final roundTripDoc = controller.globalToDocument(globalCenter);
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
// Tolerance: 1 document unit (sub-pixel at these zooms).
expect(dx, lessThan(1.0),
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
expect(dy, lessThan(1.0),
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
}
// --- fit ---
await controller.goTo(
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
duration: Duration.zero,
);
await assertCenterAtCurrentZoom('fit');
final fitZoom = controller.currentZoom;
// --- 2x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
await assertCenterAtCurrentZoom('2x');
// --- 4x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
await assertCenterAtCurrentZoom('4x');
});
}
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
/// or the timeout elapses. Returns whether it became ready.
Future<bool> _waitForReady(
WidgetTester tester,
PdfViewerController controller, {
Duration timeout = const Duration(seconds: 20),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
return true;
}
}
return false;
}
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
Future<File> _writeTinyPdf() async {
final doc = sf.PdfDocument();
final page = doc.pages.add();
final size = page.getClientSize();
page.graphics.drawRectangle(
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
);
page.graphics.drawString(
'M1 coord test',
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
);
final bytes = await doc.save();
doc.dispose();
final file = File(
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
);
await file.writeAsBytes(bytes, flush: true);
return file;
testWidgets('coordinate_assertion retired with spike pane', (tester) async {
}, skip: true);
}

View File

@@ -1,245 +1,16 @@
// integration_test/perf_scroll_bench.dart
//
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
//
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
// time ≤ 16.6ms.
//
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
// are populated before sampling.
//
// RUN (profile mode, on the Windows tablet or a desktop with a display):
// flutter test --profile integration_test/perf_scroll_bench.dart
// or via the driver for on-device profiling:
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
// always read the numbers from a PROFILE run on the target device. On a
// headless Linux box pdfium may fail to render; if so the bench prints a clear
// skip and must be run on the tablet (see coordinate_assertion_test header).
// Spike-based bench retired with own-canvas architecture.
// See docs/plans/surface-diagnostic-checklist.md for device gates.
// Replacement bench will target PenEditorScreen + PageTileCache.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
const String _kPdfPath = 'test/assets/large_300p.pdf';
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
/// Frames to sample after warm-up.
const int _kSampleFrames = 120;
/// Frames to discard before sampling (cache warm-up, §7.1).
const int _kWarmupFrames = 30;
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Report raw frame timings to the device lab / driver too.
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
pdfrxFlutterInitialize();
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
final pdf = File(_kPdfPath);
if (!pdf.existsSync()) {
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
return;
}
// ---- MUST #4: pdfrx alone ----
final r4 = await _runScrollPass(
tester,
label: 'MUST #4 — pdfrx alone (no ink overlay)',
inkLoad: false,
);
// ---- MUST #5: WITH dense ink overlay ----
final r5 = await _runScrollPass(
tester,
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
inkLoad: true,
);
if (r4 == null || r5 == null) {
stdout.writeln(
'\n=== PERF BENCH SKIPPED ===\n'
'pdfrx did not become ready (headless pdfium limitation). Run on the '
'Windows tablet in profile mode:\n'
' flutter drive --profile '
'--driver=test_driver/integration_test.dart '
'--target=integration_test/perf_scroll_bench.dart\n',
);
return;
}
_printReport('MUST #4', r4, buildOnlyGate: false);
_printReport('MUST #5', r5, buildOnlyGate: true);
});
testWidgets('perf_scroll_bench retired — use Surface diagnostic checklist',
(tester) async {
// ignore: avoid_print
print('SKIP: spike_editor_pane deleted; run Surface checklist instead.');
}, skip: true);
}
class _Stats {
_Stats(this.label, this.build, this.raster, this.total);
final String label;
final _Series build;
final _Series raster;
final _Series total;
}
class _Series {
_Series(List<double> values)
: median = _pct(values, 50),
p95 = _pct(values, 95),
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
jankFrames = values.where((v) => v > 32.0).length,
n = values.length;
final double median;
final double p95;
final double worst;
final int jankFrames;
final int n;
static double _pct(List<double> v, int p) {
if (v.isEmpty) return 0;
final s = List<double>.from(v)..sort();
final i = ((p / 100.0) * (s.length - 1)).round();
return s[i.clamp(0, s.length - 1)];
}
}
/// Pumps the spike pane, warms up, then drives a sustained fling while
/// collecting FrameTiming. Returns null if pdfrx never became ready.
Future<_Stats?> _runScrollPass(
WidgetTester tester, {
required String label,
required bool inkLoad,
}) async {
final controller = PdfViewerController();
final paneKey = GlobalKey<SpikeEditorPaneState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
key: paneKey,
pdfPath: _kPdfPath,
controller: controller,
denseStrokesAsset: _kDenseStrokesAsset,
),
),
),
);
// Wait for the document to lay out.
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
}
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
return null;
}
if (inkLoad) {
await paneKey.currentState!.setInkLoad(true);
await tester.pumpAndSettle();
}
// Collect frame timings.
final build = <double>[];
final raster = <double>[];
final total = <double>[];
var seen = 0;
void onTimings(List<FrameTiming> timings) {
for (final t in timings) {
seen++;
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
if (build.length >= _kSampleFrames) continue;
build.add(t.buildDuration.inMicroseconds / 1000.0);
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
total.add(t.totalSpan.inMicroseconds / 1000.0);
}
}
SchedulerBinding.instance.addTimingsCallback(onTimings);
try {
// Sustained fling: repeated downward flings across the viewport center to
// keep the document scrolling continuously while we gather ≥150 frames.
final center = tester.getCenter(find.byType(SpikeEditorPane));
var safety = 0;
while (build.length < _kSampleFrames && safety < 400) {
await tester.fling(
find.byType(SpikeEditorPane),
const Offset(0, -600),
2000,
warnIfMissed: false,
);
// Pump several frames to let the fling settle and emit timings.
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
// Nudge back up occasionally so we don't run off the end of 300 pages.
if (safety % 8 == 7) {
await tester.fling(find.byType(SpikeEditorPane),
const Offset(0, 1200), 2000, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 16));
}
safety++;
// Keep `center` referenced (avoids unused warning) and re-target if needed.
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
}
} finally {
SchedulerBinding.instance.removeTimingsCallback(onTimings);
}
return _Stats(
label,
_Series(build),
_Series(raster),
_Series(total),
);
}
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
final buf = StringBuffer();
buf.writeln('\n========================================================');
buf.writeln('$tag${s.label}');
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
buf.writeln('--------------------------------------------------------');
buf.writeln('phase median p95 worst jank(>32ms)');
buf.writeln('build ${_row(s.build)}');
buf.writeln('raster ${_row(s.raster)}');
buf.writeln('total ${_row(s.total)}');
buf.writeln('--------------------------------------------------------');
if (buildOnlyGate) {
final pass = s.build.median <= 16.6;
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
} else {
final passMed = s.total.median <= 16.6;
final passP95 = s.total.p95 <= 22.0;
buf.writeln('GATE (MUST #4): build+raster median '
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
'${passMed ? "PASS" : "FAIL"}; '
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
'${passP95 ? "PASS" : "FAIL"}');
}
buf.writeln('========================================================\n');
stdout.write(buf.toString());
}
String _row(_Series s) =>
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
'${s.jankFrames.toString().padLeft(6)}';

7
l10n.yaml Normal file
View File

@@ -0,0 +1,7 @@
# Flutter gen-l10n config. Generates AppLocalizations from the ARB files in
# lib/l10n. `flutter pub get` / build runs the generator (pubspec `generate: true`).
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

View File

@@ -0,0 +1,164 @@
// Global structured logging bus for BadNote.
//
// Always-on (unlike the old PDF-only DiagnosticLogger opt-in). Writes NDJSON
// lines to a rotating session file under the app documents directory so a
// Surface user can export a diagnostic pack without attaching a debugger.
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
enum LogLevel { trace, debug, info, warn, error }
/// Known subsystems — keep the set small so filters stay useful.
abstract final class LogSubsystem {
static const shell = 'shell';
static const ink = 'ink';
static const arbiter = 'arbiter';
static const penNative = 'pen_native';
static const pdf = 'pdf';
static const office = 'office';
static const board = 'board';
static const sync = 'sync';
static const diag = 'diag';
static const frame = 'frame';
}
class BadNoteLog {
BadNoteLog._();
static final BadNoteLog instance = BadNoteLog._();
final String sessionId = const Uuid().v4();
final List<Map<String, Object?>> _ring = <Map<String, Object?>>[];
static const int _ringCap = 4000;
File? _file;
Directory? _dir;
Timer? _flushTimer;
final List<String> _pending = <String>[];
bool _started = false;
LogLevel minLevel = LogLevel.debug;
/// Absolute path of the current session log, once [start] succeeds.
String? get path => _file?.path;
Directory? get directory => _dir;
Future<void> start() async {
if (_started) return;
_started = true;
try {
Directory base;
try {
base = await getApplicationDocumentsDirectory();
} catch (_) {
base = await getTemporaryDirectory();
}
_dir = Directory(
'${base.path}${Platform.pathSeparator}badnote_diagnostics',
);
if (!await _dir!.exists()) {
await _dir!.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
_file = File(
'${_dir!.path}${Platform.pathSeparator}session_$stamp.ndjson',
);
await _file!.writeAsString(
'${jsonEncode({
'ts': DateTime.now().toIso8601String(),
'level': 'info',
'subsystem': LogSubsystem.diag,
'msg': 'session_start',
'sessionId': sessionId,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
})}\n',
flush: true,
);
_flushTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
info(LogSubsystem.diag, 'log file ready', fields: {'path': _file!.path});
} catch (e) {
// Logging must never crash the app.
developer.log('BadNoteLog start failed: $e', name: 'badnote');
}
}
void trace(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.trace, subsystem, msg, fields);
void debug(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.debug, subsystem, msg, fields);
void info(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.info, subsystem, msg, fields);
void warn(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.warn, subsystem, msg, fields);
void error(String subsystem, String msg, {Map<String, Object?>? fields}) =>
_emit(LogLevel.error, subsystem, msg, fields);
void _emit(
LogLevel level,
String subsystem,
String msg,
Map<String, Object?>? fields,
) {
if (level.index < minLevel.index) return;
final entry = <String, Object?>{
'ts': DateTime.now().toIso8601String(),
'level': level.name,
'subsystem': subsystem,
'msg': msg,
'sessionId': sessionId,
if (fields != null) ...fields,
};
_ring.add(entry);
if (_ring.length > _ringCap) {
_ring.removeRange(0, _ring.length - _ringCap);
}
final line = jsonEncode(entry);
developer.log(line, name: 'badnote.$subsystem');
if (_file != null) {
_pending.add(line);
if (_pending.length >= 200) {
unawaited(_flush());
}
}
}
Future<void> _flush() async {
final file = _file;
if (file == null || _pending.isEmpty) return;
final chunk = '${_pending.join('\n')}\n';
_pending.clear();
try {
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
} catch (_) {}
}
/// Snapshot of the in-memory ring (newest last).
List<Map<String, Object?>> snapshotRing() =>
List<Map<String, Object?>>.unmodifiable(_ring);
Future<void> flush() => _flush();
Future<void> stop() async {
_flushTimer?.cancel();
_flushTimer = null;
await _flush();
}
}
/// Bridge for legacy call sites that still use plain strings.
void logLegacyInputLine(String line) {
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'badnote_log.dart';
import 'diagnostic_export.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/diagnostic_logger.dart';
import '../editor/input/pen_input_service.dart';
import '../l10n/app_localizations.dart';
/// Shared diagnostics chrome: overlay readout + export action.
/// Mount on any document surface (note / PDF / PPT / board).
class DiagnosticChrome extends StatefulWidget {
const DiagnosticChrome({
super.key,
required this.child,
this.initiallyVisible = false,
});
final Widget child;
final bool initiallyVisible;
@override
State<DiagnosticChrome> createState() => DiagnosticChromeState();
}
class DiagnosticChromeState extends State<DiagnosticChrome> {
late bool _visible = widget.initiallyVisible;
bool _exporting = false;
String? _lastExportPath;
bool get isVisible => _visible;
void toggle() {
setState(() {
_visible = !_visible;
if (_visible) {
DiagnosticLogger.instance.start();
InputDiagnostics.instance.reset();
} else {
DiagnosticLogger.instance.stop();
}
});
}
Future<void> exportPack() async {
if (_exporting) return;
setState(() => _exporting = true);
try {
final result = await DiagnosticExport.instance.exportPack();
if (!mounted) return;
setState(() => _lastExportPath = result.zipPath);
await Clipboard.setData(ClipboardData(text: result.zipPath));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).diagExported(result.bytes),
),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
BadNoteLog.instance.error(LogSubsystem.diag, 'export_failed', fields: {
'error': '$e',
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).diagExportFail('$e')),
),
);
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
widget.child,
if (_visible)
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
color: Colors.black.withValues(alpha: 0.82),
child: Padding(
padding: const EdgeInsets.all(10),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFamily: 'monospace',
height: 1.35,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListenableBuilder(
listenable: InputDiagnostics.instance,
builder: (context, _) {
return Text(
'${InputDiagnostics.instance.summary()}\n'
'${PenInputService.instance.debugSummary}\n'
'log: ${BadNoteLog.instance.path ?? "(starting…)"}\n'
'session: ${BadNoteLog.instance.sessionId}'
'${_lastExportPath != null ? "\nlast zip: $_lastExportPath" : ""}',
);
},
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () => InputDiagnostics.instance.reset(),
child: const Text('Reset',
style: TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: _exporting ? null : exportPack,
child: Text(
_exporting ? 'Exporting…' : 'Export pack',
style: const TextStyle(color: Colors.lightGreenAccent),
),
),
TextButton(
onPressed: toggle,
child: const Text('Hide',
style: TextStyle(color: Colors.white54)),
),
],
),
],
),
),
),
),
),
],
);
}
}
/// Compact icon button for app bars / toolbars.
class DiagnosticToggleButton extends StatelessWidget {
const DiagnosticToggleButton({
super.key,
required this.onToggle,
required this.onExport,
});
final VoidCallback onToggle;
final VoidCallback onExport;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
tooltip: 'Diagnostics',
icon: const Icon(Icons.bug_report_outlined),
onSelected: (v) {
if (v == 'toggle') onToggle();
if (v == 'export') onExport();
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'toggle', child: Text('Toggle overlay')),
PopupMenuItem(value: 'export', child: Text('Export diagnostic pack')),
],
);
}
}

View File

@@ -0,0 +1,154 @@
// Build a zip diagnostic pack the user can hand back for remote debugging.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../editor/canvas/input_diagnostics.dart';
import '../editor/input/pen_input_service.dart';
import 'badnote_log.dart';
import 'frame_sampler.dart';
import 'pen_event_ring.dart';
/// Injected at build/export time so Surface packages can be matched to git.
/// Override via `--dart-define=BADNOTE_GIT_SHA=...` in CI.
const String kBadNoteGitSha = String.fromEnvironment(
'BADNOTE_GIT_SHA',
defaultValue: 'dev',
);
const String kBadNoteBuildTime = String.fromEnvironment(
'BADNOTE_BUILD_TIME',
defaultValue: '',
);
class DiagnosticExportResult {
DiagnosticExportResult({required this.zipPath, required this.bytes});
final String zipPath;
final int bytes;
}
class DiagnosticExport {
DiagnosticExport._();
static final DiagnosticExport instance = DiagnosticExport._();
/// Flush logs and write a zip under Documents/badnote_diagnostics/.
Future<DiagnosticExportResult> exportPack({
Duration penWindow = const Duration(minutes: 5),
}) async {
final log = BadNoteLog.instance;
await log.flush();
final meta = <String, Object?>{
'exportedAt': DateTime.now().toIso8601String(),
'sessionId': log.sessionId,
'gitSha': kBadNoteGitSha,
'buildTime': kBadNoteBuildTime.isEmpty ? null : kBadNoteBuildTime,
'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion,
'localHostname': Platform.localHostname,
'numberOfProcessors': Platform.numberOfProcessors,
'flutter': {
'foundationDebug': kDebugMode,
'foundationProfile': kProfileMode,
'foundationRelease': kReleaseMode,
},
'penNative': PenInputService.instance.debugSummary,
'penActive': PenInputService.instance.isActive,
'zoom': InputDiagnostics.instance.summary(),
'frames': FrameSampler.instance.summary(),
'instructions':
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip. '
'Confirm meta.gitSha matches the CI commit you installed.',
};
final archive = Archive();
void addText(String name, String body) {
final bytes = utf8.encode(body);
archive.addFile(ArchiveFile(name, bytes.length, bytes));
}
addText('meta.json', const JsonEncoder.withIndent(' ').convert(meta));
addText(
'pen_events.json',
const JsonEncoder.withIndent(' ').convert(
PenEventRing.instance.toJsonList(window: penWindow),
),
);
addText(
'frame_samples.json',
const JsonEncoder.withIndent(' ').convert(FrameSampler.instance.toJsonList()),
);
addText(
'log_ring.json',
const JsonEncoder.withIndent(' ').convert(log.snapshotRing()),
);
// Include on-disk session NDJSON if present.
final sessionPath = log.path;
if (sessionPath != null) {
try {
final f = File(sessionPath);
if (await f.exists()) {
final bytes = await f.readAsBytes();
archive.addFile(
ArchiveFile('session.ndjson', bytes.length, bytes),
);
}
} catch (_) {}
}
// Legacy input log if it exists alongside.
try {
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
} catch (_) {
dir = await getTemporaryDirectory();
}
final legacy = File(
'${dir.path}${Platform.pathSeparator}badnote_input_log.txt',
);
if (await legacy.exists()) {
final bytes = await legacy.readAsBytes();
archive.addFile(
ArchiveFile('legacy_input_log.txt', bytes.length, bytes),
);
}
} catch (_) {}
final encoded = ZipEncoder().encode(archive);
if (encoded.isEmpty) {
throw StateError('Failed to encode diagnostic zip');
}
Directory outDir = log.directory ??
Directory(
'${(await getApplicationDocumentsDirectory()).path}'
'${Platform.pathSeparator}badnote_diagnostics',
);
if (!await outDir.exists()) {
await outDir.create(recursive: true);
}
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final zipPath =
'${outDir.path}${Platform.pathSeparator}badnote_diag_$stamp.zip';
await File(zipPath).writeAsBytes(encoded, flush: true);
BadNoteLog.instance.info(
LogSubsystem.diag,
'export_pack',
fields: {'path': zipPath, 'bytes': encoded.length},
);
return DiagnosticExportResult(zipPath: zipPath, bytes: encoded.length);
}
}

View File

@@ -0,0 +1,99 @@
// Frame / hitch sampler for diagnostic packs.
import 'badnote_log.dart';
class FrameSample {
FrameSample({
required this.at,
required this.label,
required this.ms,
this.dropped = false,
});
final DateTime at;
final String label;
final double ms;
final bool dropped;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'label': label,
'ms': ms,
'dropped': dropped,
};
}
class FrameSampler {
FrameSampler._();
static final FrameSampler instance = FrameSampler._();
static const int capacity = 500;
final List<FrameSample> _samples = <FrameSample>[];
int overBudget = 0;
int total = 0;
/// Budget for a single frame at 60fps.
static const double budgetMs = 16.6;
void record(String label, double ms, {bool dropped = false}) {
total++;
final over = ms > budgetMs;
if (over) overBudget++;
final sample = FrameSample(
at: DateTime.now(),
label: label,
ms: ms,
dropped: dropped || over,
);
_samples.add(sample);
if (_samples.length > capacity) {
_samples.removeRange(0, _samples.length - capacity);
}
if (over || dropped) {
BadNoteLog.instance.warn(
LogSubsystem.frame,
'slow_frame',
fields: {'label': label, 'ms': ms, 'dropped': dropped},
);
}
}
void recordZoom({
required double rawScale,
required bool scaleDrop,
required bool focalDrop,
required double focalJumpPx,
}) {
record(
'zoom',
scaleDrop || focalDrop ? budgetMs + 1 : 8,
dropped: scaleDrop || focalDrop,
);
BadNoteLog.instance.debug(
LogSubsystem.frame,
'zoom',
fields: {
'rawScale': rawScale,
'scaleDrop': scaleDrop,
'focalDrop': focalDrop,
'focalJumpPx': focalJumpPx,
},
);
}
List<Map<String, Object?>> toJsonList() =>
_samples.map((s) => s.toJson()).toList(growable: false);
Map<String, Object?> summary() => {
'total': total,
'overBudget': overBudget,
'budgetMs': budgetMs,
'recent': toJsonList(),
};
void reset() {
_samples.clear();
overBudget = 0;
total = 0;
}
}

View File

@@ -0,0 +1,121 @@
// Rolling ring of recent pen / arbiter events for diagnostic export.
class PenEventRecord {
PenEventRecord({
required this.at,
required this.kind,
required this.pointerId,
this.pressure,
this.tiltX,
this.tiltY,
this.barrel = false,
this.eraser = false,
this.inverted = false,
this.decision,
this.note,
});
final DateTime at;
final String kind; // down|move|up|hw|arbiter
final int pointerId;
final double? pressure;
final double? tiltX;
final double? tiltY;
final bool barrel;
final bool eraser;
final bool inverted;
final String? decision; // draw|pan|reject
final String? note;
Map<String, Object?> toJson() => {
'at': at.toIso8601String(),
'kind': kind,
'pointerId': pointerId,
if (pressure != null) 'pressure': pressure,
if (tiltX != null) 'tiltX': tiltX,
if (tiltY != null) 'tiltY': tiltY,
'barrel': barrel,
'eraser': eraser,
'inverted': inverted,
if (decision != null) 'decision': decision,
if (note != null) 'note': note,
};
}
class PenEventRing {
PenEventRing._();
static final PenEventRing instance = PenEventRing._();
static const int capacity = 2000;
final List<PenEventRecord> _events = <PenEventRecord>[];
void add(PenEventRecord event) {
_events.add(event);
if (_events.length > capacity) {
_events.removeRange(0, _events.length - capacity);
}
}
void recordPointer({
required String kind,
required int pointerId,
required String deviceKind,
double? pressure,
String? decision,
String? note,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: kind,
pointerId: pointerId,
pressure: pressure,
decision: decision,
note: note ?? deviceKind,
));
}
void recordHardware({
required bool barrel,
required bool eraser,
required bool inverted,
required double tiltX,
required double tiltY,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'hw',
pointerId: -1,
barrel: barrel,
eraser: eraser,
inverted: inverted,
tiltX: tiltX,
tiltY: tiltY,
));
}
void recordArbiter({
required int activeCount,
required String deviceKind,
required bool draw,
required bool fingerDrawing,
}) {
add(PenEventRecord(
at: DateTime.now(),
kind: 'arbiter',
pointerId: -1,
decision: draw ? 'draw' : 'pan',
note: 'count=$activeCount kind=$deviceKind finger=$fingerDrawing',
));
}
List<PenEventRecord> recent({Duration? window}) {
if (window == null) return List.unmodifiable(_events);
final cut = DateTime.now().subtract(window);
return _events.where((e) => e.at.isAfter(cut)).toList(growable: false);
}
List<Map<String, Object?>> toJsonList({Duration? window}) =>
recent(window: window).map((e) => e.toJson()).toList(growable: false);
void clear() => _events.clear();
}

View File

@@ -0,0 +1,61 @@
// lib/editor/canvas/editor_tool.dart
//
// The shared tool model for the pen-first editors (PDF, note, slide). Replaces
// the scattered per-editor booleans (`_selectTextMode`, `_placeLinkMode`, the
// old `CanvasTool` pen/highlighter/eraser triad) with ONE active-tool enum so
// every editor reasons about "which tool is active" the same way.
//
// [EditorToolKind] is the core, render-path-independent set shared by all three
// editors. The PDF editor layers TWO extra page-anchored tools on top
// (select-text and place-scratch-link) that the PenCanvas editors don't have —
// those remain editor-local because they ride pdfrx's text layer / the page
// overlay, not the ink capture path. See `selectTextOrLink` note below.
//
// TODO(toolbar-batch-2): bookmark-to-paragraph, search+OCR, templates — later
// batches add kinds here. (The typed-text tool now exists as [EditorToolKind.
// text], PDF-only for now; see the `text` doc below.)
/// The shared inking/editing tools available on every pen-first canvas.
enum EditorToolKind {
/// Freehand drawing with the currently-selected [BrushKind] (fountain pen,
/// ballpoint, or pencil). Each brush carries its own remembered color.
brush,
/// Freehand drawing with the highlighter brush (its own color + flat width).
highlighter,
/// Stroke eraser (partial / whole-stroke per PenConfig).
eraser,
/// Cursor / selection tool: tap a committed stroke to select it, drag the
/// selection to translate it, delete to remove it.
select,
/// Shape tool: pen-drag previews a [ShapeKind] from start→current and commits
/// it as a generated [PenStroke] on release.
shape,
/// Typed-text tool (PDF editor only for now): a pen-tap OR a mouse
/// double-click on a page drops a text box at that normalized point and
/// focuses a real Flutter text field for input (so the OS IME / Windows-Ink
/// handwriting panel works). Committed boxes render glued to the page and are
/// re-editable; an empty box deletes itself on blur.
text,
}
/// The shapes the [EditorToolKind.shape] tool can draw. Each is generated as a
/// plain [PenStroke] (a polyline) so it reuses stroke rendering, persistence,
/// erase, and undo with no new model — see `shape_geometry.dart`.
enum ShapeKind {
/// Straight line: 2 points (start → end).
line,
/// Axis-aligned rectangle: 5-point closed polyline (start corner → end corner).
rectangle,
/// Ellipse inscribed in the start→end bounding box: ~48 sampled points.
ellipse,
/// Arrow: shaft (start → end) plus two arrowhead segments at the end.
arrow,
}

View File

@@ -8,8 +8,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import '../engine/brush.dart';
import '../engine/stroke_geometry.dart'
show kDefaultPenThinning, kPenSmoothing, kPenStreamline;
show freehandOutlinePoints, kDefaultPenThinning;
import 'pen_stroke.dart';
/// Builds a filled outline [Path] for one stroke (already scaled to pixels).
@@ -39,22 +40,19 @@ Path buildStrokePath(
)
.toList();
final outline = pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: pixelWidth,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so real Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure → don't simulate; no pressure → let freehand fake
// it based on velocity. (perfect_freehand 2.x honors REAL pressure when
// simulatePressure is false — 1.0.4 ignored it, which made width
// unresponsive to pen force.)
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
// Route through THE shared recipe (stroke_geometry.freehandOutlinePoints) so
// this PDF-overlay path and the note/slide path can never diverge (R7), and
// resolve the stroke's brush so each brush renders with its own
// thinning/streamline/smoothing/caps (spec §4). Pressure was already
// pre-warped by the brush's gamma at capture, so it is baked into pfPoints.
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: pixelWidth,
isHighlighter: isHighlighter,
hasRealPressure: hasRealPressure,
isComplete: isComplete,
thinning: thinning,
brush: brushProfileFor(stroke.brush),
);
final path = Path();
@@ -67,6 +65,34 @@ Path buildStrokePath(
return path;
}
/// Mean point pressure (`pressure ?? 0.5`) of a [PenStroke], for the per-stroke
/// opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to pressure).
double _avgPressure(PenStroke stroke) {
if (stroke.points.isEmpty) return 0.5;
var sum = 0.0;
for (final p in stroke.points) {
sum += p.pressure ?? 0.5;
}
return sum / stroke.points.length;
}
/// THE single fill [Paint] for a committed/live stroke, with the brush's
/// resolved opacity (multiplied into the color's alpha) and blend mode applied
/// — closes TODO(brush-opacity). Shared by [StaticInkPainter]/[LiveInkPainter]
/// and the PDF overlay painter so both render paths composite identically.
Paint paintForStroke(PenStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}
/// Paints all committed strokes for the page. Repaints only when the stroke
/// list identity or page size changes (kept behind a RepaintBoundary).
class StaticInkPainter extends CustomPainter {
@@ -88,13 +114,9 @@ class StaticInkPainter extends CustomPainter {
final path =
buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
// Single drawPath per stroke ⇒ a highlighter's own self-overlap never
// darkens; cross-stroke overlap darkens via BlendMode.multiply (marker).
canvas.drawPath(path, paintForStroke(stroke));
}
}
@@ -201,6 +223,52 @@ class EraserPreviewPainter extends CustomPainter {
old.pageSize != pageSize;
}
/// Paints the SELECT tool's selection: a dashed-ish bounding box around the
/// selected stroke(s) so the user sees what is selected and draggable. The box
/// is given in normalized page coords and scaled to pixels at paint time.
class SelectionOverlayPainter extends CustomPainter {
SelectionOverlayPainter({
required this.boundsNorm,
required this.pageSize,
});
/// Selection bounding box in normalized page coords (null = nothing selected).
final Rect? boundsNorm;
final Size pageSize;
@override
void paint(Canvas canvas, Size size) {
final b = boundsNorm;
if (b == null) return;
// Inflate slightly so the box doesn't clip the stroke's rendered width.
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * pageSize.width - padPx,
b.top * pageSize.height - padPx,
b.right * pageSize.width + padPx,
b.bottom * pageSize.height + padPx,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true,
);
}
@override
bool shouldRepaint(SelectionOverlayPainter old) =>
old.boundsNorm != boundsNorm || old.pageSize != pageSize;
}
/// Paints just the in-progress stroke (the live layer), kept behind its own
/// RepaintBoundary so committed strokes don't repaint on every move.
class LiveInkPainter extends CustomPainter {
@@ -224,13 +292,7 @@ class LiveInkPainter extends CustomPainter {
final path =
buildStrokePath(s, pageSize, isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(
path,
Paint()
..color = Color(s.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
canvas.drawPath(path, paintForStroke(s));
}
@override

View File

@@ -8,6 +8,7 @@
import 'package:flutter/foundation.dart';
import '../../diagnostics/frame_sampler.dart';
import '../input/diagnostic_logger.dart';
class InputDiagnostics extends ChangeNotifier {
@@ -15,7 +16,8 @@ class InputDiagnostics extends ChangeNotifier {
static final InputDiagnostics instance = InputDiagnostics._();
int frames = 0;
int scaleDropped = 0; // frames rejected as a scale glitch
int scaleDropped = 0; // frames HARD-rejected (legacy; prefer soft-clamp)
int softClamped = 0; // frames whose step was soft-clamped (still applied)
int focalDropped = 0; // frames rejected as a focal/position glitch
int rebaselines = 0; // pointer-count re-baselines
int pointerCountMax = 0;
@@ -41,13 +43,16 @@ class InputDiagnostics extends ChangeNotifier {
required double focalJumpPx,
required bool scaleDrop,
required bool focalDrop,
bool softClamped = false,
double? liveScale,
}) {
frames++;
if (scaleDrop) scaleDropped++;
if (softClamped) this.softClamped++;
if (focalDrop) focalDropped++;
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
final double resulting = currentScale * appliedChange;
final double resulting = currentScale;
if (resulting < scaleMin) scaleMin = resulting;
if (resulting > scaleMax) scaleMax = resulting;
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
@@ -55,19 +60,31 @@ class InputDiagnostics extends ChangeNotifier {
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
final liveBit = liveScale == null
? ''
: ' live=${liveScale.toStringAsFixed(3)}';
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
'ch=${appliedChange.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)}$liveBit '
'fj=${focalJumpPx.toStringAsFixed(0)}'
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
'${softClamped ? " SCLAMP" : ""}'
'${scaleDrop ? " SDROP" : ""}'
'${focalDrop ? " FDROP" : ""}';
_trace.add(line);
if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom(
rawScale: rawScale,
scaleDrop: scaleDrop || softClamped,
focalDrop: focalDrop,
focalJumpPx: focalJumpPx,
);
DiagnosticLogger.instance.log('ZOOM $line');
notifyListeners();
}
void reset() {
frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0;
frames = scaleDropped = softClamped = focalDropped = rebaselines =
pointerCountMax = 0;
rawScaleMin = scaleMin = double.infinity;
rawScaleMax = scaleMax = 0;
maxFocalJumpPx = 0;
@@ -76,15 +93,15 @@ class InputDiagnostics extends ChangeNotifier {
notifyListeners();
}
String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-';
String summary() {
if (frames == 0) return 'zoom: (pinch to record)';
return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped '
'rebase=$rebaselines pMax=$pointerCountMax\n'
' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} '
'scale=${_f(scaleMin)}..${_f(scaleMax)}\n'
final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
return 'zoom f=$frames sDrop=$scaleDropped sClamp=$softClamped '
'fDrop=$focalDropped rebase=$rebaselines pMax=$pointerCountMax\n'
' raw=$rawLo..$rawHi scale=$scLo..$scHi\n'
' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px '
'maxScaleJump=${_f(maxAppliedScaleJump)}';
'maxScaleJump=${maxAppliedScaleJump.toStringAsFixed(2)}';
}
}

View File

@@ -0,0 +1,160 @@
// lib/editor/canvas/note_background.dart
//
// rnote-style page background TEMPLATES for the blank-note editor. A background
// is a repeating PATTERN painted in the note page's local pixel space (the
// `pageWidget` is sized to the page rect inside the InteractiveViewer, so a
// CustomPainter here scales 1:1 with zoom — no extra transform needed).
//
// The choice is per-notebook and persists in the sidecar (stored as the enum
// `name`; missing/unknown → [NoteBackground.blank] for back-compat).
import 'package:flutter/material.dart';
/// The available page-background templates (rnote: blank + dots/lines/grid +
/// the Cornell note layout).
enum NoteBackground {
/// Plain white sheet, no pattern.
blank,
/// A regular grid of small dots (dotted paper).
dots,
/// Evenly spaced horizontal lines (ruled / lined paper).
ruled,
/// Square grid (graph paper).
grid,
/// Cornell layout: a left cue-column line + a bottom summary line over a
/// ruled note-taking body.
cornell,
}
/// Decode a persisted background name (the enum [NoteBackground.name]); unknown
/// or missing values fall back to [NoteBackground.blank] (back-compat).
NoteBackground noteBackgroundFromName(String? name) {
for (final b in NoteBackground.values) {
if (b.name == name) return b;
}
return NoteBackground.blank;
}
/// Localized-ish English display label for the picker menu.
String noteBackgroundLabel(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return 'Blank';
case NoteBackground.dots:
return 'Dots';
case NoteBackground.ruled:
return 'Ruled lines';
case NoteBackground.grid:
return 'Grid';
case NoteBackground.cornell:
return 'Cornell';
}
}
/// An icon for the picker menu.
IconData noteBackgroundIcon(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return Icons.crop_portrait;
case NoteBackground.dots:
return Icons.grain;
case NoteBackground.ruled:
return Icons.notes;
case NoteBackground.grid:
return Icons.grid_4x4;
case NoteBackground.cornell:
return Icons.view_quilt_outlined;
}
}
/// Paints a [NoteBackground] template behind the ink, in the page's local pixel
/// space. Spacing is page-relative (a fraction of page width) so the template
/// looks the same on any logical page size, and the lines are a light, subtle
/// grey so they sit behind handwriting.
class NoteBackgroundPainter extends CustomPainter {
const NoteBackgroundPainter(this.background);
final NoteBackground background;
/// Pattern spacing as a fraction of the page WIDTH — a ~28-line page.
static const double _spacingFraction = 1 / 28;
static const Color _lineColor = Color(0x1A000000); // ~10% black, subtle grey.
static const Color _dotColor = Color(0x33000000); // dots a touch darker.
static const Color _accentColor = Color(0x33335C81); // Cornell margin lines.
@override
void paint(Canvas canvas, Size size) {
if (background == NoteBackground.blank) return;
final spacing = size.width * _spacingFraction;
if (spacing <= 0) return;
switch (background) {
case NoteBackground.blank:
break;
case NoteBackground.dots:
_paintDots(canvas, size, spacing);
case NoteBackground.ruled:
_paintRuled(canvas, size, spacing);
case NoteBackground.grid:
_paintGrid(canvas, size, spacing);
case NoteBackground.cornell:
_paintCornell(canvas, size, spacing);
}
}
void _paintDots(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _dotColor
..style = PaintingStyle.fill;
final r = (spacing * 0.06).clamp(0.6, 2.0);
for (double y = spacing; y < size.height; y += spacing) {
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawCircle(Offset(x, y), r, paint);
}
}
}
void _paintRuled(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
void _paintGrid(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
}
void _paintCornell(Canvas canvas, Size size, double spacing) {
// Ruled body lines.
_paintRuled(canvas, size, spacing);
final accent = Paint()
..color = _accentColor
..strokeWidth = 1.4;
// Left cue-column vertical line (~25% of width).
final cueX = size.width * 0.25;
// Bottom summary horizontal line (~80% down).
final summaryY = size.height * 0.80;
canvas.drawLine(Offset(cueX, 0), Offset(cueX, summaryY), accent);
canvas.drawLine(Offset(0, summaryY), Offset(size.width, summaryY), accent);
}
@override
bool shouldRepaint(covariant NoteBackgroundPainter oldDelegate) =>
oldDelegate.background != background;
}

View File

@@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../../services/office/docx_parser.dart';
import '../../services/office/office_document.dart';
import '../../services/office/pptx_parser.dart';
import '../../theme/app_theme.dart';
import '../ui/page_nav_shortcuts.dart';
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
class OfficeDocumentScreen extends StatefulWidget {
const OfficeDocumentScreen({
super.key,
required this.filePath,
});
final String filePath;
@override
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
}
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
bool _loading = true;
String? _error;
ParsedPptx? _pptx;
ParsedDocx? _docx;
int _pageIndex = 0;
final List<_InkStroke> _strokes = [];
_InkStroke? _live;
final TransformationController _transform = TransformationController();
String get _sidecarPath => '${widget.filePath}.badnote.json';
@override
void initState() {
super.initState();
_open();
}
@override
void dispose() {
_transform.dispose();
super.dispose();
}
Future<void> _open() async {
final ext = p.extension(widget.filePath).toLowerCase();
try {
if (ext == '.pptx' || ext == '.ppt') {
_pptx = await PptxParser().parse(widget.filePath);
} else if (ext == '.docx') {
_docx = await DocxParser().parse(widget.filePath);
} else {
throw StateError('Unsupported: $ext');
}
await _loadSidecar();
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
'path': widget.filePath,
'pages': pageCount,
});
} catch (e) {
_error = '$e';
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
'error': '$e',
});
}
if (mounted) setState(() => _loading = false);
}
int get pageCount {
if (_pptx != null) return _pptx!.slides.length;
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
return 0;
}
Future<void> _loadSidecar() async {
final f = File(_sidecarPath);
if (!await f.exists()) return;
try {
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final pages = json['pages'] as Map<String, dynamic>? ?? {};
final key = '$_pageIndex';
final list = pages[key] as List<dynamic>? ?? [];
_strokes
..clear()
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
} catch (_) {}
}
Future<void> _saveSidecar() async {
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
final f = File(_sidecarPath);
if (await f.exists()) {
try {
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {}
}
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
root['pages'] = pages;
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
}
Future<void> _goPage(int i) async {
await _saveSidecar();
setState(() {
_pageIndex = i.clamp(0, pageCount - 1);
_strokes.clear();
_live = null;
});
await _loadSidecar();
if (mounted) setState(() {});
}
void _onPointerDown(PointerDownEvent e) {
if (e.kind != ui.PointerDeviceKind.stylus &&
e.kind != ui.PointerDeviceKind.invertedStylus &&
e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final local = _toScene(e.localPosition);
_live = _InkStroke(points: [local], pressures: [e.pressure]);
PenEventRing.instance.recordPointer(
kind: 'down',
pointerId: e.pointer,
deviceKind: e.kind.name,
pressure: e.pressure,
decision: 'draw',
);
setState(() {});
}
void _onPointerMove(PointerMoveEvent e) {
final live = _live;
if (live == null) return;
live.points.add(_toScene(e.localPosition));
live.pressures.add(e.pressure);
setState(() {});
}
void _onPointerUp(PointerUpEvent e) {
final live = _live;
if (live == null) return;
setState(() {
_strokes.add(live);
_live = null;
});
unawaited(_saveSidecar());
}
Offset _toScene(Offset local) {
final inv = Matrix4.inverted(_transform.value);
return MatrixUtils.transformPoint(inv, local);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(p.basename(widget.filePath))),
body: Center(child: Text(_error!)),
);
}
return pageNavShortcuts(
onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
onNext:
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
onFirst: pageCount > 0 ? () => _goPage(0) : null,
onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null,
child: Scaffold(
appBar: AppBar(
title: Text(p.basename(widget.filePath)),
actions: [
IconButton(
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
icon: const Icon(Icons.chevron_left),
),
Center(child: Text('${_pageIndex + 1} / $pageCount')),
IconButton(
onPressed: _pageIndex < pageCount - 1
? () => _goPage(_pageIndex + 1)
: null,
icon: const Icon(Icons.chevron_right),
),
],
),
body: InteractiveViewer(
transformationController: _transform,
minScale: 0.5,
maxScale: 4,
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(
painter: _OfficePagePainter(
pptx: _pptx,
docx: _docx,
pageIndex: _pageIndex,
strokes: _strokes,
live: _live,
),
size: _pageSize,
),
),
),
),
);
}
Size get _pageSize {
if (_pptx != null && _pptx!.slides.isNotEmpty) {
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
return Size(s.width, s.height);
}
return const Size(800, 1100);
}
}
class _InkStroke {
_InkStroke({required this.points, required this.pressures});
final List<Offset> points;
final List<double> pressures;
Map<String, dynamic> toJson() => {
'points': [
for (final p in points) {'x': p.dx, 'y': p.dy},
],
'pressures': pressures,
};
factory _InkStroke.fromJson(Map<String, dynamic> json) {
final pts = (json['points'] as List<dynamic>)
.map((e) => Offset(
(e['x'] as num).toDouble(),
(e['y'] as num).toDouble(),
))
.toList();
final pr = (json['pressures'] as List<dynamic>?)
?.map((e) => (e as num).toDouble())
.toList() ??
List.filled(pts.length, 0.5);
return _InkStroke(points: pts, pressures: pr);
}
}
class _OfficePagePainter extends CustomPainter {
_OfficePagePainter({
required this.pptx,
required this.docx,
required this.pageIndex,
required this.strokes,
required this.live,
});
final ParsedPptx? pptx;
final ParsedDocx? docx;
final int pageIndex;
final List<_InkStroke> strokes;
final _InkStroke? live;
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = AppTokens.paper;
canvas.drawRect(Offset.zero & size, bg);
if (pptx != null && pptx!.slides.isNotEmpty) {
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
final border = Paint()
..color = AppTokens.rule
..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
for (final run in slide.runs) {
final tp = TextPainter(
text: TextSpan(
text: run.text,
style: TextStyle(
color: AppTokens.ink,
fontSize: run.fontSize,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
tp.paint(canvas, Offset(run.x, run.y));
}
} else if (docx != null) {
final start = pageIndex * 12;
final blocks = docx!.blocks.skip(start).take(12).toList();
var y = 48.0;
for (final b in blocks) {
final style = TextStyle(
color: AppTokens.ink,
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
fontWeight:
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
);
final tp = TextPainter(
text: TextSpan(text: b.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: size.width - 96);
tp.paint(canvas, Offset(48, y));
y += tp.height + 12;
}
}
final ink = Paint()
..color = AppTokens.copper
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final s in [...strokes, if (live != null) live!]) {
if (s.points.length < 2) continue;
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
for (var i = 1; i < s.points.length; i++) {
path.lineTo(s.points[i].dx, s.points[i].dy);
}
canvas.drawPath(path, ink);
}
}
@override
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
}

View File

@@ -21,22 +21,49 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../engine/brush.dart';
import '../engine/pen_physics.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_predictor.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../engine/shape_geometry.dart';
import 'dart:math' as math;
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'ink_painters.dart' show EraserPreviewPainter;
import 'editor_tool.dart';
import 'ink_painters.dart' show EraserPreviewPainter, SelectionOverlayPainter;
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart';
/// The active tool on the pen canvas.
enum CanvasTool { pen, highlighter, eraser }
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
/// triad; [select] and [shape] are the core-writing-batch additions. This mirrors
/// the shared [EditorToolKind] (the PDF editor uses that enum directly); the
/// PenCanvas keeps its own enum because it predates the shared model and is wired
/// through many call sites — see [editorToolToCanvas].
enum CanvasTool { pen, highlighter, eraser, select, shape }
/// Map the shared [EditorToolKind] to the PenCanvas's [CanvasTool] so the note/
/// slide editors can drive PenCanvas from the shared active-tool state.
CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
EditorToolKind.brush => CanvasTool.pen,
EditorToolKind.highlighter => CanvasTool.highlighter,
EditorToolKind.eraser => CanvasTool.eraser,
EditorToolKind.select => CanvasTool.select,
EditorToolKind.shape => CanvasTool.shape,
// The TEXT tool is PDF-editor-only for now (note/slide typed text is a
// later increment); the note palette has no text button, so this mapping
// is unreachable in practice — fall back to the pen so the switch stays
// exhaustive without inventing a PenCanvas typed-text path.
EditorToolKind.text => CanvasTool.pen,
};
class PenCanvas extends StatefulWidget {
const PenCanvas({
@@ -46,15 +73,26 @@ class PenCanvas extends StatefulWidget {
required this.strokes,
required this.transformationController,
required this.tool,
this.brush = BrushKind.fountainPen,
this.shapeKind = ShapeKind.line,
required this.color,
required this.strokeWidth,
required this.onStrokeComplete,
required this.onEraseStroke,
this.selectedStrokeIndex,
this.onSelectStroke,
this.onMoveStroke,
this.allowFingerDrawing = false,
this.minScale = 0.5,
this.maxScale = 8.0,
this.scaleEnabled = true,
this.panEnabled = true,
this.onPenDebug,
this.thinning = kDefaultPenThinning,
this.pressureGamma = kNaturalPressureGamma,
this.pressureFloor = kNaturalPressureFloor,
this.eraserRadius = kDefaultEraserRadius,
this.eraserWholeStroke = false,
this.sideButtonAction = PenButtonAction.eraser,
this.eraserEndAction = PenButtonAction.eraser,
this.onPenButtonAction,
@@ -79,6 +117,17 @@ class PenCanvas extends StatefulWidget {
final TransformationController transformationController;
final CanvasTool tool;
/// The brush selected for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always renders with [BrushKind.highlighter] regardless of
/// this value; the eraser draws nothing. Drives both the capture-time pressure
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
final BrushKind brush;
/// The shape to draw when [tool] is [CanvasTool.shape]. Generated as a
/// PenStroke via [generateShapePoints] (no new model).
final ShapeKind shapeKind;
final Color color;
/// Pen width as a fraction of page width (so it zooms with the page).
@@ -93,6 +142,22 @@ class PenCanvas extends StatefulWidget {
final void Function(int strokeIndex, List<PenStroke> replacements)
onEraseStroke;
/// Index of the currently selected committed stroke (SELECT tool), or null.
/// Drives the selection bounding-box overlay.
final int? selectedStrokeIndex;
/// Called when the SELECT tool taps a committed stroke (its index), or null
/// when the tap hits empty space (clears the selection).
final ValueChanged<int?>? onSelectStroke;
/// Called when the SELECT tool drags the selected stroke: ([strokeIndex],
/// [dx],[dy]) is the normalized translation to apply, and [isDragStart] is true
/// on the FIRST delta of a drag so the parent records ONE undo snapshot per
/// drag (not per pixel). The parent translates + persists (see
/// `translateStroke`).
final void Function(int strokeIndex, double dx, double dy, bool isDragStart)?
onMoveStroke;
/// User toggle: allow a single finger to draw. Forced off once a stylus is
/// seen (palm rejection).
final bool allowFingerDrawing;
@@ -100,9 +165,40 @@ class PenCanvas extends StatefulWidget {
final double minScale;
final double maxScale;
/// When false, the canvas cannot be pinch-zoomed (sticky notes lock this so
/// writing isn't fighting an inner transform).
final bool scaleEnabled;
/// When false, one-finger pan is disabled (sticky notes often lock pan too).
final bool panEnabled;
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
final double thinning;
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
///
/// TODO(brush-pressure-knob): superseded by the per-brush
/// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the
/// capture-time warp. This config knob is retained for the API + future
/// reconciliation (e.g. a user multiplier on top of the brush curve) but is no
/// longer read by [_normalizedPressure].
final double pressureGamma;
/// Minimum shaped pressure, so a light stroke still has body instead of
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
final double pressureFloor;
/// Eraser radius as a fraction of page width (live hit area + cursor size).
/// From `PenConfig.eraserRadius`.
final double eraserRadius;
/// When true the eraser removes a whole stroke on contact (OneNote-style);
/// when false it does a partial / segment erase. From
/// `PenConfig.eraserWholeStroke`.
final bool eraserWholeStroke;
/// Configured action for the pen's side barrel button (W3 — resolved against
/// the native pen plugin's flags on Windows).
final PenButtonAction sideButtonAction;
@@ -127,10 +223,28 @@ class _PenCanvasState extends State<PenCanvas> {
/// In-progress stroke points (normalized).
final List<PenPoint> _livePoints = [];
final StrokePredictor _predictor = StrokePredictor();
/// Count of real (non-predicted) points in [_livePoints].
int _realPointCount = 0;
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke;
/// SHAPE tool: the normalized start point of the in-progress shape, or null.
PenPoint? _shapeStart;
/// SELECT tool: the last normalized drag position, used to compute the
/// incremental translation reported to [PenCanvas.onMoveStroke].
PenPoint? _selectLast;
/// SELECT tool: true once a drag of the selected stroke has begun (so the move
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
bool _selectDragging = false;
/// Tip-velocity tracker for [tipVelocityWidthScale] (physical ink starvation).
Offset? _lastTipNorm;
Duration? _lastTipTime;
/// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
@@ -168,17 +282,21 @@ class _PenCanvasState extends State<PenCanvas> {
bool get _isEraserMode =>
widget.tool == CanvasTool.eraser || _eraserActive;
/// Eraser radius as a fraction of page width (shared by the live erase and the
/// preview overlay so they always agree). A decisive fixed size — the old
/// strokeWidth*2 was so small that a pass removed only a couple of points and
/// the stroke visibly survived ("选中了的笔画也不见得能删掉").
static const double _eraserRadius = 0.02;
/// Page aspect (height / width) so the eraser circle stays round on screen.
double get _pageAspect => widget.pageSize.width <= 0
? 1.0
: widget.pageSize.height / widget.pageSize.width;
/// Normalized bounding box of the currently selected stroke (SELECT tool), or
/// null when nothing valid is selected.
Rect? get _selectionBounds {
final idx = widget.selectedStrokeIndex;
if (idx == null || idx < 0 || idx >= widget.strokes.length) return null;
final b = penStrokeBounds(widget.strokes[idx]);
if (b == null) return null;
return Rect.fromLTRB(b.left, b.top, b.right, b.bottom);
}
// The explicit user toggle wins: if finger-drawing is ON, a single finger
// draws even after a stylus has been seen. (Palm rejection when the toggle is
// OFF is automatic — fingers simply never draw — and a 2nd pointer always
@@ -187,10 +305,49 @@ class _PenCanvasState extends State<PenCanvas> {
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
/// The brush in effect for the current tool: highlighter tool ⇒ highlighter
/// brush, otherwise the selected pen brush. (Eraser draws nothing, so its
/// brush is irrelevant.)
BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter
? BrushKind.highlighter
: widget.brush;
/// The brush profile in effect, for the capture-time pressure pre-warp.
BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush);
/// Normalize stylus pressure to [0,1], or null when the device reports no
/// usable pressure range (then perfect_freehand simulates pressure).
///
/// The raw normalized force is then shaped by the pressure-response curve
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
/// — and because the shaping happens at capture, the live stroke and the PDF
/// export replay identical pressures (no divergence).
double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
final double? raw = _rawNormalizedPressure(event);
if (raw == null) return null;
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
// = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the
// existing PressureCurve. Baking the warp in at capture means the live
// stroke and the export replay identical pressures (no divergence). The
// brush gamma supersedes the legacy per-config `pressureGamma` knob — see
// TODO(brush-pressure-knob) on `widget.pressureGamma`.
return PressureCurve(
floor: widget.pressureFloor,
gamma: _currentBrushProfile.pressureGamma,
).apply(raw);
}
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
///
/// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's
/// PointerEvent.pressure on Windows is often flat/useless while the driver
/// still reports real 0..1024 via GetPointerPenInfo.
double? _rawNormalizedPressure(PointerEvent event) {
final hw = PenInputService.instance;
if (hw.isActive && hw.current.pressureValid) {
return hw.current.pressure.clamp(0.0, 1.0);
}
final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
@@ -257,7 +414,8 @@ class _PenCanvasState extends State<PenCanvas> {
if (action == _lastHwAction) return;
_lastHwAction = action;
if (action == PenButtonAction.undo ||
action == PenButtonAction.toggleTool) {
action == PenButtonAction.toggleTool ||
action == PenButtonAction.select) {
widget.onPenButtonAction?.call(action);
}
}
@@ -278,19 +436,28 @@ class _PenCanvasState extends State<PenCanvas> {
/// Decide whether the gesture currently forming should DRAW. Delegates to the
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
/// the tests can never disagree on the rule.
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
bool _shouldDraw(PointerDeviceKind kind) {
final draw = arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
PenEventRing.instance.recordArbiter(
activeCount: _activePointers.length,
deviceKind: kind.name,
draw: draw,
fingerDrawing: _fingerDrawingEnabled,
);
return draw;
}
// --- Coordinate mapping ---------------------------------------------------
/// Map a global pointer position into normalized page coords using the
/// shared transform (inverse) and this widget's geometry.
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
{double? tilt}) {
{double? tilt, Duration? timeStamp}) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
@@ -300,7 +467,23 @@ class _PenCanvasState extends State<PenCanvas> {
final nx = scene.dx / widget.pageSize.width;
final ny = scene.dy / widget.pageSize.height;
return PenPoint(nx, ny, pressure, tilt: tilt);
double? shaped = pressure;
if (shaped != null && timeStamp != null && _lastTipNorm != null &&
_lastTipTime != null) {
final dt = (timeStamp - _lastTipTime!).inMicroseconds / 1e6;
if (dt > 0) {
final dx = nx - _lastTipNorm!.dx;
final dy = ny - _lastTipNorm!.dy;
final speed = math.sqrt(dx * dx + dy * dy) / dt;
shaped = (shaped * tipVelocityWidthScale(_currentBrush, speed))
.clamp(0.0, 1.0);
}
}
_lastTipNorm = Offset(nx, ny);
_lastTipTime = timeStamp;
return PenPoint(nx, ny, shaped, tilt: tilt);
}
// --- Stroke lifecycle -----------------------------------------------------
@@ -308,9 +491,15 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_lastTipNorm = null;
_lastTipTime = null;
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
if (p != null) _livePoints.add(p);
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
@@ -321,12 +510,33 @@ class _PenCanvasState extends State<PenCanvas> {
if (_liveStroke != null) setState(() => _liveStroke = null);
return;
}
// SELECT: tap hit-tests the committed strokes (topmost first) and reports
// the selection. A subsequent drag translates it (see _extendStroke).
if (widget.tool == CanvasTool.select) {
if (p != null) {
_selectLast = p;
widget.onSelectStroke?.call(_hitTestStroke(p));
}
return;
}
// SHAPE: record the start point; the preview shape is built on each move.
if (widget.tool == CanvasTool.shape) {
_shapeStart = p;
return;
}
if (p != null) {
_livePoints.add(p);
_realPointCount = _livePoints.length;
}
_updateLiveStroke();
}
void _extendStroke(PointerMoveEvent event) {
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
tilt: _tiltFor(event), timeStamp: event.timeStamp);
if (p == null) return;
if (_eraserActive || widget.tool == CanvasTool.eraser) {
@@ -334,29 +544,128 @@ class _PenCanvasState extends State<PenCanvas> {
_eraseAt(p);
return;
}
// SELECT drag: translate the selected stroke by the incremental delta.
if (widget.tool == CanvasTool.select) {
final last = _selectLast;
final idx = widget.selectedStrokeIndex;
if (last != null && idx != null) {
final dx = p.x - last.x;
final dy = p.y - last.y;
if (dx != 0 || dy != 0) {
final isStart = !_selectDragging;
_selectDragging = true;
widget.onMoveStroke?.call(idx, dx, dy, isStart);
}
}
_selectLast = p;
return;
}
// SHAPE preview: regenerate the shape from start→current on every move.
if (widget.tool == CanvasTool.shape) {
_updateShapePreview(p);
return;
}
// Drop previous predicted tip before appending the real sample.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
_livePoints.add(p);
_realPointCount = _livePoints.length;
final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5);
if (pred != null) {
_livePoints.add(PenPoint(
pred.offset.dx.clamp(0.0, 1.0),
pred.offset.dy.clamp(0.0, 1.0),
pred.pressure,
tilt: p.tilt,
));
}
_updateLiveStroke();
}
void _endStroke() {
if (_drawPointer == null) return;
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
if (!wasEraser && _livePoints.isNotEmpty) {
final tool = widget.tool;
final wasEraser = _eraserActive || tool == CanvasTool.eraser;
if (tool == CanvasTool.shape) {
// Commit the generated shape stroke (if the drag spanned any distance).
final start = _shapeStart;
final end = _livePoints.isNotEmpty ? _livePoints.last : null;
if (start != null && end != null) {
final pts = generateShapePoints(widget.shapeKind, start, end);
widget.onStrokeComplete(PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: kShapeBrush,
));
}
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
// Never commit predicted tips — only real digitizer samples.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
),
);
}
_drawPointer = null;
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null);
}
/// Hit-test committed strokes (topmost first) at normalized [p]; returns the
/// index of the first stroke within the eraser radius, or null. Reuses
/// [strokeHit] so tap-select matches the eraser's proximity model.
int? _hitTestStroke(PenPoint p) {
final radius = widget.eraserRadius;
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
if (strokeHit(widget.strokes[i], p.x, p.y, radius, aspect: aspect)) {
return i;
}
}
return null;
}
/// Build the SHAPE preview stroke from the recorded start to the current [p].
void _updateShapePreview(PenPoint p) {
final start = _shapeStart;
if (start == null) return;
_livePoints
..clear()
..add(p); // remember the latest end point for commit
final pts = generateShapePoints(widget.shapeKind, start, p);
setState(() {
_liveStroke = PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: kShapeBrush,
);
});
}
/// Discard the in-progress stroke without committing (palm/2nd-finger).
void _cancelStroke() {
_drawPointer = null;
@@ -372,6 +681,7 @@ class _PenCanvasState extends State<PenCanvas> {
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
);
});
}
@@ -391,13 +701,16 @@ class _PenCanvasState extends State<PenCanvas> {
/// stays round on screen (the page rect is not square).
void _eraseAt(PenPoint? p) {
if (p == null) return;
final radius = _eraserRadius; // normalized (page-width fraction)
final radius = widget.eraserRadius; // normalized (page-width fraction)
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
final stroke = widget.strokes[i];
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
final pieces =
splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
// Point-eraser mode (default): cut out the touched span, keep the rest.
final pieces = widget.eraserWholeStroke
? const <PenStroke>[]
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
widget.onEraseStroke(i, pieces);
@@ -500,7 +813,7 @@ class _PenCanvasState extends State<PenCanvas> {
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
// so a pinch re-enables pan/zoom immediately.
final panEnabled = _drawPointer == null;
final panEnabled = widget.panEnabled && _drawPointer == null;
// Mirror committed strokes into the revision-tracked store (only re-mirrors
// when the parent handed us a new list identity).
@@ -519,7 +832,7 @@ class _PenCanvasState extends State<PenCanvas> {
minScale: widget.minScale,
maxScale: widget.maxScale,
panEnabled: panEnabled,
scaleEnabled: true,
scaleEnabled: widget.scaleEnabled,
child: SizedBox(
width: widget.pageSize.width,
height: widget.pageSize.height,
@@ -559,14 +872,15 @@ class _PenCanvasState extends State<PenCanvas> {
painter: EraserPreviewPainter(
strokes: widget.strokes,
cursor: _eraserCursor,
radius: _eraserRadius,
radius: widget.eraserRadius,
aspect: _pageAspect,
pageSize: widget.pageSize,
),
),
),
),
// Live ink (current stroke only, isolated repaint).
// Live ink (current stroke only, isolated repaint). Also carries
// the SHAPE tool's preview (built as a live PenStroke).
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
@@ -578,6 +892,18 @@ class _PenCanvasState extends State<PenCanvas> {
),
),
),
// SELECT tool: bounding box around the selected stroke.
if (widget.tool == CanvasTool.select && _selectionBounds != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: SelectionOverlayPainter(
boundsNorm: _selectionBounds,
pageSize: widget.pageSize,
),
),
),
),
],
),
),

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
// because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
@@ -32,6 +33,7 @@ import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'input_diagnostics.dart';
import 'pinch_scale_solver.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
@@ -45,13 +47,13 @@ const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
/// demanding far more than this is a Windows multi-touch position glitch, not
/// intent — that frame is dropped so the zoom can't pop and snap back.
const double _kScaleGlitchHi = 1.4;
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
const double _kScaleGlitchHi = 1.18;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
/// and the frame is dropped (position-jump guard).
const double _kFocalGlitchPx = 250.0;
const double _kFocalGlitchPx = 64.0;
const double _kDrag = 0.0000135;
@@ -105,17 +107,28 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
/// applying a frame whose scale/focal still refer to the old finger set.
int _lastPointerCount = 0;
/// The recognizer's cumulative `details.scale` and the absolute scale we last
/// APPLIED, both as of the previous accepted frame. The pinch is driven
/// absolutely from these + the gesture-start snapshot — we never read the live
/// matrix back into the per-frame scale change. (Re-reading
/// `getMaxScaleOnAxis()` per frame was the flicker source: a single transient
/// mis-read/interleaved write made `desiredScale/liveScale` demand a ~1.31.4x
/// jump for one frame and snap back. The glitch guard missed it because the
/// spike sat just under the 1.4 threshold.)
double _lastRawScale = 1.0;
/// The absolute scale we last APPLIED. Soft-clamp limits the step from this
/// value; we never read the live matrix back into the per-frame scale change.
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
/// gesture start, or the last pointer-count re-baseline). The absolute target
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
///
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
/// its un-normalized cumulative value, so the next frame computed
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0;
/// Windows ScaleGestureRecognizer emits one onUpdate per finger move in the
/// same event-loop turn. Applying both mutates the matrix twice with an
/// intermediate state (Surface diag: √2-ish cur ping-pong). Keep latest only.
ScaleUpdateDetails? _pendingScaleUpdate;
bool _scaleFlushScheduled = false;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
@@ -163,15 +176,38 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
}
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_gestureType = null;
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
// Pointer-count change must apply immediately (re-baseline), not coalesce.
if (details.pointerCount != _lastPointerCount) {
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_applyScaleUpdate(details);
return;
}
_pendingScaleUpdate = details;
if (_scaleFlushScheduled) return;
_scaleFlushScheduled = true;
scheduleMicrotask(() {
_scaleFlushScheduled = false;
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
if (pending != null && mounted && _scaleStart != null) {
_applyScaleUpdate(pending);
}
});
}
void _applyScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint;
@@ -180,10 +216,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
// The transitional frame itself is skipped.
if (details.pointerCount != _lastPointerCount) {
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
// oscillated frame-to-frame (the reported "zoom jump"). Using
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
// re-baseline: target == _lastAppliedScale at this instant, regardless of
// any transient in the live matrix.
_scaleStart = _lastAppliedScale;
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
_lastAppliedScale = _scaleStart!;
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
@@ -204,14 +250,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
void record(double appliedChange, bool scaleDrop, bool focalDropped) {
void record(
double currentScale,
double appliedChange,
bool softClamped,
bool focalDropped,
) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: scale,
currentScale: currentScale,
appliedChange: appliedChange,
focalJumpPx: focalJumpPx,
scaleDrop: scaleDrop,
scaleDrop: false,
softClamped: softClamped,
focalDrop: focalDropped,
);
}
@@ -219,34 +271,32 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
// scale — the clean, monotonic signal (verified against device logs).
// Crucially we do NOT divide by the live matrix scale here: feeding
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
// and snap back. A ratio outside the glitch band is a real multi-touch
// spike → drop the frame; absolute tracking means the next good frame
// resumes from the true finger span, so the spike never shows.
final double rawRatio =
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
final bool scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
if (scaleDrop || focalDrop) {
record(1.0, scaleDrop, focalDrop);
// Soft-clamp per-step change instead of hard-dropping (Surface diag:
// hard SDROP froze lastRaw and avalanched while the matrix still moved).
final SoftPinchStep step = softClampedPinchStep(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
lastAppliedScale: _lastAppliedScale,
minScale: widget.minScale,
maxScale: widget.maxScale,
maxStepRatio: _kScaleGlitchHi,
);
if (focalDrop) {
if (step.reanchor) {
_scaleStart = step.appliedScale;
_rawScaleAtBaseline = details.scale;
_lastAppliedScale = step.appliedScale;
}
record(_lastAppliedScale, 1.0, step.spiked, true);
return;
}
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
// scene point that was under the focal at gesture start stays under the
// CURRENT focal (which also yields 2-finger pan for free). Closed form
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
final double targetScale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
);
final double targetScale = step.appliedScale;
if (step.reanchor) {
_scaleStart = targetScale;
_rawScaleAtBaseline = details.scale;
}
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
@@ -258,9 +308,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastRawScale = details.scale;
_lastAppliedScale = targetScale;
record(applied, false, false);
record(targetScale, applied, step.spiked, false);
case _GestureType.pan:
assert(_referenceFocalPoint != null);
@@ -268,7 +317,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
if (details.scale != 1.0) return;
if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, true);
record(scale, 1.0, false, true);
return;
}
final Offset translationChange =
@@ -276,11 +325,17 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, false);
record(scale, 1.0, false, false);
}
}
void _onScaleEnd(ScaleEndDetails details) {
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
if (pending != null && _scaleStart != null) {
_applyScaleUpdate(pending);
}
_scaleStart = null;
_referenceFocalPoint = null;
_lastPointerCount = 0;
@@ -321,26 +376,9 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_animation!.addListener(_handleInertiaAnimation);
_controller.forward();
case _GestureType.scale:
if (details.scaleVelocity.abs() < 0.1) return;
final double scale = _transformer.value.getMaxScaleOnAxis();
final FrictionSimulation frictionSimulation = FrictionSimulation(
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
scale,
details.scaleVelocity / 10,
);
final double tFinal = _getFinalTime(
details.scaleVelocity.abs(),
widget.interactionEndFrictionCoefficient,
effectivelyMotionless: 0.1,
);
_scaleAnimation = Tween<double>(
begin: scale,
end: frictionSimulation.x(tFinal),
).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
_scaleAnimation!.addListener(_handleScaleAnimation);
_scaleController.forward();
// No scale fling: Windows touch often reports noisy scaleVelocity that
// animates past the intended zoom and feels like a "jump" after pinch.
return;
case null:
break;
}

View File

@@ -0,0 +1,921 @@
// lib/editor/canvas/pen_note_screen.dart
//
// Pen-first blank-note editor. Reuses the single performant inking engine
// (PenCanvas) over a white logical page instead of a PDF page, and persists
// strokes back to the Note model via the InkStroke<->PenStroke adapter. This is
// the note half of "all note features on the pen-first canvas".
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_stroke.dart';
import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_model.dart';
import '../persistence/sidecar_repository.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../notebook/ink_stroke_adapter.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'note_background.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenNoteScreen extends ConsumerStatefulWidget {
const PenNoteScreen({super.key, this.note});
/// Existing note to edit, or null for a new note.
final Note? note;
@override
ConsumerState<PenNoteScreen> createState() => _PenNoteScreenState();
}
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
static const _uuid = Uuid();
/// Per-page live strokes in normalized coords (the canvas source of truth).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// Current page index (0-based) and total page count (min 1).
int _pageIndex = 0;
int _pageCount = 1;
/// Snapshot-before-change undo/redo scoped to the current page. Cleared on
/// page switch so undo never crosses pages.
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
bool _showPageScrubber = false;
double? _pageScrub;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// Highlighter keeps its own color (not a pen slot).
Color _highlighterColor = Colors.orange;
/// Active pen brush from the selected slot (fallback until slots load).
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
/// Active drawing color: highlighter tool uses [_highlighterColor], else the
/// active pen slot's color.
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
/// The page-background template painted behind the ink (rnote-style). Default
/// blank; persisted per-notebook in the sidecar.
NoteBackground _background = NoteBackground.blank;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
/// The note's synthetic source path `<folder>/notebook` (also the note id).
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
String? _notePath;
/// Per-file sidecar persistence sink (strokes + pageCount + title), debounced
/// and atomic — replaces the old SQLite Note/noteListProvider write path here.
SidecarRepository? _repo;
final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
/// Current page's stroke list (PenCanvas source of truth).
List<PenStroke> get _strokes =>
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
set _strokes(List<PenStroke> value) {
_strokesByPage[_pageIndex] = value;
}
@override
void initState() {
super.initState();
PenInputService.instance.start();
final note = widget.note;
if (note != null) {
_notePath = note.id;
_titleController.text = note.title;
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
// load resolves and (if present) overrides with persisted strokes.
_strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else {
_titleController.text = 'Untitled';
}
_initPenConfig();
if (_notePath != null) _initPersistence(_notePath!);
}
/// Open the note's `notebook.badnote.json` sidecar and hydrate every page of
/// strokes plus title / background / pageCount.
Future<void> _initPersistence(String notePath) async {
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
setState(() {
_hydrateFromRepo(repo);
});
}
/// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1,
/// highest stroke key + 1). Persists pageCount when the sidecar omitted it.
void _hydrateFromRepo(SidecarRepository repo) {
// Only replace in-memory strokes when the sidecar actually holds ink —
// otherwise keep the seed from widget.note (widget tests / cold open).
if (repo.loadedStrokes.isNotEmpty) {
_strokesByPage.clear();
for (final entry in repo.loadedStrokes.entries) {
if (entry.value.isEmpty) continue;
_strokesByPage[entry.key] = [
for (final es in entry.value) _penStrokeFromEditor(es),
];
}
}
final fromKeys = _strokesByPage.isEmpty
? 1
: _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1;
final declared = repo.sidecar.pageCount ?? 1;
_pageCount = declared > fromKeys ? declared : fromKeys;
if (_pageCount < 1) _pageCount = 1;
if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1;
_undo.clear();
_redo.clear();
_selectedStroke = null;
final title = repo.loadedTitle;
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
_background = noteBackgroundFromName(repo.loadedBackground);
if (repo.sidecar.pageCount != _pageCount) {
repo.schedulePageCountSave(_pageCount);
}
}
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
/// is persisted on the EditorStroke now, so carry it through; old sidecars
/// without the field decode to fountainPen (back-compat default).
PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke(
points: es.points
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
brush: es.brush,
);
Future<void> _initPenConfig() async {
final results = await Future.wait([
PenConfigController.load(),
PenSlotsController.load(),
]);
final config = results[0] as PenConfigController;
final slots = results[1] as PenSlotsController;
if (!mounted) {
config.dispose();
slots.dispose();
return;
}
config.addListener(_onPenConfigChanged);
slots.addListener(_onPenSlotsChanged);
setState(() {
_penConfig = config;
_penSlots = slots;
_allowFingerDrawing = config.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
// Flush any pending sidecar write before tearing down (atomic write
// completes off the widget tree).
final repo = _repo;
if (repo != null) {
repo.flush();
repo.dispose();
}
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_titleController.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
_undo.add(List<PenStroke>.from(_strokes));
_redo.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokes = [..._strokes, stroke];
_dirty = true;
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, index),
...replacements,
..._strokes.sublist(index + 1),
];
_dirty = true;
});
}
void _performUndo() {
if (_undo.isEmpty) return;
setState(() {
_redo.add(List<PenStroke>.from(_strokes));
_strokes = _undo.removeLast();
_dirty = true;
});
}
void _performRedo() {
if (_redo.isEmpty) return;
setState(() {
_undo.add(List<PenStroke>.from(_strokes));
_strokes = _redo.removeLast();
_dirty = true;
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
// ── Persistence ──────────────────────────────────────────────────────────────
/// Schedule a stroke save for [pageIndex] (defaults to current) without
/// flushing. Used when switching pages so ink isn't lost mid-edit.
void _schedulePageStrokeSave([int? pageIndex]) {
final repo = _repo;
if (repo == null) return;
final idx = pageIndex ?? _pageIndex;
final pageStrokes = _strokesByPage[idx] ?? const <PenStroke>[];
final editorStrokes = <EditorStroke>[
for (final s in pageStrokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleStrokeSave(idx, editorStrokes);
}
void _goToPage(int index) {
if (_pageCount < 1) return;
final clamped = index.clamp(0, _pageCount - 1);
if (clamped == _pageIndex) {
setState(() {
_pageScrub = null;
_showPageScrubber = false;
});
return;
}
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageIndex = clamped;
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
});
}
void _addPage() {
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageCount += 1;
_pageIndex = _pageCount - 1;
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
_dirty = true;
});
_repo?.schedulePageCountSave(_pageCount);
}
/// Persist the live pen strokes + title + pageCount to the note's
/// `notebook.badnote.json` sidecar, debounced/atomic via [SidecarRepository].
/// Creates the notebook folder lazily on first save when the screen was opened
/// without a path. Refreshes the home list and triggers local OCR for search.
Future<void> _save() async {
if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
final title = _titleController.text.trim().isEmpty
? 'Untitled'
: _titleController.text.trim();
// Lazily create the notebook folder + sidecar repo on first save.
if (_repo == null) {
final created = await notifier.createNote(title: title);
if (!mounted) return;
_notePath = created.id;
final repo =
await SidecarRepository.open(created.id, docType: 'notebook');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
}
final repo = _repo!;
repo.scheduleTitleSave(title);
repo.scheduleBackgroundSave(_background.name);
repo.schedulePageCountSave(_pageCount);
// Persist every page that has (or had) strokes in this session. Empty pages
// clear their sidecar entry via scheduleStrokeSave.
for (final idx in _strokesByPage.keys.toList()..sort()) {
_schedulePageStrokeSave(idx);
}
// Also ensure the current page is written even if never putIfAbsent'd empty.
_schedulePageStrokeSave(_pageIndex);
await repo.flush();
// Refresh the home list so the title/recency update is visible on return.
await notifier.loadNotes();
if (!mounted) return;
setState(() => _dirty = false);
// Build an in-memory Note (id = note path) for OCR/FTS indexing only —
// flatten all pages into one stroke list.
final inkStrokes = <InkStroke>[
for (final page in _strokesByPage.values)
for (final s in page)
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
];
_runLocalOcr(Note(
id: _notePath!,
title: title,
strokes: inkStrokes,
createdAt: now,
updatedAt: now,
));
}
void _runLocalOcr(Note note) {
final id = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.processing,
};
ref.read(ocrServiceProvider).processNote(note).then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.done,
};
}).catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.failed,
};
});
}
// ── Layout helpers ──────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
/// Set (or clear) the selected stroke from a SELECT-tool tap.
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
/// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta
/// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a
/// single undo step.
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(_strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokes = next;
_dirty = true;
});
}
/// Delete the selected stroke (button or long-press), as one undo step.
void _deleteSelected() {
final idx = _selectedStroke;
if (idx == null || idx < 0 || idx >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, idx),
..._strokes.sublist(idx + 1),
];
_selectedStroke = null;
_dirty = true;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _dirty) _save();
},
child: Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
// Tool palette (top-center) — identical chrome to the PDF editor.
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
// Back (saves on the way out).
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () async {
final navigator = Navigator.of(context);
await _save();
if (mounted) navigator.maybePop();
},
),
),
),
// Title + page chrome (bottom-center).
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildPagePill(cs),
const SizedBox(height: 8),
_buildTitlePill(cs),
],
),
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
return LayoutBuilder(
builder: (context, constraints) {
// Fit the logical note page into the viewport at scale 1.0.
final fitW = constraints.maxWidth / kNoteLogicalPage.width;
final fitH = constraints.maxHeight / kNoteLogicalPage.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(
kNoteLogicalPage.width * scale,
kNoteLogicalPage.height * scale,
);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
pageSize: pageSize,
strokes: _strokes,
transformationController: _transform,
tool: editorToolToCanvas(_tool),
brush: _penBrush,
shapeKind: _shapeKind,
color: _color,
strokeWidth: _strokeWidth,
selectedStrokeIndex: _selectedStroke,
onSelectStroke: _selectStroke,
onMoveStroke: _moveStroke,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.select,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
onPenButtonAction: (action) {
if (action == PenButtonAction.select) {
setState(() => _tool = EditorToolKind.select);
} else if (action == PenButtonAction.undo) {
if (_undo.isNotEmpty) _performUndo();
} else if (action == PenButtonAction.toggleTool) {
setState(() {
_tool = _tool == EditorToolKind.eraser
? EditorToolKind.brush
: EditorToolKind.eraser;
});
}
},
// A white sheet with a soft shadow — the note "paper" — overlaid with
// the selected background template, painted in page-pixel space (so it
// scales with zoom) and BEHIND the ink layers.
pageWidget: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
spreadRadius: 1,
),
],
),
child: CustomPaint(
painter: NoteBackgroundPainter(_background),
size: Size.infinite,
),
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
// Select (cursor) + shape tools.
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undo.isNotEmpty ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _redo.isNotEmpty ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
PaletteDivider(cs: cs),
// Page-background template picker (rnote-style: blank / dots / ruled
// / grid / cornell). Persists per-notebook in the sidecar.
PopupMenuButton<NoteBackground>(
tooltip: 'Page background',
initialValue: _background,
onSelected: (b) {
setState(() {
_background = b;
_dirty = true;
});
},
itemBuilder: (context) => [
for (final b in NoteBackground.values)
PopupMenuItem<NoteBackground>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(noteBackgroundIcon(b), size: 20),
const SizedBox(width: 10),
Text(noteBackgroundLabel(b)),
if (b == _background) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
noteBackgroundIcon(_background),
size: 22,
color: cs.onSurfaceVariant,
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
PaletteDivider(cs: cs),
ToolButton(
icon:
_allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
),
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE slot (or highlighter) color. A color tap
// updates only the active slot / highlighter — not other slots.
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
setState(() => _tool = EditorToolKind.brush);
}
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildTitlePill(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: TextField(
controller: _titleController,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Note title…',
isDense: true,
),
onChanged: (_) => _dirty = true,
),
),
),
);
}
/// Compact page chrome: prev / "n / total" / next, plus add-page. Tapping the
/// center label toggles a scrubber Slider when there is more than one page.
Widget _buildPagePill(ColorScheme cs) {
final total = _pageCount;
final scrub = _pageScrub;
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1 && _showPageScrubber)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Slider(
min: 1,
max: total.toDouble(),
value: (scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
divisions: total > 1 ? total - 1 : null,
onChanged: (v) => setState(() => _pageScrub = v),
onChangeEnd: (v) {
setState(() => _pageScrub = v);
_goToPage(v.round() - 1);
},
),
),
),
),
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed: _pageIndex > 0
? () => _goToPage(_pageIndex - 1)
: null,
),
TextButton(
onPressed: () {
if (total > 1) {
setState(() => _showPageScrubber = !_showPageScrubber);
}
},
child: Text(
'$shown / $total',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
IconButton(
tooltip: 'Add page',
icon: const Icon(Icons.add),
onPressed: _addPage,
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,547 @@
// lib/editor/canvas/pen_palette_widgets.dart
//
// Shared Material 3 chrome for the pen-first editors (PDF, note, slide) so the
// floating tool palette looks and behaves identically everywhere — one source
// of truth for the inking UI.
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import '../input/pen_slots.dart';
import 'editor_tool.dart';
/// Shared ink color palette for PDF / note / slide / scratch editors.
const List<Color> kInkPalette = <Color>[
Color(0xFF1A1A1A),
Color(0xFFC62828),
Color(0xFF1565C0),
Color(0xFF2E7D32),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFF00838F),
Color(0xFF5D4037),
Color(0xFFF9A825),
Color(0xFFE91E63),
Color(0xFF455A64),
Color(0xFF37474F),
];
/// Localized display name for a brush (single source so all three editors agree).
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
BrushKind.fountainPen => l.brushFountainPen,
BrushKind.ballpoint => l.brushBallpoint,
BrushKind.pencil => l.brushPencil,
BrushKind.highlighter => l.brushHighlighter,
};
/// English fallback brush name, for the note/slide editors which (like their
/// other chrome) use hardcoded English strings rather than [AppLocalizations]
/// (their test harness mounts a MaterialApp without localization delegates).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String brushLabelEn(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => 'Fountain pen',
BrushKind.ballpoint => 'Ballpoint',
BrushKind.pencil => 'Pencil',
BrushKind.highlighter => 'Highlighter',
};
/// Localized display name for a shape kind.
String shapeLabel(ShapeKind kind, AppLocalizations l) => switch (kind) {
ShapeKind.line => l.shapeLine,
ShapeKind.rectangle => l.shapeRectangle,
ShapeKind.ellipse => l.shapeEllipse,
ShapeKind.arrow => l.shapeArrow,
};
/// English fallback shape name (for the note/slide editors which use hardcoded
/// English strings — see [brushLabelEn]).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String shapeLabelEn(ShapeKind kind) => switch (kind) {
ShapeKind.line => 'Line',
ShapeKind.rectangle => 'Rectangle',
ShapeKind.ellipse => 'Ellipse',
ShapeKind.arrow => 'Arrow',
};
/// The brushes selectable as the PEN tool. The highlighter is its own tool, so
/// it is NOT offered here (eraser is also a separate tool).
const List<BrushKind> kPenToolBrushes = [
BrushKind.fountainPen,
BrushKind.ballpoint,
BrushKind.pencil,
];
/// Material icon for a brush (used in the brush picker + the pen tool button).
IconData brushIcon(BrushKind kind) => switch (kind) {
BrushKind.fountainPen => Icons.edit_outlined, // nib pen
BrushKind.ballpoint => Icons.create_outlined, // ballpoint
BrushKind.pencil => Icons.draw_outlined, // pencil
BrushKind.highlighter => Icons.brush_outlined, // marker
};
/// Material icon for a [ShapeKind] (used in the shape picker + tool button).
IconData shapeIcon(ShapeKind kind) => switch (kind) {
ShapeKind.line => Icons.show_chart, // straight line
ShapeKind.rectangle => Icons.crop_square,
ShapeKind.ellipse => Icons.circle_outlined,
ShapeKind.arrow => Icons.arrow_outward,
};
/// OneNote-style pen slot: each slot is its own toolbar button with a color
/// underline (slot-remembered color). Prefer this over [BrushPickerButton]
/// when the UX wants pens visible side-by-side.
class PenSlotButton extends StatelessWidget {
const PenSlotButton({
super.key,
required this.kind,
required this.selected,
required this.color,
required this.tooltip,
required this.onPressed,
this.widthHint,
});
final BrushKind kind;
final bool selected;
final Color color;
final String tooltip;
final VoidCallback onPressed;
/// Optional page-width fraction; when set, underline height scales slightly
/// so thicker slots read visually thicker. Null keeps the fixed 3px bar.
final double? widthHint;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
final barHeight = widthHint == null
? 3.0
: (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0);
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(20),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(kind), size: 22, color: iconColor),
const SizedBox(height: 3),
Container(
width: 16,
height: barHeight,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
),
);
}
}
/// Compact thickness control: S / M / L presets + a custom slider. Writes the
/// chosen page-width fraction via [onChanged] (typically
/// [PenSlotsController.setActiveWidth]).
class ThicknessPickerButton extends StatelessWidget {
const ThicknessPickerButton({
super.key,
required this.width,
required this.onChanged,
this.tooltip = 'Thickness',
});
/// Current stroke width (page-width fraction).
final double width;
final ValueChanged<double> onChanged;
final String tooltip;
static String _labelFor(double w) {
if ((w - kThicknessSmall).abs() < 0.0003) return 'S';
if ((w - kThicknessMedium).abs() < 0.0003) return 'M';
if ((w - kThicknessLarge).abs() < 0.0003) return 'L';
return '·';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopupMenuButton<double>(
tooltip: tooltip,
onSelected: onChanged,
itemBuilder: (context) => [
PopupMenuItem<double>(
value: kThicknessSmall,
child: Row(
children: [
const Text('S'),
const Spacer(),
if ((width - kThicknessSmall).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessMedium,
child: Row(
children: [
const Text('M'),
const Spacer(),
if ((width - kThicknessMedium).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessLarge,
child: Row(
children: [
const Text('L'),
const Spacer(),
if ((width - kThicknessLarge).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
enabled: false,
child: SizedBox(
width: 180,
child: StatefulBuilder(
builder: (context, setLocal) {
final v = width.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
return Slider(
value: v,
min: kPenSlotWidthMin,
max: kPenSlotWidthMax,
onChanged: (next) {
onChanged(next);
setLocal(() {});
},
);
},
),
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.line_weight, size: 20, color: cs.onSurfaceVariant),
const SizedBox(width: 2),
Text(
_labelFor(width),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
/// [labelFor] localizes each brush name so the menu honors the app locale.
/// [colorFor] returns each brush's REMEMBERED color (rnote-style per-brush color
/// memory): the active brush's color is shown as an underline on the button and
/// as a dot beside each menu item, so the toolbar makes each brush's color
/// visible at a glance.
class BrushPickerButton extends StatelessWidget {
const BrushPickerButton({
super.key,
required this.selected,
required this.active,
required this.onSelected,
required this.labelFor,
required this.colorFor,
required this.tooltip,
});
/// The currently selected pen brush.
final BrushKind selected;
/// True when the pen tool (this brush) is the active tool — drives highlight.
final bool active;
final ValueChanged<BrushKind> onSelected;
/// Localized display name for a brush.
final String Function(BrushKind) labelFor;
/// The remembered color for a brush (drives the underline + menu dots).
final Color Function(BrushKind) colorFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<BrushKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final b in kPenToolBrushes)
PopupMenuItem<BrushKind>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(b), size: 20),
const SizedBox(width: 10),
Text(labelFor(b)),
const SizedBox(width: 8),
// The brush's remembered color (rnote per-brush color memory).
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colorFor(b),
shape: BoxShape.circle,
border: Border.all(color: cs.outlineVariant),
),
),
if (b == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
// Per-brush color underline: shows the active brush's remembered
// color so switching brushes visibly switches the color.
Container(
height: 3,
width: 24,
decoration: BoxDecoration(
color: colorFor(selected),
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
);
}
}
/// A toggle-style tool button that doubles as a [ShapeKind] picker: a short tap
/// activates the shape tool with the current shape; a long-press (or the dropdown
/// caret) opens the line / rectangle / ellipse / arrow submenu.
class ShapePickerButton extends StatelessWidget {
const ShapePickerButton({
super.key,
required this.selected,
required this.active,
required this.onActivate,
required this.onSelected,
required this.labelFor,
required this.tooltip,
});
/// The currently selected shape kind.
final ShapeKind selected;
/// True when the shape tool is the active tool — drives highlight.
final bool active;
/// Called when the button body is tapped (activate the shape tool).
final VoidCallback onActivate;
/// Called when a shape kind is picked from the submenu (also activates).
final ValueChanged<ShapeKind> onSelected;
/// Localized display name for a shape kind.
final String Function(ShapeKind) labelFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<ShapeKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final s in ShapeKind.values)
PopupMenuItem<ShapeKind>(
value: s,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(s), size: 20),
const SizedBox(width: 10),
Text(labelFor(s)),
if (s == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onActivate,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
),
),
);
}
}
/// A Material 3 toggle-style icon button for the floating tool palette.
class ToolButton extends StatelessWidget {
const ToolButton({
super.key,
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final enabled = onPressed != null;
final iconColor = !enabled
? cs.onSurfaceVariant.withValues(alpha: 0.38)
: selected
? cs.onSecondaryContainer
: cs.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 22, color: iconColor),
),
),
);
}
}
/// A thin vertical divider between palette groups.
class PaletteDivider extends StatelessWidget {
const PaletteDivider({super.key, required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class RoundIconButton extends StatelessWidget {
const RoundIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,681 @@
// lib/editor/canvas/pen_slide_screen.dart
//
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
// (PenCanvas) over each slide image, with per-slide normalized strokes and
// prev/next navigation. Export to PDF maps the normalized strokes into each
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
// to PDF is how annotations are kept), matching the previous behavior.
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../pdf/slide_export.dart';
import '../ui/page_nav_shortcuts.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenSlideScreen extends StatefulWidget {
const PenSlideScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
@override
State<PenSlideScreen> createState() => _PenSlideScreenState();
}
class _PenSlideScreenState extends State<PenSlideScreen> {
int _slideIndex = 0;
final Map<int, List<PenStroke>> _strokesBySlide = {};
final Map<int, List<List<PenStroke>>> _undo = {};
final Map<int, List<List<PenStroke>>> _redo = {};
/// Intrinsic pixel size of each slide image, loaded async so the page rect
/// keeps the slide's aspect (no distortion). Null until loaded.
Map<int, Size>? _slideSizes;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// Highlighter keeps its own color (not a pen slot).
Color _highlighterColor = Colors.orange;
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
bool _allowFingerDrawing = false;
bool _needsCenter = true;
bool _showSlider = false;
double? _scrub;
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _highlighterWidthFraction = 0.02;
static const Size _fallbackSlide = Size(1600, 900);
static const List<Color> _palette = kInkPalette;
int get _slideCount => widget.slideImagePaths.length;
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
@override
void initState() {
super.initState();
PenInputService.instance.start();
_loadSlideSizes();
_initPenConfig();
}
Future<void> _loadSlideSizes() async {
final sizes = <int, Size>{};
for (var i = 0; i < _slideCount; i++) {
try {
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
sizes[i] = Size(
frame.image.width.toDouble(), frame.image.height.toDouble());
frame.image.dispose();
} catch (_) {
sizes[i] = _fallbackSlide;
}
}
if (mounted) setState(() => _slideSizes = sizes);
}
Future<void> _initPenConfig() async {
final results = await Future.wait([
PenConfigController.load(),
PenSlotsController.load(),
]);
final config = results[0] as PenConfigController;
final slots = results[1] as PenSlotsController;
if (!mounted) {
config.dispose();
slots.dispose();
return;
}
config.addListener(_onPenConfigChanged);
slots.addListener(_onPenSlotsChanged);
setState(() {
_penConfig = config;
_penSlots = slots;
_allowFingerDrawing = config.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_redo[_slideIndex]?.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, index),
...replacements,
...strokes.sublist(index + 1),
];
});
}
void _performUndo() {
final stack = _undo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _performRedo() {
final stack = _redo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
void _goToSlide(int i) {
final clamped = i.clamp(0, _slideCount - 1);
if (clamped == _slideIndex) return;
setState(() {
_slideIndex = clamped;
_needsCenter = true;
_selectedStroke = null; // selection is per-slide
});
}
// ── Export ──────────────────────────────────────────────────────────────────
Future<void> _exportPdf() async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
final dir = await _exportDir();
final base = p.basenameWithoutExtension(widget.filePath);
final outPath = p.join(dir.path, '${base}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _exportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
final sizes = _slideSizes ?? const {};
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
try {
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imageSize = sizes[i] ??
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
final draw = slideDrawRect(
Size(pageSize.width, pageSize.height), imageSize);
page.graphics.drawImage(bitmap, draw);
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final p1 = stroke.points[j];
final p2 = stroke.points[j + 1];
path.addLine(
normToSlide(p1.x, p1.y, draw),
normToSlide(p2.x, p2.y, draw),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(PdfColor(r, g, b),
width: slideStrokeWidth(stroke.width, draw)),
);
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// ── Layout ────────────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokesBySlide[_slideIndex] = next;
});
}
void _deleteSelected() {
final idx = _selectedStroke;
final strokes = _currentStrokes;
if (idx == null || idx < 0 || idx >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, idx),
...strokes.sublist(idx + 1),
];
_selectedStroke = null;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return pageNavShortcuts(
onPrevious:
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
onNext: _slideIndex < _slideCount - 1
? () => _goToSlide(_slideIndex + 1)
: null,
onFirst: _slideCount > 0 ? () => _goToSlide(0) : null,
onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null,
child: Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.picture_as_pdf_outlined,
tooltip: 'Export to PDF',
onPressed: _exportPdf,
),
),
),
),
if (_slideCount > 0)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildSlidePill(cs),
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
final sizes = _slideSizes;
if (sizes == null) {
return const Center(child: CircularProgressIndicator());
}
if (_slideCount == 0) {
return const Center(child: Text('No slides.'));
}
final slide = sizes[_slideIndex] ?? _fallbackSlide;
return LayoutBuilder(
builder: (context, constraints) {
final fitW = constraints.maxWidth / slide.width;
final fitH = constraints.maxHeight / slide.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(slide.width * scale, slide.height * scale);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_slideIndex),
pageSize: pageSize,
strokes: _currentStrokes,
transformationController: _transform,
tool: editorToolToCanvas(_tool),
brush: _penBrush,
shapeKind: _shapeKind,
color: _color,
strokeWidth: _strokeWidth,
selectedStrokeIndex: _selectedStroke,
onSelectStroke: _selectStroke,
onMoveStroke: _moveStroke,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
pageWidget: Image.file(
File(widget.slideImagePaths[_slideIndex]),
fit: BoxFit.fill,
gaplessPlayback: true,
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed:
(_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed:
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
setState(() => _tool = EditorToolKind.brush);
}
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildSlidePill(ColorScheme cs) {
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_showSlider && _slideCount > 1)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Slider(
min: 1,
max: _slideCount.toDouble(),
value: (_scrub ?? (_slideIndex + 1).toDouble())
.clamp(1, _slideCount.toDouble()),
divisions: _slideCount > 1 ? _slideCount - 1 : null,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
final target = v.round() - 1;
setState(() {
_scrub = v;
_slideIndex = target;
});
_goToSlide(target);
setState(() {
_scrub = null;
_showSlider = false;
});
},
),
),
),
),
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous slide',
icon: const Icon(Icons.chevron_left),
onPressed:
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
),
TextButton(
onPressed: _slideCount > 1
? () => setState(() => _showSlider = !_showSlider)
: null,
child: Text(
'$shown / $_slideCount',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next slide',
icon: const Icon(Icons.chevron_right),
onPressed: _slideIndex < _slideCount - 1
? () => _goToSlide(_slideIndex + 1)
: null,
),
],
),
),
),
],
);
}
}

View File

@@ -6,6 +6,8 @@
import 'package:flutter/foundation.dart';
import '../engine/brush.dart';
/// A single captured sample of a stroke.
///
/// [x]/[y] are normalized to the page rectangle ([0,1]).
@@ -35,6 +37,7 @@ class PenStroke {
required this.color,
required this.width,
required this.kind,
this.brush = BrushKind.fountainPen,
});
/// Normalized points (see [PenPoint]).
@@ -48,4 +51,12 @@ class PenStroke {
final double width;
final PenStrokeKind kind;
/// The brush this stroke was drawn with — drives the perfect_freehand
/// geometry (thinning/streamline/smoothing/caps) at render time via
/// [brushProfileFor]. The pressure pre-warp ([BrushProfile.pressureGamma]) is
/// applied at CAPTURE so it is already baked into [points]. Defaults to
/// [BrushKind.fountainPen] (the legacy pen visual) so old/loaded strokes keep
/// rendering as before.
final BrushKind brush;
}

View File

@@ -0,0 +1,103 @@
// lib/editor/canvas/pinch_scale_solver.dart
//
// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the
// re-baseline behavior (the subtle part) can be unit-tested without simulating
// a flaky multi-pointer gesture.
//
// The pinch is driven ABSOLUTELY: the scale shown is always
// scaleStart * (rawScale / rawScaleAtBaseline)
// where `scaleStart` is the matrix scale captured at the current baseline and
// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that
// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative
// scale so it reads 1.0 at the baseline instant.
//
// Why this matters: a baseline is captured at gesture start AND on every
// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At
// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is
// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
//
// Soft-clamp (Surface 2026-08-05 diag): a HARD drop of frames whose per-step
// ratio exceeds the glitch band caused an avalanche — lastRaw never advanced,
// so every subsequent frame also dropped while pdfrx/live zoom still crawled.
// [softClampedPinchStep] always returns an applied scale, clamping the step,
// and tells the caller to re-anchor when a spike was clipped.
import 'package:flutter/foundation.dart' show clampDouble;
/// Returns the absolute target scale for a pinch frame.
///
/// [scaleStart] — matrix scale captured at the current baseline.
/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that
/// baseline (1.0 at gesture start; the live value at a re-baseline).
/// [rawScale] — the recognizer's current cumulative `details.scale`.
/// Result is clamped to [minScale, maxScale].
double absolutePinchScale({
required double scaleStart,
required double rawScaleAtBaseline,
required double rawScale,
required double minScale,
required double maxScale,
}) {
final double cumulative =
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
return clampDouble(scaleStart * cumulative, minScale, maxScale);
}
/// Result of one soft-clamped pinch step.
class SoftPinchStep {
const SoftPinchStep({
required this.appliedScale,
required this.reanchor,
required this.spiked,
});
/// Scale to write into the matrix / controller this frame.
final double appliedScale;
/// When true the caller must set `scaleStart = appliedScale` and
/// `rawScaleAtBaseline = rawScale` so absolute tracking does not keep
/// fighting the clamp on later frames.
final bool reanchor;
/// True when the ideal absolute target was clipped by the per-step band.
final bool spiked;
}
/// Soft-clamp the per-frame scale change instead of dropping the frame.
///
/// Ideal scale comes from [absolutePinchScale]. The step from
/// [lastAppliedScale] is then limited to `[1/maxStepRatio, maxStepRatio]`.
/// Spikes still get partially applied (smooth catch-up) and the caller
/// re-anchors so the next frame starts clean.
SoftPinchStep softClampedPinchStep({
required double scaleStart,
required double rawScaleAtBaseline,
required double rawScale,
required double lastAppliedScale,
required double minScale,
required double maxScale,
required double maxStepRatio,
}) {
final ideal = absolutePinchScale(
scaleStart: scaleStart,
rawScaleAtBaseline: rawScaleAtBaseline,
rawScale: rawScale,
minScale: minScale,
maxScale: maxScale,
);
if (lastAppliedScale <= 0 || maxStepRatio <= 1.0) {
return SoftPinchStep(appliedScale: ideal, reanchor: false, spiked: false);
}
final lo = lastAppliedScale / maxStepRatio;
final hi = lastAppliedScale * maxStepRatio;
final applied = clampDouble(ideal, lo, hi);
final spiked = applied != ideal;
return SoftPinchStep(
appliedScale: clampDouble(applied, minScale, maxScale),
reanchor: spiked,
spiked: spiked,
);
}

View File

@@ -0,0 +1,291 @@
// lib/editor/canvas/sticky_note_overlay.dart
//
// Page-anchored paper sticky: sized/positioned by the parent in page space,
// shares the editor brush/color/tool, locks inner pan/zoom so writing feels
// like drawing on the sticky surface itself.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_stroke.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../engine/brush.dart';
import '../input/pen_config.dart' show kDefaultEraserRadius;
import '../notebook/ink_stroke_adapter.dart';
import '../persistence/sidecar_repository.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_stroke.dart';
/// Default world size for a fresh sticky scratchpad (absolute px).
const Size kStickyWorldSize = Size(1200, 900);
/// Floating sticky-note card glued to a PDF page (parent supplies pixel size).
class StickyNoteOverlay extends StatefulWidget {
const StickyNoteOverlay({
super.key,
required this.link,
required this.repo,
required this.onClose,
required this.onDelete,
required this.onDragPx,
required this.onResizePx,
this.brush = BrushKind.ballpoint,
this.color = const Color(0xFF1A1A1A),
this.tool = EditorToolKind.brush,
this.strokeWidth = 0.008,
this.allowFingerDrawing = false,
});
final ScratchLink link;
final SidecarRepository repo;
final VoidCallback onClose;
final VoidCallback onDelete;
/// Header drag delta in viewer/page pixels.
final void Function(double dx, double dy) onDragPx;
/// Corner resize delta in viewer/page pixels.
final void Function(double dx, double dy) onResizePx;
final BrushKind brush;
final Color color;
final EditorToolKind tool;
final double strokeWidth;
final bool allowFingerDrawing;
@override
State<StickyNoteOverlay> createState() => _StickyNoteOverlayState();
}
class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
static const _uuid = Uuid();
final TransformationController _transform = TransformationController();
List<InkStroke> _strokes = [];
Size _world = kStickyWorldSize;
Timer? _saveTimer;
bool _dirty = false;
@override
void initState() {
super.initState();
final pad = widget.repo.scratchpadFor(widget.link.id);
if (pad != null) {
_world = Size(pad.canvasWidth, pad.canvasHeight);
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
}
}
@override
void dispose() {
_saveTimer?.cancel();
if (_dirty) {
_persist(flush: true);
}
_transform.dispose();
super.dispose();
}
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 600), () => _persist());
}
Future<void> _persist({bool flush = false}) async {
if (!_dirty && !flush) return;
widget.repo.scheduleScratchpadSave(
widget.link.id,
SidecarScratchpad(
canvasWidth: _world.width,
canvasHeight: _world.height,
strokes: List<InkStroke>.of(_strokes),
),
);
_dirty = false;
if (flush) await widget.repo.flush();
}
void _onStrokeComplete(PenStroke pen) {
setState(() {
_strokes = [
..._strokes,
inkStrokeFromPen(pen, _world, id: _uuid.v4(), createdAt: DateTime.now()),
];
});
_scheduleSave();
}
void _onErase(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
final next = List<InkStroke>.of(_strokes)..removeAt(index);
for (final r in replacements) {
next.insert(
index,
inkStrokeFromPen(r, _world, id: _uuid.v4(), createdAt: DateTime.now()),
);
}
_strokes = next;
});
_scheduleSave();
}
Future<void> _close() async {
_saveTimer?.cancel();
await _persist(flush: true);
widget.onClose();
}
CanvasTool get _canvasTool {
switch (widget.tool) {
case EditorToolKind.eraser:
return CanvasTool.eraser;
case EditorToolKind.select:
return CanvasTool.select;
case EditorToolKind.highlighter:
return CanvasTool.highlighter;
case EditorToolKind.brush:
case EditorToolKind.shape:
case EditorToolKind.text:
return CanvasTool.pen;
}
}
BrushKind get _canvasBrush =>
widget.tool == EditorToolKind.highlighter
? BrushKind.highlighter
: widget.brush;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
elevation: 8,
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFFFF8E1),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Column(
children: [
_StickyHeader(
onDragDelta: widget.onDragPx,
onClose: _close,
onDelete: () async {
await _persist(flush: true);
widget.onDelete();
},
cs: cs,
),
Expanded(
child: PenCanvas(
pageSize: _world,
strokes: penStrokesFromInk(_strokes, _world),
transformationController: _transform,
tool: _canvasTool,
brush: _canvasBrush,
color: widget.color,
strokeWidth: widget.strokeWidth,
eraserRadius: kDefaultEraserRadius,
allowFingerDrawing: widget.allowFingerDrawing,
scaleEnabled: false,
panEnabled: false,
minScale: 1.0,
maxScale: 1.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
),
),
],
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy),
child: MouseRegion(
cursor: SystemMouseCursors.resizeUpLeftDownRight,
child: SizedBox(
width: 28,
height: 28,
child: Icon(
Icons.south_east,
size: 16,
color: cs.onSurface.withValues(alpha: 0.45),
),
),
),
),
),
],
),
);
}
}
class _StickyHeader extends StatelessWidget {
const _StickyHeader({
required this.onDragDelta,
required this.onClose,
required this.onDelete,
required this.cs,
});
final void Function(double dx, double dy) onDragDelta;
final VoidCallback onClose;
final VoidCallback onDelete;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
const Icon(Icons.drag_indicator, size: 18),
const SizedBox(width: 4),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
Text(
'拖标题定位 · 角缩放',
style: TextStyle(
fontSize: 10,
color: cs.onSurface.withValues(alpha: 0.5),
),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onDelete,
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onClose,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,292 @@
// lib/editor/engine/brush.dart
//
// Data-driven, Krita-compatible brush model — the extensibility seam for the
// pen engine. Each [BrushKind] maps to an immutable [BrushProfile] that fully
// describes how a stroke is captured (pressure pre-warp) and rendered
// (perfect_freehand geometry params + caps/taper). Adding a brush = adding one
// const entry to [kBrushPresets]; no render-path branching.
//
// Mirrors Krita's sensor→curve design (Pixel brush: each property is driven by
// a sensor through a response curve). Here the response curve is a pure power
// law `p^gamma` applied to pressure BEFORE perfect_freehand (rnote's
// `PressureCurve`: Pow2 = quadratic, Sqrt = √p), and the geometry knobs are
// perfect_freehand's `thinning`/`streamline`/`smoothing`/caps. A future `.kpp`
// (Krita brush preset) importer can produce [BrushProfile]s from the same
// fields — see TODO(brush-kpp-import).
//
// Source spec: docs/research/pen-brush-spec.md §1 (rnote pressure curve) and §4
// (per-brush perfect_freehand option tables). The numbers below are lifted from
// that spec verbatim.
import 'dart:ui' show Color, BlendMode;
import 'package:freezed_annotation/freezed_annotation.dart';
/// The four selectable brushes. Extensible: add a kind here + a preset in
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
///
/// The `@JsonValue` names are the STABLE on-disk identifiers persisted in the
/// sidecar (`EditorStroke.brush`); they are decoupled from the Dart enum
/// identifiers so renaming a constant here never breaks existing sidecars. A
/// brush whose stored name is unknown (e.g. a future brush opened by an older
/// build) is read back as [fountainPen] (see `EditorStroke.brush`'s JsonKey).
enum BrushKind {
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
@JsonValue('fountainPen')
fountainPen,
/// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell").
@JsonValue('ballpoint')
ballpoint,
/// Broad, flat width, translucent, square (uncapped) ends.
@JsonValue('highlighter')
highlighter,
/// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy.
@JsonValue('pencil')
pencil,
}
/// Immutable, const description of one brush.
///
/// The capture path reads [pressureGamma] (the rnote power-law warp applied via
/// `PressureCurve(gamma: pressureGamma)` BEFORE perfect_freehand) and the render
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
///
/// [opacity] / [blendMultiply] drive the painters' compositing via
/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied
/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see
/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply].
class BrushProfile {
const BrushProfile({
required this.kind,
required this.baseWidthFraction,
required this.pressureGamma,
required this.pfThinning,
required this.pfStreamline,
required this.pfSmoothing,
required this.simulatePressure,
required this.capStart,
required this.capEnd,
required this.taper,
required this.opacity,
required this.blendMultiply,
});
/// Which brush this profile is for.
final BrushKind kind;
/// Suggested base stroke width as a fraction of page width (so it scales with
/// zoom, matching `PenStroke.width`). The editors may override with their own
/// configured pen/highlighter widths; this is the spec's nominal default
/// (spec §4 diameters, expressed as a page-width fraction).
final double baseWidthFraction;
/// rnote `PressureCurve` exponent applied to raw pressure at CAPTURE, before
/// perfect_freehand. `2.0` = Pow2 (quadratic, fountain pen); `0.5` = Sqrt
/// (pencil); `1.0` = Linear (ballpoint / highlighter). Fed through the
/// existing `PressureCurve(gamma: …)` — no new pow function (spec §1).
final double pressureGamma;
/// perfect_freehand `thinning`: how strongly (pre-warped) pressure modulates
/// width. `0.0` = constant width (highlighter); high = wide dynamic range
/// (fountain pen) (spec §4).
final double pfThinning;
/// perfect_freehand `streamline`: EMA low-pass on input positions (spec §4).
final double pfStreamline;
/// perfect_freehand `smoothing`: outline corner-softening (spec §4).
final double pfSmoothing;
/// perfect_freehand `simulatePressure`: when true, fakes pressure from
/// velocity. All four presets ship `false` so REAL stylus pressure (already
/// pre-warped by [pressureGamma]) drives width (spec §4). The render path
/// still falls back to simulation when the device reports NO usable pressure.
final bool simulatePressure;
/// Round cap on the start of the stroke (false = square end, highlighter).
final bool capStart;
/// Round cap on the end of the stroke (false = square end, highlighter).
final bool capEnd;
/// Whether the ends taper to a point (fountain pen) (spec §4).
final bool taper;
/// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter
/// this flat value is used; ballpoint/pencil derive opacity from pressure
/// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters
/// via [resolveStrokePaint] (multiplied into the stroke color's alpha).
final double opacity;
/// Whether the brush composites with [BlendMode.multiply] (highlighter
/// build-up / marker feel). Applied by [resolveStrokePaint].
final bool blendMultiply;
}
/// The 4 brush presets, populated from the spec §4 tables.
///
/// Widths are the spec's logical-px diameters re-expressed as page-width
/// fractions against the project's ~1000px logical page (the existing
/// pen/highlighter widths are 0.006 / 0.02). Fountain pen ≈ pen (0.006),
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
/// the fountainPen/highlighter presets (no regression).
const Map<BrushKind, BrushProfile> kBrushPresets = {
// Fountain pen — Surface feel: lower streamline (less lag), higher thinning
// for expressive width, pressure pre-warped to p² (Pow2 / quadratic).
// Solid ink (opacity 1.0). See also tipVelocityWidthScale (ink starvation).
BrushKind.fountainPen: BrushProfile(
kind: BrushKind.fountainPen,
baseWidthFraction: 0.006,
pressureGamma: 2.0,
pfThinning: 0.75,
pfStreamline: 0.22,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
capEnd: true,
// Light taper only; full taper made Chinese characters look frayed.
taper: false,
opacity: 1.0,
blendMultiply: false,
),
// Ballpoint — near-constant width, SOLID opacity. Lower streamline (~0.35)
// for lower latency; thinning 0.12 keeps width almost flat.
BrushKind.ballpoint: BrushProfile(
kind: BrushKind.ballpoint,
baseWidthFraction: 0.0022,
pressureGamma: 1.0,
pfThinning: 0.12,
pfStreamline: 0.35,
pfSmoothing: 0.5,
simulatePressure: false,
capStart: true,
capEnd: true,
taper: false,
opacity: 1.0,
blendMultiply: false,
),
// Highlighter — flat width (thinning 0), square (uncapped) ends, translucent +
// multiply build-up. streamline 0.3 for a bit less lag on broad strokes.
BrushKind.highlighter: BrushProfile(
kind: BrushKind.highlighter,
baseWidthFraction: 0.02,
pressureGamma: 1.0,
pfThinning: 0.0,
pfStreamline: 0.3,
pfSmoothing: 0.4,
simulatePressure: false,
capStart: false,
capEnd: false,
taper: false,
opacity: 0.35,
blendMultiply: true,
),
// Pencil — soft graphite via √p, moderate translucency. streamline 0.25.
BrushKind.pencil: BrushProfile(
kind: BrushKind.pencil,
baseWidthFraction: 0.003,
pressureGamma: 0.5,
pfThinning: 0.45,
pfStreamline: 0.25,
pfSmoothing: 0.45,
simulatePressure: false,
capStart: true,
capEnd: true,
taper: false,
opacity: 0.88,
blendMultiply: false,
),
};
/// Resolve the [BrushProfile] for [kind] (always present; const map).
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) -------------
//
// perfect_freehand produces a single closed fill polygon per stroke; the
// painters then fill it with ONE Paint. These helpers resolve that Paint's
// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel
// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in
// the freehand path. Both render paths (PenCanvas + the PDF
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
/// stroke's AVERAGE pressure [pressureAvg].
///
/// Krita-inspired (not a full brush engine): ballpoint stays essentially solid
/// (width carries the pressure feel); pencil uses a soft √p curve capped below
/// 1 so light strokes stay grey without multiply-style mud; fountain/highlighter
/// use the flat profile opacity. Per-dab / textured brushes remain deferred.
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
final p = pressureAvg.clamp(0.0, 1.0);
switch (profile.kind) {
// Solid ink — tiny residual so "hover contact" can't punch full black holes
// into overlapping strokes, but no 0.55 floor translucency stacking.
case BrushKind.ballpoint:
return (0.92 + 0.08 * p).clamp(0.0, 1.0);
// Soft graphite: √p darkens quickly under pressure, capped by profile.
case BrushKind.pencil:
final soft = 0.50 + 0.38 * _sqrt01(p);
return soft.clamp(0.0, profile.opacity);
case BrushKind.fountainPen:
case BrushKind.highlighter:
return profile.opacity.clamp(0.0, 1.0);
}
}
double _sqrt01(double v) {
if (v <= 0) return 0;
if (v >= 1) return 1;
var x = v;
for (var i = 0; i < 8; i++) {
x = 0.5 * (x + v / x);
}
return x;
}
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
/// double-counting — the profile opacity scales whatever alpha the color has.
int applyOpacityToArgb(int argb, double opacity) {
final baseAlpha = (argb >> 24) & 0xFF;
final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255);
return (scaled << 24) | (argb & 0x00FFFFFF);
}
/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every
/// painter can configure its `Paint` identically. [argb] is the stroke's stored
/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`).
///
/// - [color]: stroke color with `profile`-resolved opacity multiplied into its
/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter).
/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up:
/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke
/// is still drawn ONCE per render (single fill polygon) so its OWN self-
/// overlap never darkens — that single-draw invariant lives in the painters.
class ResolvedStrokePaint {
const ResolvedStrokePaint({required this.color, required this.blendMode});
final Color color;
final BlendMode blendMode;
}
/// Resolve the paint config for a stroke drawn with [kind]. See
/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is
/// still deferred — opacity is enough for this increment.
ResolvedStrokePaint resolveStrokePaint(
BrushKind kind,
int argb, {
double pressureAvg = 0.5,
}) {
final profile = brushProfileFor(kind);
final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg);
return ResolvedStrokePaint(
color: Color(applyOpacityToArgb(argb, opacity)),
blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver,
);
}

View File

@@ -0,0 +1,32 @@
// lib/editor/engine/pen_physics.dart
//
// Simple physical tip model: modulate stroke width by tip velocity so fountain
// ink feels slightly thinner when moving fast (starvation), while ballpoint
// stays nearly velocity-invariant.
//
// Wired at capture: PenCanvas._toNormalized and PenEditorScreen._pressureWithPhysics.
import 'brush.dart';
/// Modulate width fraction by tip velocity (page-normalized units per second).
///
/// Fountain: faster → slightly thinner (ink starvation feel).
/// Ballpoint: nearly ignore velocity.
/// Pencil: mild thinning at speed.
/// Highlighter: ignore velocity (flat marker).
double tipVelocityWidthScale(BrushKind kind, double speedNormPerSec) {
final speed =
speedNormPerSec.isNaN || speedNormPerSec < 0 ? 0.0 : speedNormPerSec;
// Reference: ~2 page-widths/sec ≈ fast handwriting; clamp influence to [0,1].
final t = (speed / 2.0).clamp(0.0, 1.0);
switch (kind) {
case BrushKind.fountainPen:
return 1.0 - 0.15 * t;
case BrushKind.ballpoint:
return 1.0 - 0.02 * t;
case BrushKind.pencil:
return 1.0 - 0.08 * t;
case BrushKind.highlighter:
return 1.0;
}
}

View File

@@ -0,0 +1,143 @@
// lib/editor/engine/shape_geometry.dart
//
// Pure geometry for the SHAPE tool. Each shape is generated as a list of
// NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is
// just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo
// with NO new model or storage. Points carry a constant pressure (1.0) so the
// brush renders them at a steady width (shapes don't taper with pressure).
//
// All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the
// caller wraps the points in a PenStroke with the current brush color/width.
import 'dart:math' as math;
import '../canvas/editor_tool.dart';
import '../canvas/pen_stroke.dart';
import 'brush.dart';
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
/// last point repeats the first ⇒ [kEllipseSamples] + 1 total points.
const int kEllipseSamples = 48;
/// Constant pressure baked into every shape point so the brush renders a steady
/// width (no pressure taper for geometric shapes).
const double _kShapePressure = 1.0;
/// Geometric shapes must NOT inherit fountain thinning/taper — force a near-
/// constant-width brush so line/rect/ellipse look like ruler ink.
const BrushKind kShapeBrush = BrushKind.ballpoint;
/// Generate the normalized polyline for [kind] spanning [start] → [end].
///
/// * [ShapeKind.line] → 2 points.
/// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the
/// first), an axis-aligned box whose opposite corners are [start]/[end].
/// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed
/// in the [start]→[end] bounding box.
/// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments,
/// emitted as a single polyline so it renders as one stroke.
List<PenPoint> generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) {
switch (kind) {
case ShapeKind.line:
return [
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
case ShapeKind.rectangle:
final l = math.min(start.x, end.x);
final r = math.max(start.x, end.x);
final t = math.min(start.y, end.y);
final b = math.max(start.y, end.y);
return [
PenPoint(l, t, _kShapePressure),
PenPoint(r, t, _kShapePressure),
PenPoint(r, b, _kShapePressure),
PenPoint(l, b, _kShapePressure),
PenPoint(l, t, _kShapePressure), // close
];
case ShapeKind.ellipse:
final cx = (start.x + end.x) / 2;
final cy = (start.y + end.y) / 2;
final rx = (end.x - start.x).abs() / 2;
final ry = (end.y - start.y).abs() / 2;
final pts = <PenPoint>[];
for (var i = 0; i <= kEllipseSamples; i++) {
final a = (i / kEllipseSamples) * 2 * math.pi;
pts.add(PenPoint(
cx + rx * math.cos(a),
cy + ry * math.sin(a),
_kShapePressure,
));
}
return pts;
case ShapeKind.arrow:
// Shaft start→end, then back up the shaft to draw the two head barbs so
// the whole arrow is one continuous polyline (no pen lifts).
final dx = end.x - start.x;
final dy = end.y - start.y;
final len = math.sqrt(dx * dx + dy * dy);
final pts = <PenPoint>[
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft
// Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction,
// [_kArrowHead] of the shaft length (capped) long.
final ang = math.atan2(dy, dx);
final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax);
for (final sign in const [1.0, -1.0]) {
final a = ang + math.pi + sign * _kArrowAngle;
pts.add(PenPoint(
end.x + head * math.cos(a),
end.y + head * math.sin(a),
_kShapePressure,
));
pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip
}
return pts;
}
}
/// Arrowhead barb length as a fraction of the shaft length.
const double _kArrowHeadFraction = 0.25;
/// Hard cap on the barb length (normalized) so a long arrow's head stays sane.
const double _kArrowHeadMax = 0.06;
/// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°).
const double _kArrowAngle = 0.5;
/// Return a copy of [points] translated by ([dx],[dy]) in normalized coords,
/// preserving pressure/tilt. Used by the SELECT tool to drag a stroke.
List<PenPoint> translatePoints(List<PenPoint> points, double dx, double dy) =>
[for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)];
/// A translated copy of [stroke] (its points shifted by [dx],[dy]); color,
/// width, kind, and brush are preserved.
PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke(
points: translatePoints(stroke.points, dx, dy),
color: stroke.color,
width: stroke.width,
kind: stroke.kind,
brush: stroke.brush,
);
/// Tight normalized bounds of [stroke]'s points, or null when it has no points.
/// Used by the SELECT tool to draw the selection bounding box.
({double left, double top, double right, double bottom})? penStrokeBounds(
PenStroke stroke) {
if (stroke.points.isEmpty) return null;
var l = double.infinity, t = double.infinity;
var r = double.negativeInfinity, b = double.negativeInfinity;
for (final p in stroke.points) {
if (p.x < l) l = p.x;
if (p.y < t) t = p.y;
if (p.x > r) r = p.x;
if (p.y > b) b = p.y;
}
return (left: l, top: t, right: r, bottom: b);
}

View File

@@ -11,6 +11,7 @@ import 'dart:ui';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'brush.dart';
import 'stroke_model.dart';
/// Canonical default for perfect_freehand's `thinning` (how strongly pressure
@@ -24,12 +25,11 @@ const double kDefaultPenThinning = 0.85;
/// perfect_freehand input-smoothing parameters, shared (single source of truth)
/// by the on-screen painter and the export path so the two can never diverge
/// (guarded by the screen==export parity test). [kPenStreamline] lowers the
/// per-point lag from freehand's 0.5 default to 0.32: at 0.5 a quick flick lags
/// so far behind the pen that a short fast stroke collapsed toward its start and
/// rendered as a dot ("写字识别成单击") and the pen felt sluggish; 0.32 tracks the
/// real path closely (crisper, lower-latency feel) while still damping digitizer
/// jitter. [kPenSmoothing] keeps freehand's 0.5 corner rounding.
const double kPenStreamline = 0.32;
/// per-point lag from freehand's 0.5 default to 0.28: paired with
/// [StrokePredictor] lookahead this tracks the Surface Pen more tightly while
/// still damping digitizer jitter. [kPenSmoothing] keeps freehand's 0.5 corner
/// rounding.
const double kPenStreamline = 0.28;
const double kPenSmoothing = 0.5;
/// THE single perfect_freehand outline recipe — the raw outline points for a
@@ -51,23 +51,68 @@ List<Offset> freehandOutlinePoints({
required bool hasRealPressure,
required bool isComplete,
double thinning = kDefaultPenThinning,
BrushProfile? brush,
}) {
if (pfPoints.isEmpty) return const <Offset>[];
return pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: size,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure -> don't simulate; no pressure -> let freehand
// fake it based on velocity (highlighter never simulates). perfect_freehand
// 2.x honors real pressure when simulatePressure is false.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
options: brush != null
// Brush-driven path: every geometry knob (thinning / streamline /
// smoothing / caps / taper / simulatePressure) comes from the
// BrushProfile so each brush renders distinctly. Pressure was already
// pre-warped by the brush's gamma at CAPTURE (PressureCurve), so the
// pre-warp is baked into pfPoints — perfect_freehand stays linear here.
// simulatePressure is forced true only when the device gave us NO real
// pressure, so velocity-thinning still kicks in for mice/trackpads.
? _optionsFromBrush(brush,
size: size,
isComplete: isComplete,
hasRealPressure: hasRealPressure)
: pf.StrokeOptions(
size: size,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure -> don't simulate; no pressure -> let
// freehand fake it based on velocity (highlighter never simulates).
// perfect_freehand 2.x honors real pressure when simulatePressure
// is false.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
);
}
/// Build perfect_freehand [pf.StrokeOptions] from a [BrushProfile] (spec §4).
///
/// Geometry only: [BrushProfile.opacity] / [BrushProfile.blendMultiply] are
/// consumed by the painters' [paintForEditorStroke] / `paintForStroke` (via
/// [resolveStrokePaint]), NOT here — this stays a pure outline recipe.
pf.StrokeOptions _optionsFromBrush(
BrushProfile brush, {
required double size,
required bool isComplete,
required bool hasRealPressure,
}) {
return pf.StrokeOptions(
size: size,
thinning: brush.pfThinning,
smoothing: brush.pfSmoothing,
streamline: brush.pfStreamline,
// Honor REAL pressure (already gamma-pre-warped at capture). Only fall back
// to velocity simulation when the device reported no usable pressure.
simulatePressure: brush.simulatePressure || !hasRealPressure,
start: pf.StrokeEndOptions.start(
cap: brush.capStart,
taperEnabled: brush.taper,
),
end: pf.StrokeEndOptions.end(
cap: brush.capEnd,
taperEnabled: brush.taper,
),
isComplete: isComplete,
);
}
@@ -101,6 +146,11 @@ Path buildStrokeOutline(
)
.toList();
// Resolve the brush so each stroke renders with its own geometry. The
// pressure pre-warp ([BrushProfile.pressureGamma]) was already applied at
// capture, so it is baked into the points here.
final brush = brushProfileFor(stroke.brush);
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: stroke.width * pageSize.width,
@@ -108,6 +158,7 @@ Path buildStrokeOutline(
hasRealPressure: stroke.points.any((p) => p.pressure != null),
isComplete: isComplete,
thinning: thinning,
brush: brush,
);
if (outline.isEmpty) return path;
@@ -118,3 +169,36 @@ Path buildStrokeOutline(
path.close();
return path;
}
/// Mean point pressure (`pressure ?? 0.5`) of an [EditorStroke], for the
/// per-stroke opacity resolution (spec §3/§4 tie ballpoint/pencil opacity to
/// pressure).
double _avgPressure(EditorStroke stroke) {
if (stroke.points.isEmpty) return 0.5;
var sum = 0.0;
for (final p in stroke.points) {
sum += p.pressure ?? 0.5;
}
return sum / stroke.points.length;
}
/// THE single fill [Paint] for an [EditorStroke], with the brush's resolved
/// opacity (multiplied into the color's alpha) and blend mode applied — closes
/// TODO(brush-opacity). Shared by the committed [Picture] and live painters so
/// the EditorStroke render path composites exactly like the PenStroke one.
///
/// The stroke is drawn as ONE fill polygon, so a highlighter's own self-overlap
/// never darkens; cross-stroke overlap darkens via [BlendMode.multiply]
/// (marker build-up). TODO(brush-texture): pencil paper grain still deferred.
Paint paintForEditorStroke(EditorStroke stroke) {
final resolved = resolveStrokePaint(
stroke.brush,
stroke.color,
pressureAvg: _avgPressure(stroke),
);
return Paint()
..color = resolved.color
..blendMode = resolved.blendMode
..style = PaintingStyle.fill
..isAntiAlias = true;
}

View File

@@ -13,6 +13,12 @@
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
// @JsonKey is applied to freezed factory parameters (e.g. EditorStroke.brush)
// for fine-grained serialization control; freezed re-emits those annotations on
// generated getters where they're valid, so suppress the source-level
// invalid_annotation_target for the whole file (the documented freezed pattern).
// ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:uuid/uuid.dart';
@@ -21,6 +27,7 @@ import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../../models/pointer_device_kind.dart';
import '../canvas/pen_stroke.dart';
import 'brush.dart';
part 'stroke_model.freezed.dart';
part 'stroke_model.g.dart';
@@ -74,6 +81,19 @@ abstract class EditorStroke with _$EditorStroke {
@Default(false) bool filled,
String? textContent,
@Default(14.0) double fontSize,
// Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
@Default(BrushKind.fountainPen)
BrushKind brush,
}) = _EditorStroke;
/// Convenience constructor that generates a uuid [id] when none is supplied.
@@ -86,6 +106,7 @@ abstract class EditorStroke with _$EditorStroke {
bool filled = false,
String? textContent,
double fontSize = 14.0,
BrushKind brush = BrushKind.fountainPen,
}) =>
EditorStroke(
id: id ?? _uuid.v4(),
@@ -96,6 +117,7 @@ abstract class EditorStroke with _$EditorStroke {
filled: filled,
textContent: textContent,
fontSize: fontSize,
brush: brush,
);
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
@@ -118,6 +140,7 @@ abstract class EditorStroke with _$EditorStroke {
},
color: stroke.color,
width: stroke.width,
brush: stroke.brush,
);
/// Lossless adapter from the freezed/JSON [InkStroke] model.
@@ -141,6 +164,11 @@ abstract class EditorStroke with _$EditorStroke {
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
// InkStroke has no brush field; derive from the tool so a loaded
// highlighter keeps the flat highlighter brush (pens → fountainPen).
brush: stroke.tool == PenTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
);
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset

View File

@@ -302,7 +302,19 @@ mixin _$EditorStroke {
double get width => throw _privateConstructorUsedError;
bool get filled => throw _privateConstructorUsedError;
String? get textContent => throw _privateConstructorUsedError;
double get fontSize => throw _privateConstructorUsedError;
double get fontSize =>
throw _privateConstructorUsedError; // Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind get brush => throw _privateConstructorUsedError;
/// Serializes this EditorStroke to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -330,6 +342,11 @@ abstract class $EditorStrokeCopyWith<$Res> {
bool filled,
String? textContent,
double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind brush,
});
}
@@ -356,6 +373,7 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
Object? brush = null,
}) {
return _then(
_value.copyWith(
@@ -391,6 +409,10 @@ class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
brush: null == brush
? _value.brush
: brush // ignore: cast_nullable_to_non_nullable
as BrushKind,
)
as $Val,
);
@@ -415,6 +437,11 @@ abstract class _$$EditorStrokeImplCopyWith<$Res>
bool filled,
String? textContent,
double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind brush,
});
}
@@ -440,6 +467,7 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
Object? brush = null,
}) {
return _then(
_$EditorStrokeImpl(
@@ -475,6 +503,10 @@ class __$$EditorStrokeImplCopyWithImpl<$Res>
? _value.fontSize
: fontSize // ignore: cast_nullable_to_non_nullable
as double,
brush: null == brush
? _value.brush
: brush // ignore: cast_nullable_to_non_nullable
as BrushKind,
),
);
}
@@ -492,6 +524,11 @@ class _$EditorStrokeImpl extends _EditorStroke {
this.filled = false,
this.textContent,
this.fontSize = 14.0,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
this.brush = BrushKind.fountainPen,
}) : _points = points,
super._();
@@ -525,10 +562,23 @@ class _$EditorStrokeImpl extends _EditorStroke {
@override
@JsonKey()
final double fontSize;
// Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@override
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
final BrushKind brush;
@override
String toString() {
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize, brush: $brush)';
}
@override
@@ -545,7 +595,8 @@ class _$EditorStrokeImpl extends _EditorStroke {
(identical(other.textContent, textContent) ||
other.textContent == textContent) &&
(identical(other.fontSize, fontSize) ||
other.fontSize == fontSize));
other.fontSize == fontSize) &&
(identical(other.brush, brush) || other.brush == brush));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -560,6 +611,7 @@ class _$EditorStrokeImpl extends _EditorStroke {
filled,
textContent,
fontSize,
brush,
);
/// Create a copy of EditorStroke
@@ -586,6 +638,11 @@ abstract class _EditorStroke extends EditorStroke {
final bool filled,
final String? textContent,
final double fontSize,
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
final BrushKind brush,
}) = _$EditorStrokeImpl;
_EditorStroke._() : super._();
@@ -607,7 +664,19 @@ abstract class _EditorStroke extends EditorStroke {
@override
String? get textContent;
@override
double get fontSize;
double get fontSize; // Brush the stroke was drawn with — drives the committed render path's
// perfect_freehand geometry + opacity/blend (resolveStrokePaint). Persisted
// as the stable `BrushKind` @JsonValue name (e.g. "ballpoint") so a
// ballpoint/highlighter/pencil stroke keeps its look across close/reopen.
// BACK-COMPAT: sidecars written before this field existed have no `brush`
// key, and an unknown name (a future brush opened by an older build) is
// tolerated — both fall back to fountainPen via the JsonKey below.
@override
@JsonKey(
defaultValue: BrushKind.fountainPen,
unknownEnumValue: BrushKind.fountainPen,
)
BrushKind get brush;
/// Create a copy of EditorStroke
/// with the given fields replaced by the non-null parameter values.

View File

@@ -52,6 +52,13 @@ _$EditorStrokeImpl _$$EditorStrokeImplFromJson(Map<String, dynamic> json) =>
filled: json['filled'] as bool? ?? false,
textContent: json['textContent'] as String?,
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
brush:
$enumDecodeNullable(
_$BrushKindEnumMap,
json['brush'],
unknownValue: BrushKind.fountainPen,
) ??
BrushKind.fountainPen,
);
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
@@ -64,6 +71,7 @@ Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
'filled': instance.filled,
'textContent': instance.textContent,
'fontSize': instance.fontSize,
'brush': _$BrushKindEnumMap[instance.brush]!,
};
const _$EditorToolEnumMap = {
@@ -71,3 +79,10 @@ const _$EditorToolEnumMap = {
EditorTool.highlighter: 'highlighter',
EditorTool.eraser: 'eraser',
};
const _$BrushKindEnumMap = {
BrushKind.fountainPen: 'fountainPen',
BrushKind.ballpoint: 'ballpoint',
BrushKind.highlighter: 'highlighter',
BrushKind.pencil: 'pencil',
};

View File

@@ -0,0 +1,56 @@
// Lightweight stroke prediction — extrapolates the next point from recent
// velocity so the live stroke tip leads the digitizer slightly (lower perceived
// latency). Not a full ink-stroke-modeler; intentionally small and testable.
import 'dart:ui';
class PredictedPoint {
const PredictedPoint(this.offset, this.pressure);
final Offset offset;
final double pressure;
}
class StrokePredictor {
StrokePredictor({this.lookaheadMs = 8});
/// How far ahead to project, in milliseconds of recent velocity.
final double lookaheadMs;
Offset? _prev;
double? _prevPressure;
DateTime? _prevAt;
Offset _velocity = Offset.zero;
void reset() {
_prev = null;
_prevPressure = null;
_prevAt = null;
_velocity = Offset.zero;
}
/// Feed a real sample; returns an optional predicted tip ahead of [point].
PredictedPoint? observe(Offset point, double pressure, {DateTime? at}) {
final now = at ?? DateTime.now();
if (_prev != null && _prevAt != null) {
final dtMs = now.difference(_prevAt!).inMicroseconds / 1000.0;
if (dtMs > 0.5 && dtMs < 80) {
final raw = (point - _prev!) * (1000.0 / dtMs);
// EMA blend to avoid jerky predictions.
_velocity = Offset(
_velocity.dx * 0.55 + raw.dx * 0.45,
_velocity.dy * 0.55 + raw.dy * 0.45,
);
}
}
_prev = point;
_prevPressure = pressure;
_prevAt = now;
if (_velocity.distance < 40) return null; // idle / slow — no predict
final tip = point + _velocity * (lookaheadMs / 1000.0);
return PredictedPoint(tip, pressure);
}
/// Last known pressure (for predicted tip).
double get lastPressure => _prevPressure ?? 0.5;
}

View File

@@ -1,90 +1,38 @@
// lib/editor/input/diagnostic_logger.dart
//
// On-device input diagnostics. When the user manually enables the diagnostic
// (the toolbar toggle), every native pen event (raw button/flag/tilt fields)
// and every zoom frame is emitted through the standard `dart:developer` log
// channel (name 'badnote.input') — capturable via `flutter run`, DevTools, or
// any log tool — AND mirrored to a text file as a fallback for the packaged
// GUI build, which has no attached console. Disabled by default (no overhead).
// Compatibility facade over [BadNoteLog] + [PenEventRing]. The PDF editor's
// toolbar toggle still calls start/stop; globally, [BadNoteLog.start] runs at
// app launch so packaged builds always have a session file.
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../../diagnostics/badnote_log.dart';
class DiagnosticLogger {
DiagnosticLogger._();
static final DiagnosticLogger instance = DiagnosticLogger._();
final List<String> _buffer = <String>[];
File? _file;
Timer? _timer;
int _epochMs = 0;
bool _verbose = false;
bool get isActive => _verbose || BadNoteLog.instance.path != null;
bool _active = false;
bool get isActive => _active;
/// Absolute path of the structured session log (preferred), else null.
String? get path => BadNoteLog.instance.path;
/// Absolute path of the current log file (shown in the overlay), or null.
String? path;
/// Begin a session. Enables the `dart:developer` log channel immediately and
/// opens the fallback file (best-effort). Safe to call repeatedly.
/// Begin a verbose input session (also ensures the global log is running).
Future<void> start() async {
if (_active) return;
_active = true; // developer.log works even if the file can't be opened
_epochMs = DateTime.now().millisecondsSinceEpoch;
developer.log('--- session start ${DateTime.now().toIso8601String()} ---',
name: 'badnote.input');
try {
Directory dir;
try {
dir = await getApplicationDocumentsDirectory();
} catch (_) {
dir = await getTemporaryDirectory();
}
final file = File('${dir.path}${Platform.pathSeparator}badnote_input_log.txt');
await file.writeAsString(
'# BadNote input diagnostic log\n'
'# started ${DateTime.now().toIso8601String()}\n'
'# columns: <ms> <kind> <fields...>\n',
flush: true,
);
_file = file;
path = file.path;
_buffer.clear();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
} catch (_) {
// File is a fallback; never break the app over it.
}
_verbose = true;
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_on');
}
/// Emit one diagnostic line through the standard log channel and the file.
void log(String line) {
if (!_active) return;
developer.log(line, name: 'badnote.input');
if (_file == null) return;
final t = DateTime.now().millisecondsSinceEpoch - _epochMs;
_buffer.add('$t $line');
if (_buffer.length >= 1000) _flush();
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
}
Future<void> _flush() async {
final file = _file;
if (file == null || _buffer.isEmpty) return;
final chunk = '${_buffer.join('\n')}\n';
_buffer.clear();
try {
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
} catch (_) {}
}
/// Flush and stop. The file remains on disk for retrieval.
Future<void> stop() async {
if (!_active) return;
_active = false;
_timer?.cancel();
_timer = null;
await _flush();
if (!_verbose) return;
_verbose = false;
BadNoteLog.instance.info(LogSubsystem.diag, 'verbose_input_off');
await BadNoteLog.instance.flush();
}
}

View File

@@ -5,6 +5,11 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import 'pressure_curve.dart' show kNaturalPressureGamma;
/// Default eraser radius as a fraction of page width (the legacy fixed value,
/// now the default of the configurable [PenConfig.eraserRadius]).
const double kDefaultEraserRadius = 0.02;
/// Action that can be triggered by a hardware pen button or the eraser end.
enum PenButtonAction {
@@ -13,6 +18,12 @@ enum PenButtonAction {
undo,
toggleTool,
pan,
/// Rising-edge: switch to the universal stroke [select] tool (OneNote-like).
select,
/// Hold to temporarily enable PDF text selection.
selectText,
}
/// Immutable configuration for pen input behaviour.
@@ -20,20 +31,24 @@ enum PenButtonAction {
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
class PenConfig {
const PenConfig({
this.sideButton = PenButtonAction.eraser,
this.sideButton = PenButtonAction.selectText,
this.eraserEnd = PenButtonAction.eraser,
this.pressureGamma = 1.0,
this.pressureGamma = kNaturalPressureGamma,
this.palmRejectionMs = 150.0,
this.fingerDrawing = false,
this.penWidth = 0.004,
this.highlighterWidth = 0.02,
this.pressureSensitivity = kDefaultPenThinning,
this.eraserRadius = kDefaultEraserRadius,
this.eraserWholeStroke = false,
}) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0,
'pressureGamma must be in [0.3, 3.0]'),
assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0,
'palmRejectionMs must be in [0, 500]'),
assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0,
'pressureSensitivity must be in [0, 1]');
'pressureSensitivity must be in [0, 1]'),
assert(eraserRadius >= 0.005 && eraserRadius <= 0.1,
'eraserRadius must be in [0.005, 0.1]');
/// Which action fires when the side barrel button is held.
final PenButtonAction sideButton;
@@ -66,6 +81,16 @@ class PenConfig {
/// export golden are unchanged.
final double pressureSensitivity;
/// Eraser radius as a fraction of page width. Range [0.005, 0.1], default
/// [kDefaultEraserRadius]. Controls both the live erase hit area and the
/// on-screen eraser cursor.
final double eraserRadius;
/// When true the eraser removes a WHOLE stroke on contact (OneNote-style
/// stroke eraser); when false it does a partial / segment erase (the default,
/// rnote-style point eraser).
final bool eraserWholeStroke;
PenConfig copyWith({
PenButtonAction? sideButton,
PenButtonAction? eraserEnd,
@@ -75,6 +100,8 @@ class PenConfig {
double? penWidth,
double? highlighterWidth,
double? pressureSensitivity,
double? eraserRadius,
bool? eraserWholeStroke,
}) {
return PenConfig(
sideButton: sideButton ?? this.sideButton,
@@ -85,6 +112,8 @@ class PenConfig {
penWidth: penWidth ?? this.penWidth,
highlighterWidth: highlighterWidth ?? this.highlighterWidth,
pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity,
eraserRadius: eraserRadius ?? this.eraserRadius,
eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke,
);
}
@@ -97,6 +126,8 @@ class PenConfig {
'penWidth': penWidth,
'highlighterWidth': highlighterWidth,
'pressureSensitivity': pressureSensitivity,
'eraserRadius': eraserRadius,
'eraserWholeStroke': eraserWholeStroke,
};
factory PenConfig.fromJson(Map<String, dynamic> json) {
@@ -107,13 +138,17 @@ class PenConfig {
eraserEnd:
PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ??
PenButtonAction.eraser,
pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0,
pressureGamma:
(json['pressureGamma'] as num?)?.toDouble() ?? kNaturalPressureGamma,
palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0,
fingerDrawing: json['fingerDrawing'] as bool? ?? false,
penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004,
highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02,
pressureSensitivity:
(json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning,
eraserRadius:
(json['eraserRadius'] as num?)?.toDouble() ?? kDefaultEraserRadius,
eraserWholeStroke: json['eraserWholeStroke'] as bool? ?? false,
);
}
@@ -129,7 +164,9 @@ class PenConfig {
fingerDrawing == other.fingerDrawing &&
penWidth == other.penWidth &&
highlighterWidth == other.highlighterWidth &&
pressureSensitivity == other.pressureSensitivity;
pressureSensitivity == other.pressureSensitivity &&
eraserRadius == other.eraserRadius &&
eraserWholeStroke == other.eraserWholeStroke;
@override
int get hashCode => Object.hash(
@@ -141,6 +178,8 @@ class PenConfig {
penWidth,
highlighterWidth,
pressureSensitivity,
eraserRadius,
eraserWholeStroke,
);
}
@@ -161,6 +200,9 @@ class PenConfigController extends ChangeNotifier {
/// The SharedPreferences key under which [PenConfig] JSON is stored.
static const prefsKey = 'pen_config_v1';
/// Marker so the legacy-gamma migration in [load] runs at most once.
static const _gammaMigratedKey = 'pen_config_gamma_migrated_v1';
final SharedPreferences _prefs;
PenConfig _value;
@@ -184,6 +226,18 @@ class PenConfigController extends ChangeNotifier {
config = const PenConfig();
}
}
// One-time migration: before this build, pressureGamma was never applied to
// strokes (a dead slider), so a stored 1.0 is the legacy inert default, not
// a deliberate "linear feel" choice. Upgrade it ONCE to the natural curve so
// the pen feels right out of the box. Guarded by a marker key so that, after
// migrating, the user is free to set gamma back to 1.0 and have it stick.
if (!(prefs.getBool(_gammaMigratedKey) ?? false)) {
if (config.pressureGamma == 1.0) {
config = config.copyWith(pressureGamma: kNaturalPressureGamma);
await prefs.setString(prefsKey, jsonEncode(config.toJson()));
}
await prefs.setBool(_gammaMigratedKey, true);
}
return PenConfigController._(prefs, config);
}
@@ -236,6 +290,18 @@ class PenConfigController extends ChangeNotifier {
}
/// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1].
/// Sets [PenConfig.eraserRadius]. Clamped to [0.005, 0.1].
Future<void> setEraserRadius(double radius) async {
_value = _value.copyWith(eraserRadius: radius.clamp(0.005, 0.1));
await _persist();
}
/// Sets [PenConfig.eraserWholeStroke] (true = OneNote-style stroke eraser).
Future<void> setEraserWholeStroke(bool whole) async {
_value = _value.copyWith(eraserWholeStroke: whole);
await _persist();
}
Future<void> setPressureSensitivity(double sensitivity) async {
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
notifyListeners();

View File

@@ -2,27 +2,16 @@
//
// Dart side of the native Windows pen observer (`windows/runner/pen_channel.cpp`).
//
// WHY THIS EXISTS: Flutter 3.44 on Windows delivers stylus PRESSURE but drops
// the pen's barrel button, eraser/inverted end, and tilt (it does not map
// POINTER_PEN_FLAG_* into `PointerEvent.buttons`/`invertedStylus`/`tilt`). The
// native plugin observes WM_POINTER + GetPointerPenInfo and streams the missing
// hardware state over an EventChannel; this service latches the LATEST value.
//
// CORRELATION (plan M2): we do NOT key state by Win32 pointerId joined to
// Flutter's `event.pointer` — those are different id spaces. Only one pen is
// active at a time, so a single latched "current" state is correct. The native
// observer runs at the TOP of the window proc (BEFORE Flutter synthesizes its
// pointer event, plan M1), so by the time Dart's pointer-down handler reads
// [current], the latch already reflects that exact contact — no hover required.
//
// GRACEFUL DEGRADATION: on non-Windows (or if the channel is silent) the stream
// simply never emits / errors are swallowed, and [current] stays [PenHardwareState.empty]
// so the canvas falls back to its normal Flutter-pressure drawing.
// Streams barrel / eraser / tilt / PRESSURE from WM_POINTER + GetPointerPenInfo.
// Flutter's PointerEvent.pressure on Windows is unreliable (often flat); native
// pressure (0..1024 → [0,1]) is preferred when [PenHardwareState.pressureValid].
import 'dart:async';
import 'package:flutter/services.dart';
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import 'diagnostic_logger.dart';
/// Latest hardware pen state delivered by the native observer.
@@ -33,22 +22,20 @@ class PenHardwareState {
this.eraser = false,
this.tiltX = 0.0,
this.tiltY = 0.0,
this.pressure = 0.0,
this.pressureValid = false,
});
/// Side barrel button held.
final bool barrel;
/// Pen flipped to the inverted (eraser) end.
final bool inverted;
/// Hardware eraser flag set.
final bool eraser;
/// Tilt in degrees along X / Y ([-90, 90]); 0 = perpendicular.
final double tiltX;
final double tiltY;
/// Combined tilt magnitude in degrees (for [PenPoint.tilt]).
/// Normalized stylus pressure in [0,1] when [pressureValid] is true.
final double pressure;
final bool pressureValid;
double get tiltMagnitude {
final t = tiltX * tiltX + tiltY * tiltY;
return t <= 0 ? 0.0 : _sqrt(t);
@@ -57,12 +44,10 @@ class PenHardwareState {
static const empty = PenHardwareState();
}
// Avoids importing dart:math for a single call.
double _sqrt(double v) {
if (v <= 0) return 0;
var x = v;
var last = 0.0;
// Newton's method; converges fast for the small (<=~127) magnitudes here.
for (var i = 0; i < 12 && x != last; i++) {
last = x;
x = 0.5 * (x + v / x);
@@ -70,33 +55,34 @@ double _sqrt(double v) {
return x;
}
/// Latches the most recent [PenHardwareState] streamed by the native pen plugin.
///
/// Use the singleton [PenInputService.instance]. Call [start] once (e.g. in the
/// editor's `initState`) and [stop] on dispose.
class PenInputService {
PenInputService._();
/// Process-wide singleton (one physical pen).
static final PenInputService instance = PenInputService._();
/// Must match the native `EventChannel` name in `pen_channel.cpp`.
static const EventChannel _channel = EventChannel('badnote/pen');
StreamSubscription<dynamic>? _sub;
PenHardwareState _current = PenHardwareState.empty;
/// The latest hardware pen state (or [PenHardwareState.empty] when no native
/// data has arrived — non-Windows, plugin absent, or channel silent).
PenHardwareState get current => _current;
/// Whether the native channel has delivered at least one event (i.e. the
/// native pen plugin is present and active). Used to prefer hardware signals
/// over the Flutter fallback only when they are actually available.
bool get isActive => _active;
bool _active = false;
// Native-side diagnostics (see windows/runner/pen_channel.cpp).
final List<VoidCallback> _listeners = <VoidCallback>[];
/// Notify when native hardware state changes (barrel / pressure / tilt).
void addListener(VoidCallback listener) => _listeners.add(listener);
void removeListener(VoidCallback listener) => _listeners.remove(listener);
void _notifyListeners() {
for (final l in List<VoidCallback>.of(_listeners)) {
l();
}
}
int _diagPtr = 0;
int _diagPen = 0;
int _diagMouse = 0;
@@ -106,47 +92,42 @@ class PenInputService {
int _orPenMask = 0;
int _btnChangeLast = 0;
int _tiltAbsMax = 0;
double _pressureMaxSeen = 0;
String _hex(int v) => '0x${v.toRadixString(16)}';
/// Multi-line native readout for the diagnostic overlay. The OR-accumulated
/// flag fields are the ground truth for which field carries the side/eraser
/// button: e.g. orPtrFlags with bit 0x20 (POINTER_FLAG_SECONDBUTTON) set means
/// the barrel button IS detectable.
String get debugSummary => _active
? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=${_hex(_diagMsg)}'
'\n orPtrFlags=${_hex(_orPtrFlags)} orPenFlags=${_hex(_orPenFlags)}'
' mask=${_hex(_orPenMask)} btnChg=$_btnChangeLast tiltMax=$_tiltAbsMax'
'\n pressure=${_current.pressureValid ? _current.pressure.toStringAsFixed(3) : "n/a"}'
' maxSeen=${_pressureMaxSeen.toStringAsFixed(3)}'
: 'native: channel silent (no events)';
/// Begins listening to the native channel. Idempotent; safe on any platform
/// (no-ops where the channel has no handler).
void start() {
if (_sub != null) return;
try {
_sub = _channel.receiveBroadcastStream().listen(
_onEvent,
onError: (Object _) {
// No native handler (e.g. Linux/macOS) or transient error — ignore
// and keep the empty fallback state.
},
onError: (Object _) {},
cancelOnError: false,
);
} catch (_) {
// receiveBroadcastStream can throw synchronously if the platform side is
// unavailable; degrade silently.
}
} catch (_) {}
}
void _onEvent(dynamic event) {
if (event is! Map) return;
final flags = (event['flags'] as num?)?.toInt() ?? 0;
final pressureValid = ((event['pressureValid'] as num?)?.toInt() ?? 0) != 0;
final pressure = (event['pressure'] as num?)?.toDouble() ?? 0.0;
_current = PenHardwareState(
barrel: flags & 0x1 != 0,
inverted: flags & 0x2 != 0,
eraser: flags & 0x4 != 0,
tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0,
tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0,
pressure: pressure.clamp(0.0, 1.0),
pressureValid: pressureValid,
);
_diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr;
_diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen;
@@ -157,31 +138,60 @@ class PenInputService {
_orPenMask = (event['orPenMask'] as num?)?.toInt() ?? _orPenMask;
_btnChangeLast = (event['btnChangeLast'] as num?)?.toInt() ?? _btnChangeLast;
_tiltAbsMax = (event['tiltAbsMax'] as num?)?.toInt() ?? _tiltAbsMax;
_pressureMaxSeen =
(event['pressureMaxSeen'] as num?)?.toDouble() ?? _pressureMaxSeen;
if (pressureValid && pressure > _pressureMaxSeen) {
_pressureMaxSeen = pressure;
}
_active = true;
// Log a PEN line whenever the raw per-event button/flag fields change, so
// the file captures exactly which field a button press sets (without
// flooding on every high-rate WM_POINTERUPDATE).
final rawPtr = (event['rawPtrFlags'] as num?)?.toInt() ?? 0;
final rawPen = (event['rawPenFlags'] as num?)?.toInt() ?? 0;
final rawMask = (event['rawPenMask'] as num?)?.toInt() ?? 0;
final btnChange = (event['btnChange'] as num?)?.toInt() ?? 0;
final key = '$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY}';
final key =
'$rawPtr,$rawPen,$rawMask,$btnChange,${_current.tiltX},${_current.tiltY},'
'${pressureValid ? pressure.toStringAsFixed(2) : "x"}';
if (key != _lastPenLogKey) {
_lastPenLogKey = key;
PenEventRing.instance.recordHardware(
barrel: _current.barrel,
eraser: _current.eraser,
inverted: _current.inverted,
tiltX: _current.tiltX,
tiltY: _current.tiltY,
);
BadNoteLog.instance.debug(
LogSubsystem.penNative,
'pen_hw',
fields: {
'ptrFlags': '0x${rawPtr.toRadixString(16)}',
'penFlags': '0x${rawPen.toRadixString(16)}',
'mask': '0x${rawMask.toRadixString(16)}',
'btnChg': btnChange,
'tiltX': _current.tiltX,
'tiltY': _current.tiltY,
'pressure': pressureValid ? pressure : null,
'pressureValid': pressureValid,
'barrel': _current.barrel,
'eraser': _current.eraser,
'inverted': _current.inverted,
},
);
DiagnosticLogger.instance.log(
'PEN ptrFlags=0x${rawPtr.toRadixString(16)} '
'penFlags=0x${rawPen.toRadixString(16)} '
'mask=0x${rawMask.toRadixString(16)} btnChg=$btnChange '
'tilt=${_current.tiltX.toStringAsFixed(0)},${_current.tiltY.toStringAsFixed(0)} '
'p=${pressureValid ? pressure.toStringAsFixed(3) : "n/a"} '
'msg=0x${_diagMsg.toRadixString(16)} resolved=0x${flags.toRadixString(16)}',
);
}
_notifyListeners();
}
String _lastPenLogKey = '';
/// Stops listening and resets state.
void stop() {
_sub?.cancel();
_sub = null;

View File

@@ -0,0 +1,213 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/brush.dart';
/// OneNote-style independent pen slot: brush + color + thickness together.
///
/// Selecting a slot restores all three; color dots / thickness controls edit
/// only the active slot.
class PenSlot {
const PenSlot({
required this.id,
required this.brush,
required this.color,
required this.width,
});
final String id;
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
final BrushKind brush;
final Color color;
/// Stroke width as a fraction of page width.
final double width;
PenSlot copyWith({
String? id,
BrushKind? brush,
Color? color,
double? width,
}) {
return PenSlot(
id: id ?? this.id,
brush: brush ?? this.brush,
color: color ?? this.color,
width: width ?? this.width,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'brush': brush.name,
'color': color.toARGB32(),
'width': width,
};
factory PenSlot.fromJson(Map<String, dynamic> json) {
final brushName = json['brush'] as String? ?? '';
return PenSlot(
id: json['id'] as String? ?? 'slot_0',
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
color: Color(json['color'] as int? ?? 0xFF000000),
width: (json['width'] as num?)?.toDouble() ?? 0.006,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PenSlot &&
runtimeType == other.runtimeType &&
id == other.id &&
brush == other.brush &&
color.toARGB32() == other.color.toARGB32() &&
width == other.width;
@override
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
}
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
List<PenSlot> kDefaultPenSlots() => const [
PenSlot(
id: 'slot_0',
brush: BrushKind.fountainPen,
color: Colors.black,
width: 0.006,
),
PenSlot(
id: 'slot_1',
brush: BrushKind.ballpoint,
color: Colors.blue,
width: 0.0022,
),
PenSlot(
id: 'slot_2',
brush: BrushKind.pencil,
color: Colors.green,
width: 0.003,
),
];
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
const double kThicknessSmall = 0.0022;
const double kThicknessMedium = 0.006;
const double kThicknessLarge = 0.012;
/// Allowed range for slot stroke width (page-width fraction).
const double kPenSlotWidthMin = 0.001;
const double kPenSlotWidthMax = 0.05;
/// Manages independent [PenSlot]s with SharedPreferences persistence.
///
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
class PenSlotsController extends ChangeNotifier {
PenSlotsController._(this._prefs, this._slots, this._activeId);
/// SharedPreferences key for the slots JSON blob.
static const prefsKey = 'pen_slots_v1';
final SharedPreferences _prefs;
List<PenSlot> _slots;
String _activeId;
List<PenSlot> get slots => List.unmodifiable(_slots);
String get activeId => _activeId;
PenSlot get active {
for (final s in _slots) {
if (s.id == _activeId) return s;
}
return _slots.first;
}
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
/// JSON.
static Future<PenSlotsController> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(prefsKey);
var slots = kDefaultPenSlots();
var activeId = slots.first.id;
if (raw != null) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final list = map['slots'] as List<dynamic>?;
if (list != null && list.isNotEmpty) {
slots = [
for (final e in list)
PenSlot.fromJson(e as Map<String, dynamic>),
];
}
final storedActive = map['activeId'] as String?;
if (storedActive != null &&
slots.any((s) => s.id == storedActive)) {
activeId = storedActive;
} else {
activeId = slots.first.id;
}
} catch (_) {
slots = kDefaultPenSlots();
activeId = slots.first.id;
}
}
return PenSlotsController._(prefs, slots, activeId);
}
Future<void> _persist() async {
await _prefs.setString(
prefsKey,
jsonEncode({
'activeId': _activeId,
'slots': [for (final s in _slots) s.toJson()],
}),
);
}
int _indexOfActive() {
final i = _slots.indexWhere((s) => s.id == _activeId);
return i >= 0 ? i : 0;
}
void _replaceActive(PenSlot next) {
final i = _indexOfActive();
_slots = [..._slots]..[i] = next;
}
/// Selects [id] as the active slot (restores brush + color + width).
Future<void> select(String id) async {
if (!_slots.any((s) => s.id == id)) return;
if (_activeId == id) return;
_activeId = id;
notifyListeners();
await _persist();
}
/// Sets the active slot's color.
Future<void> setActiveColor(Color c) async {
_replaceActive(active.copyWith(color: c));
notifyListeners();
await _persist();
}
/// Sets the active slot's stroke width (clamped).
Future<void> setActiveWidth(double w) async {
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
_replaceActive(active.copyWith(width: clamped));
notifyListeners();
await _persist();
}
/// Sets the active slot's brush kind.
Future<void> setActiveBrush(BrushKind b) async {
if (b == BrushKind.highlighter) return;
_replaceActive(active.copyWith(brush: b));
notifyListeners();
await _persist();
}
}

View File

@@ -8,33 +8,105 @@
// - [gamma]: the response exponent — γ<1 makes light touches register more
// width (more sensitive), γ>1 requires firmer pressure (less sensitive).
//
// Named [PressureCurveShape] presets mirror rnote-style curves (linear / soft /
// Pow2 / cubic / log / sqrt) via [PressureCurve.shaped]. Logarithmic uses
// ln(1+k·p)/ln(1+k); all others use p^gamma.
//
// Pure value type (widget-free, storage-free) so the full mapping is unit
// tested; PenConfig / the canvas wire it later (the wiring touches the live
// draw path and is validated on-device).
import 'dart:math' as math;
/// Default pressure-response exponent. <1 so light-to-medium pressure registers
/// more width — the responsive, rnote/OneNote-like feel — instead of the raw
/// linear mapping that made the pen feel like a pressure-sensitive finger.
const double kNaturalPressureGamma = 0.7;
/// Default minimum shaped pressure: even the lightest touch keeps ~12% of the
/// dynamic range so thin strokes have body instead of scratchy near-zero width.
const double kNaturalPressureFloor = 0.12;
/// Steepness for [PressureCurveShape.logarithmic]: `ln(1+k·p)/ln(1+k)`.
const double kLogarithmicPressureK = 9.0;
/// Named rnote-style pressure-response shapes.
enum PressureCurveShape {
/// Identity: gamma 1.
linear,
/// Light-touch sensitive: gamma ≈ 0.6.
soft,
/// rnote Pow2 / fountain: gamma 2.
quadratic,
/// gamma 3.
cubic,
/// Log curve: ln(1+k·p)/ln(1+k).
logarithmic,
/// Pencil: gamma 0.5.
sqrt,
}
/// Maps raw normalized pressure to a shaped response in `[floor, 1]`.
class PressureCurve {
const PressureCurve({this.floor = 0.0, this.gamma = 1.0})
: assert(floor >= 0.0 && floor < 1.0),
const PressureCurve({
this.floor = 0.0,
this.gamma = 1.0,
this.shape,
}) : assert(floor >= 0.0 && floor < 1.0),
assert(gamma > 0.0);
/// Named-shape factory. Sets [gamma] for power-law shapes; logarithmic
/// ignores gamma and uses [kLogarithmicPressureK] in [apply].
factory PressureCurve.shaped(
PressureCurveShape shape, {
double floor = 0.0,
}) {
switch (shape) {
case PressureCurveShape.linear:
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
case PressureCurveShape.soft:
return PressureCurve(floor: floor, gamma: 0.6, shape: shape);
case PressureCurveShape.quadratic:
return PressureCurve(floor: floor, gamma: 2.0, shape: shape);
case PressureCurveShape.cubic:
return PressureCurve(floor: floor, gamma: 3.0, shape: shape);
case PressureCurveShape.logarithmic:
return PressureCurve(floor: floor, gamma: 1.0, shape: shape);
case PressureCurveShape.sqrt:
return PressureCurve(floor: floor, gamma: 0.5, shape: shape);
}
}
/// Minimum output (>=0, <1). 0 = full dynamic range; raise toward 1 for a
/// fixed-pressure feel (marker).
final double floor;
/// Response exponent (>0). 1 = linear; <1 = more sensitive at light pressure;
/// >1 = firmer.
/// >1 = firmer. Unused when [shape] is [PressureCurveShape.logarithmic].
final double gamma;
/// Optional named shape. When [PressureCurveShape.logarithmic], [apply] uses
/// the log formula; otherwise (or when null) uses `p^gamma`.
final PressureCurveShape? shape;
/// Linear, full-range pen response (identity).
static const PressureCurve linear = PressureCurve();
/// Shape [pressure] (clamped to [0,1]) into `[floor, 1]`.
double apply(double pressure) {
final p = pressure.isNaN ? 0.0 : pressure.clamp(0.0, 1.0);
final shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
final double shaped;
if (shape == PressureCurveShape.logarithmic) {
shaped = math.log(1.0 + kLogarithmicPressureK * p) /
math.log(1.0 + kLogarithmicPressureK);
} else {
shaped = gamma == 1.0 ? p : math.pow(p, gamma).toDouble();
}
return floor + (1.0 - floor) * shaped;
}
}

View File

@@ -0,0 +1,103 @@
// lib/editor/notebook/ink_stroke_adapter.dart
//
// Bridge between the legacy note/ppt storage model (`InkStroke`, ABSOLUTE pixel
// coordinates, `PenTool`) and the pen-first canvas model (`PenStroke`,
// NORMALIZED [0,1] coordinates, `PenStrokeKind`). The pen-first canvas is the
// single performant inking engine, so notes and slides are rebuilt on top of it
// and persisted back as `InkStroke` via this adapter.
//
// Coordinates are normalized against a logical page rectangle: ink absolute
// (x,y) -> pen (x/pageW, y/pageH) and back. Stroke width is likewise expressed
// as a fraction of the page width on the pen side and as absolute pixels on the
// ink side. Only freehand pen/highlighter strokes round-trip; shape/text
// `PenTool`s have no pen-canvas representation and are dropped (the pen-first
// note is handwriting-first — see the rebuild roadmap).
import 'dart:ui' show Size;
import '../../models/ink_point.dart';
import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../canvas/pen_stroke.dart';
import '../engine/brush.dart';
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
/// Strokes are normalized against this so they stay pinned under zoom/pan.
const Size kNoteLogicalPage = Size(1000, 1414);
/// True when [tool] is a freehand mark the pen canvas can render
/// (pen/marker/highlighter). Shapes and text are not representable.
bool isFreehandTool(PenTool tool) =>
tool == PenTool.pen ||
tool == PenTool.marker ||
tool == PenTool.highlighter;
/// Maps an ink [PenTool] to the pen-canvas stroke kind.
PenStrokeKind penKindFromTool(PenTool tool) =>
tool == PenTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen;
/// Maps a pen-canvas stroke kind back to a [PenTool].
PenTool toolFromPenKind(PenStrokeKind kind) =>
kind == PenStrokeKind.highlighter ? PenTool.highlighter : PenTool.pen;
/// Convert a stored [InkStroke] (absolute px on [page]) to a [PenStroke]
/// (normalized). Returns null for non-freehand strokes (shapes/text), which the
/// pen canvas cannot draw.
PenStroke? penStrokeFromInk(InkStroke s, Size page) {
if (!isFreehandTool(s.tool)) return null;
if (s.points.isEmpty) return null;
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return PenStroke(
points: [
for (final p in s.points)
PenPoint(p.x / w, p.y / h, p.pressure, tilt: p.tilt),
],
color: s.color,
width: s.strokeWidth / w,
kind: penKindFromTool(s.tool),
// Brush isn't persisted yet (TODO(brush-persist)); derive from the tool so
// a loaded highlighter renders with the flat highlighter brush and pens
// fall back to the fountainPen default.
brush: s.tool == PenTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
);
}
/// Convert a freshly drawn [PenStroke] (normalized) back to an [InkStroke]
/// (absolute px on [page]) for persistence. [id] and [createdAt] come from the
/// caller (uuid + clock) so this stays pure/deterministic.
InkStroke inkStrokeFromPen(
PenStroke s,
Size page, {
required String id,
required DateTime createdAt,
}) {
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return InkStroke(
id: id,
points: [
for (final p in s.points)
InkPoint(
x: p.x * w,
y: p.y * h,
pressure: p.pressure ?? 0.5,
tilt: p.tilt ?? 0.0,
timestamp: 0,
),
],
tool: toolFromPenKind(s.kind),
color: s.color,
strokeWidth: s.width * w,
createdAt: createdAt,
);
}
/// Convert a list of stored ink strokes to pen strokes, dropping the ones the
/// canvas cannot represent (shapes/text). Order is preserved.
List<PenStroke> penStrokesFromInk(Iterable<InkStroke> strokes, Size page) =>
[for (final s in strokes) penStrokeFromInk(s, page)]
.whereType<PenStroke>()
.toList();

View File

@@ -0,0 +1,61 @@
// Double-buffer helper on top of [PageTileCache] to kill zoom white-flash:
// keep painting the last good tile while a higher-DPI raster is in flight.
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'page_tile_cache.dart';
/// Holds the "last good" page image for the currently visible page so a zoom
/// settle never exposes an empty frame (plan W2 / R11).
class PageTileLayer extends ChangeNotifier {
PageTileLayer({PageTileCache? cache}) : _cache = cache ?? PageTileCache();
final PageTileCache _cache;
ui.Image? _lastGood;
TileKey? _lastKey;
PageTileCache get cache => _cache;
ui.Image? get lastGood => _lastGood;
TileKey? get lastKey => _lastKey;
/// Snap continuous zoom to a coarse DPI bucket (avoids a tile per frame).
static int dpiBucketFor(double zoom, {double baseDpi = 96, double step = 0.5}) {
final raw = zoom / step;
final snapped = raw.round().clamp(1, 16);
return (snapped * step * baseDpi).round();
}
/// Promote [image] as the last-good tile for [key].
void put(TileKey key, ui.Image image) {
_cache.put(key, image);
_lastGood = image;
_lastKey = key;
notifyListeners();
}
/// Prefer exact bucket; else fall back to last-good so zoom never blanks.
ui.Image? resolve(TileKey key) {
final hit = _cache.get(key);
if (hit != null) {
_lastGood = hit;
_lastKey = key;
return hit;
}
return _lastGood;
}
void clear() {
_lastGood = null;
_lastKey = null;
_cache.dispose();
notifyListeners();
}
@override
void dispose() {
clear();
super.dispose();
}
}

View File

@@ -0,0 +1,36 @@
// lib/editor/pdf/slide_export.dart
//
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
// just maps each normalized point into the slide image's draw rectangle on the
// PDF page — no live-widget-size guessing, which is what made the old PPT
// exporter misalign ink (see the removed ppt_annotator_screen comment).
import 'dart:ui' show Offset, Rect, Size;
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
/// exported ink lands exactly where it was drawn.
Rect slideDrawRect(Size page, Size image) {
final iw = image.width <= 0 ? 1.0 : image.width;
final ih = image.height <= 0 ? 1.0 : image.height;
final scale = (page.width / iw) < (page.height / ih)
? page.width / iw
: page.height / ih;
final drawW = iw * scale;
final drawH = ih * scale;
final offX = (page.width - drawW) / 2;
final offY = (page.height - drawH) / 2;
return Rect.fromLTWH(offX, offY, drawW, drawH);
}
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
/// inside the slide's [drawRect] on the PDF page.
Offset normToSlide(double nx, double ny, Rect drawRect) =>
Offset(drawRect.left + nx * drawRect.width,
drawRect.top + ny * drawRect.height);
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
/// page width, scaled into [drawRect].
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
normalizedWidth * drawRect.width;

View File

@@ -1,187 +0,0 @@
// lib/editor/pdf/spike_app.dart
//
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
// on-screen frame-timing HUD (median build & raster ms over the last ~120
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pdfrx/pdfrx.dart';
import 'spike_editor_pane.dart';
class SpikeApp extends StatelessWidget {
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BadNote M1 Spike',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: SpikeHome(
pdfPath: pdfPath,
denseStrokesAsset: denseStrokesAsset,
),
);
}
}
class SpikeHome extends StatefulWidget {
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
State<SpikeHome> createState() => _SpikeHomeState();
}
class _SpikeHomeState extends State<SpikeHome> {
final GlobalKey<SpikeEditorPaneState> _paneKey =
GlobalKey<SpikeEditorPaneState>();
final PdfViewerController _controller = PdfViewerController();
bool _inkLoad = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SpikeEditorPane(
key: _paneKey,
controller: _controller,
pdfPath: widget.pdfPath,
denseStrokesAsset: widget.denseStrokesAsset,
),
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
],
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
heroTag: 'inkload',
onPressed: () async {
final next = !_inkLoad;
await _paneKey.currentState?.setInkLoad(next);
setState(() => _inkLoad = next);
},
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
icon: const Icon(Icons.brush),
),
],
),
);
}
}
/// On-screen median build/raster frame-time HUD, driven by
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
/// [_window] frames for both the build (`buildDuration`) and raster
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
/// MUST #4/#5.
class FrameTimingHud extends StatefulWidget {
const FrameTimingHud({super.key});
@override
State<FrameTimingHud> createState() => _FrameTimingHudState();
}
class _FrameTimingHudState extends State<FrameTimingHud> {
static const int _window = 120;
final List<double> _build = <double>[];
final List<double> _raster = <double>[];
double _medBuild = 0;
double _medRaster = 0;
double _p95Build = 0;
double _p95Raster = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
@override
void dispose() {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
super.dispose();
}
void _onTimings(List<FrameTiming> timings) {
for (final t in timings) {
_build.add(t.buildDuration.inMicroseconds / 1000.0);
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
}
while (_build.length > _window) {
_build.removeAt(0);
}
while (_raster.length > _window) {
_raster.removeAt(0);
}
if (!mounted) return;
setState(() {
_medBuild = _percentile(_build, 50);
_medRaster = _percentile(_raster, 50);
_p95Build = _percentile(_build, 95);
_p95Raster = _percentile(_raster, 95);
});
}
static double _percentile(List<double> values, int p) {
if (values.isEmpty) return 0;
final sorted = List<double>.from(values)..sort();
final idx = ((p / 100.0) * (sorted.length - 1)).round();
return sorted[idx.clamp(0, sorted.length - 1)];
}
@override
Widget build(BuildContext context) {
Color budget(double ms) => ms <= 16.6
? Colors.greenAccent
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
return IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(8),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('frames: ${_build.length}/$_window'),
Text.rich(TextSpan(children: [
const TextSpan(text: 'build med '),
TextSpan(
text: '${_medBuild.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medBuild))),
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
])),
Text.rich(TextSpan(children: [
const TextSpan(text: 'raster med '),
TextSpan(
text: '${_medRaster.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medRaster))),
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
])),
],
),
),
),
);
}
}

View File

@@ -1,344 +0,0 @@
// lib/editor/pdf/spike_editor_pane.dart
//
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
// PdfViewer.file and exercises the three things the M1 gate must prove:
//
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
// diagnostic crosshair at normalized (0.5, 0.5) using
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
//
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
// touch scrolls and pinch zooms — same overlay, no mode switch.
//
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
// per page (from dense_strokes.json) into the page overlay so the perf
// bench can measure BUILD time with a non-trivial ui.Picture per page.
//
// This file is NOT production code and is excluded from the real editor.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
/// Normalized page-space point the diagnostic marker is painted at. The M1
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
const Offset kMarkerNormalized = Offset(0.5, 0.5);
/// A single captured pen sample in normalized page space, tagged with its page.
class _PenSample {
const _PenSample(this.pageIndex, this.normalized);
final int pageIndex;
final Offset normalized;
}
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
class SpikeEditorPane extends StatefulWidget {
const SpikeEditorPane({
super.key,
required this.pdfPath,
this.denseStrokesAsset,
this.strokesPerPage = 300,
this.strokeCountKey = '2000',
this.onViewerReady,
this.controller,
});
final String pdfPath;
final String? denseStrokesAsset;
final int strokesPerPage;
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
final String strokeCountKey;
/// Forwarded from pdfrx once the document is laid out and interactive.
final void Function(PdfDocument document, PdfViewerController controller)?
onViewerReady;
/// Optional externally-owned controller (tests drive zoom through this).
final PdfViewerController? controller;
@override
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
}
class SpikeEditorPaneState extends State<SpikeEditorPane> {
late final PdfViewerController _controller =
widget.controller ?? PdfViewerController();
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
List<_PenSample>? _activeStroke;
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
/// list of normalized polylines (one stroke = list of points).
List<List<Offset>>? _syntheticStrokes;
bool _inkLoadEnabled = false;
bool _loadingSynthetic = false;
bool get inkLoadEnabled => _inkLoadEnabled;
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
/// enable. Public so the perf bench can drive it programmatically.
Future<void> setInkLoad(bool enabled) async {
if (enabled && _syntheticStrokes == null) {
await _loadSyntheticStrokes();
}
if (mounted) setState(() => _inkLoadEnabled = enabled);
}
Future<void> _loadSyntheticStrokes() async {
final asset = widget.denseStrokesAsset;
if (asset == null || _loadingSynthetic) return;
_loadingSynthetic = true;
try {
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
final raw = await File(asset).readAsString();
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final strokesJson =
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
final result = <List<Offset>>[];
for (final s in strokesJson) {
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
final poly = <Offset>[];
for (final p in points) {
final pt = p as Map<String, dynamic>;
poly.add(Offset(
(pt['x'] as num).toDouble(),
(pt['y'] as num).toDouble(),
));
}
if (poly.length >= 2) result.add(poly);
}
_syntheticStrokes = result;
} finally {
_loadingSynthetic = false;
}
}
// --- Pen capture (viewer-level) ---------------------------------------
void _onPenEvent(PointerEvent event) {
// Convert global → document → which page + normalized page coords.
final doc = _controller.globalToDocument(event.position);
if (doc == null) return;
final hit = _documentToPage(doc);
if (hit == null) return;
if (event is PointerDownEvent) {
_activeStroke = <_PenSample>[hit];
_penStrokes.add(_activeStroke!);
setState(() {});
} else if (event is PointerMoveEvent) {
_activeStroke?.add(hit);
setState(() {});
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_activeStroke = null;
}
}
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
/// controller's page layout rects (document coordinates). Returns null if the
/// point is outside every page box.
_PenSample? _documentToPage(Offset doc) {
if (!_controller.isReady) return null;
final rects = _controller.layout.pageLayouts;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return _PenSample(i, Offset(nx, ny));
}
}
return null;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
PdfViewer.file(
widget.pdfPath,
controller: _controller,
params: PdfViewerParams(
onViewerReady: widget.onViewerReady,
// (1) Per-page overlay: diagnostic center marker + optional synthetic
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
// normalized [0,1] → zoomed pixels (plan §2.1).
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
return [
SizedBox.fromSize(
size: pageRectInViewer.size,
child: CustomPaint(
painter: _SpikeInkPainter(
synthetic:
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
),
),
),
];
},
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: PenCaptureRegion(
onPenEvent: _onPenEvent,
child: IgnorePointer(
child: CustomPaint(
size: size,
painter: _LivePenPainter(
strokes: _penStrokes,
controller: _controller,
),
),
),
),
),
];
},
),
),
],
);
}
/// Deterministic per-page slice of the synthetic stroke pool so each page
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
List<List<Offset>> _strokesForPage(int pageIndex) {
final pool = _syntheticStrokes;
if (pool == null || pool.isEmpty) return const [];
final n = widget.strokesPerPage.clamp(0, pool.length);
final start = (pageIndex * n) % pool.length;
final out = <List<Offset>>[];
for (var i = 0; i < n; i++) {
out.add(pool[(start + i) % pool.length]);
}
return out;
}
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
}
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
/// inspects.
class _SpikeInkPainter extends CustomPainter {
_SpikeInkPainter({this.synthetic});
final List<List<Offset>>? synthetic;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
// Map normalized [0,1] → zoomed pixels (plan §2.1).
canvas.scale(size.width, size.height);
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
final syn = synthetic;
if (syn != null && syn.isNotEmpty) {
final inkPaint = Paint()
..color = const Color(0x5500AAFF)
..style = PaintingStyle.stroke
// Stroke width is in normalized units post-scale; keep it page-relative
// and hairline-ish so 300 strokes are visible but cheap.
..strokeWidth = 0.002
..strokeCap = StrokeCap.round;
for (final poly in syn) {
if (poly.length < 2) continue;
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
for (var i = 1; i < poly.length; i++) {
path.lineTo(poly[i].dx, poly[i].dy);
}
canvas.drawPath(path, inkPaint);
}
}
canvas.restore();
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
// restore) so its line thickness is constant on screen and its CENTER is at
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
// this pixel.
final center = Offset(
size.width * kMarkerNormalized.dx,
size.height * kMarkerNormalized.dy,
);
final markerPaint = Paint()
..color = const Color(0xFFFF0066)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
const arm = 16.0;
canvas.drawLine(
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
canvas.drawLine(
center.translate(0, -arm), center.translate(0, arm), markerPaint);
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
}
@override
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
oldDelegate.synthetic != synthetic;
}
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
/// in normalized page space, so for each sample we re-project page→document→
/// local each paint via the controller (keeps strokes glued to pages under
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
class _LivePenPainter extends CustomPainter {
_LivePenPainter({required this.strokes, required this.controller})
: super(repaint: controller);
final List<List<_PenSample>> strokes;
final PdfViewerController controller;
@override
void paint(Canvas canvas, Size size) {
if (!controller.isReady) return;
final rects = controller.layout.pageLayouts;
final paint = Paint()
..color = const Color(0xFF1565C0)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final stroke in strokes) {
Path? path;
for (final s in stroke) {
if (s.pageIndex >= rects.length) continue;
final r = rects[s.pageIndex];
// normalized page → document
final docPt = Offset(
r.left + s.normalized.dx * r.width,
r.top + s.normalized.dy * r.height,
);
// document → local (viewer) coords
final local = controller.documentToLocal(docPt);
if (path == null) {
path = Path()..moveTo(local.dx, local.dy);
} else {
path.lineTo(local.dx, local.dy);
}
}
if (path != null) canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
}

View File

@@ -1,31 +0,0 @@
// lib/editor/pdf/spike_launcher.dart
//
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
// real Surface Pen with the user's OWN large PDFs). Remove together with the
// rest of lib/editor/pdf/spike_* once M1 is signed off.
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../canvas/pen_editor_screen.dart';
/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor.
///
/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which
/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old
/// spike_* files are left in place but no longer wired to this entry.
Future<void> openM1Spike(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
final path = result?.files.single.path;
if (path == null) return;
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: path),
),
);
}

View File

@@ -1,62 +0,0 @@
// lib/editor/pdf/spike_main.dart
//
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
//
// Launch on the Windows tablet (or any desktop with a display):
// flutter run -t lib/editor/pdf/spike_main.dart
//
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
// observable on-device.
//
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
// below before pdfrx init). pdfrx itself is initialized via
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
import 'spike_app.dart';
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
/// Filesystem path for the synthetic ink load (regenerate via
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
void main(List<String> args) {
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
pdfrxFlutterInitialize();
// Allow overriding the PDF path as the first CLI arg (otherwise the default
// 300-page bench asset relative to the project root / cwd).
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
runApp(
SpikeApp(
pdfPath: pdfPath,
denseStrokesAsset: _kDenseStrokesAsset,
),
);
}
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
/// the relative asset path works on desktop; we also try a couple of fallbacks.
String _resolvePdfPath() {
final candidates = <String>[
_kDefaultPdfRelPath,
'${Directory.current.path}/$_kDefaultPdfRelPath',
];
for (final c in candidates) {
if (File(c).existsSync()) return c;
}
// Return the primary path anyway; pdfrx will surface a clear load error.
return _kDefaultPdfRelPath;
}

View File

@@ -0,0 +1,58 @@
// lib/editor/persistence/sidecar_flush_observer.dart
//
// Phase 6 / §F.3 of the file-based storage plan (docs/plans/2026-06-24-file-
// based-storage.md): app-lifecycle flush hardening.
//
// The per-file SidecarRepository debounces writes by 800 ms. That window is the
// data-loss gap on a Windows tablet: if the OS suspends or closes the app
// before the timer fires, the last strokes never reach disk. This observer
// listens for the app leaving the foreground and DRAINS every open repo's
// pending write before the process can be frozen, so "never lose the last
// strokes on app close" holds even when the editor's own dispose() doesn't run.
//
// Registered once in BadNoteApp; it delegates to
// [SidecarRepositoryRegistry.flushAll], which awaits every repo's flush().
import 'package:flutter/widgets.dart';
import 'sidecar_repository.dart';
/// A [WidgetsBindingObserver] that flushes all open sidecar repositories when
/// the app leaves the foreground (`inactive`/`paused`/`detached`/`hidden`).
class SidecarFlushObserver with WidgetsBindingObserver {
/// Whether the observer is currently registered with the binding.
bool get isAttached => _attached;
bool _attached = false;
/// Register with [WidgetsBinding.instance] so lifecycle changes are observed.
void attach() {
if (_attached) return;
WidgetsBinding.instance.addObserver(this);
_attached = true;
}
/// Stop observing lifecycle changes.
void detach() {
if (!_attached) return;
WidgetsBinding.instance.removeObserver(this);
_attached = false;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
// Any transition out of the foreground is a potential suspend/kill point:
// drain pending sidecar writes now (the editors' own dispose() may never
// run when the OS freezes the process).
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// Fire-and-forget at the framework boundary, but each write is atomic
// and awaited inside flushAll, so a half-written sidecar is impossible.
SidecarRepositoryRegistry.flushAll();
case AppLifecycleState.resumed:
break;
}
}
}

View File

@@ -0,0 +1,440 @@
// lib/editor/persistence/sidecar_repository.dart
//
// Phase 2 of the file-based storage plan (docs/plans/2026-06-24-file-based-
// storage.md §F): the PDF editor's persistence sink. Replaces the SQLite-backed
// EditorRepository/SaveScheduler/DatabaseService trio for the pen editor with a
// single per-file SIDECAR (`<sourceFile>.badnote.json`) living ALONGSIDE the
// source file, so annotations travel with the file ("跟着文件走").
//
// Design:
// * The repository owns the canonical in-memory [BadnoteSidecar]. Callers
// mutate it through the schedule* methods, which (1) update the in-memory
// model SYNCHRONOUSLY (so the snapshot can't be corrupted by a later edit
// mid-write — the SaveScheduler discipline, §F.2) and (2) arm a single
// debounce timer that writes the WHOLE sidecar atomically (§F.1, via
// SidecarStore.writeAtomic — temp + rename + .bak).
// * The unit of debounce is the whole document sidecar (sidecars are small —
// sparse normalized strokes), one atomic write per debounce window.
// * Identity is the SOURCE FILE PATH, not the old djb2 path-hash document id.
// The sidecar IS the identity.
import 'dart:async';
import 'dart:io';
import '../../models/bookmark.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/sidecar_store.dart';
import '../engine/stroke_model.dart';
/// Suffix appended to a source-file path to form its sidecar path.
const String kSidecarSuffix = '.badnote.json';
/// Process-wide registry of OPEN [SidecarRepository] instances (Phase 6 / §F.3).
///
/// The 800 ms debounce timer only protects against losing work to a crash that
/// happens *between* edits; it does NOT help when the OS suspends or kills the
/// app mid-window (the main data-loss window on a Windows tablet). The app's
/// lifecycle observer ([SidecarFlushObserver]) calls [flushAll] on
/// `paused`/`inactive`/`detached` to drain every open repo's pending write
/// before the process can be frozen.
///
/// A repo registers itself in [open] and removes itself in [dispose], so the
/// set always reflects exactly the editors holding unsaved sidecar state.
class SidecarRepositoryRegistry {
SidecarRepositoryRegistry._();
static final Set<SidecarRepository> _open = <SidecarRepository>{};
/// The currently open repositories (for tests / inspection).
static Set<SidecarRepository> get open => Set.unmodifiable(_open);
/// Flush every open repository's pending debounced write and await them all.
/// Safe to call repeatedly; a repo with nothing pending is a cheap no-op.
static Future<void> flushAll() async {
// Snapshot first: a flush may complete and (in a future) trigger disposal,
// which mutates `_open` — iterating a copy avoids concurrent-modification.
final repos = List<SidecarRepository>.of(_open);
await Future.wait(repos.map((r) => r.flush()));
}
/// The open repository for [sourceFilePath], or null if none is open. Lets a
/// background task (e.g. OCR) write through the SAME in-memory sidecar the
/// editor holds, instead of racing it with a second open handle.
static SidecarRepository? forPath(String sourceFilePath) {
for (final r in _open) {
if (r.sourceFilePath == sourceFilePath) return r;
}
return null;
}
static void _register(SidecarRepository repo) => _open.add(repo);
static void _unregister(SidecarRepository repo) => _open.remove(repo);
/// Test-only: drop all registrations so one test can't leak repos into the
/// next. Does NOT flush or dispose them.
static void resetForTest() => _open.clear();
}
/// Per-file persistence for the pen editor. Loads the sidecar for a source file
/// path, holds it in memory, and debounces atomic writes back to disk.
class SidecarRepository {
SidecarRepository._({
required this.sourceFilePath,
required BadnoteSidecar sidecar,
Duration debounce = const Duration(milliseconds: 800),
}) : _sidecar = sidecar,
_debounce = debounce;
/// Absolute path to the annotated source file (e.g. the vault PDF copy).
final String sourceFilePath;
/// The sidecar file: `<sourceFilePath>.badnote.json`.
File get sidecarFile => File('$sourceFilePath$kSidecarSuffix');
final Duration _debounce;
BadnoteSidecar _sidecar;
Timer? _timer;
bool _disposed = false;
/// How many editors currently hold this repo. [open] reuses an existing
/// instance and bumps the count; [dispose] only tears down at zero so a
/// split-view / sticky overlay cannot clobber the PDF editor's sidecar.
int _retainCount = 1;
/// Tail of the in-flight write chain. Writes are serialized through this so a
/// debounce-timer write and a concurrent lifecycle [flush] can't race on the
/// same `.tmp`/rename (which would throw on the loser). Each write always
/// persists the LATEST snapshot, so collapsing overlapping writes is safe.
Future<void> _writeChain = Future<void>.value();
/// Open (or create) the repository for [sourceFilePath]. Reads the existing
/// sidecar if present (falling back to its `.bak`), else starts empty.
///
/// Reuses an already-open repo for the same path (retain-counted) so a
/// scratchpad overlay / split view cannot race the PDF editor with a second
/// in-memory snapshot that would overwrite scratchpad ink on flush.
static Future<SidecarRepository> open(
String sourceFilePath, {
String? docType,
Duration debounce = const Duration(milliseconds: 800),
}) async {
final existing = SidecarRepositoryRegistry.forPath(sourceFilePath);
if (existing != null && !existing._disposed) {
existing._retainCount++;
return existing;
}
final file = File('$sourceFilePath$kSidecarSuffix');
final loaded = await SidecarStore.read(file);
var sidecar = loaded ??
BadnoteSidecar(
sourceFile: _basename(sourceFilePath),
docType: docType,
pageCount: docType == 'notebook' ? 1 : null,
createdAt: DateTime.now().toUtc(),
);
// Standalone notebooks always carry an explicit pageCount (min 1). Older
// sidecars that omit it are normalized in-memory on open.
if (docType == 'notebook' &&
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
sidecar = BadnoteSidecar(
version: sidecar.version,
sourceFile: sidecar.sourceFile,
docType: sidecar.docType,
title: sidecar.title,
pageCount: 1,
rotation: sidecar.rotation,
createdAt: sidecar.createdAt,
updatedAt: sidecar.updatedAt,
strokes: sidecar.strokes,
highlights: sidecar.highlights,
texts: sidecar.texts,
bookmarks: sidecar.bookmarks,
scratchLinks: sidecar.scratchLinks,
legacyAnnotations: sidecar.legacyAnnotations,
ocrText: sidecar.ocrText,
pageText: sidecar.pageText,
legacyId: sidecar.legacyId,
background: sidecar.background,
);
}
final repo = SidecarRepository._(
sourceFilePath: sourceFilePath,
sidecar: sidecar,
debounce: debounce,
);
SidecarRepositoryRegistry._register(repo);
return repo;
}
// ── Loaded snapshot accessors (read at open) ───────────────────────────────
/// Page index → committed strokes loaded from the sidecar.
Map<int, List<EditorStroke>> get loadedStrokes => _sidecar.strokes;
/// Page index → highlight rects loaded from the sidecar.
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
/// Page index → typed-text annotations loaded from the sidecar.
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
/// Scratch-link anchors loaded from the sidecar.
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
/// Bookmarks loaded from the sidecar.
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
/// The current in-memory sidecar (for tests / inspection).
BadnoteSidecar get sidecar => _sidecar;
/// The standalone-notebook title loaded from the sidecar, or null.
String? get loadedTitle => _sidecar.title;
/// The page-background template name loaded from the sidecar, or null
/// (missing → blank, decoded by the editor).
String? get loadedBackground => _sidecar.background;
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
/// Replace the standalone-notebook title and schedule a save. No-op if the
/// title is unchanged.
void scheduleTitleSave(String title) {
if (_sidecar.title == title) return;
_replace(title: title);
}
/// Replace the page-background template (a [NoteBackground] enum name) and
/// schedule a save. No-op if unchanged.
void scheduleBackgroundSave(String background) {
if (_sidecar.background == background) return;
_replace(background: background);
}
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
/// search index). No-op if unchanged.
void scheduleOcrTextSave(String? ocrText) {
final next = (ocrText != null && ocrText.isEmpty) ? null : ocrText;
if (_sidecar.ocrText == next) return;
_replace(ocrText: next, clearOcrText: next == null);
}
/// The OCR text loaded from the sidecar, or null.
String? get loadedOcrText => _sidecar.ocrText;
/// Replace the document-body search text (PDF embedded text layer, or
/// background OCR of a rasterized PDF — see [PdfTextIndexer]) and schedule a
/// save. No-op if unchanged. An empty string is normalized to null.
void schedulePageTextSave(String? pageText) {
final next = (pageText != null && pageText.isEmpty) ? null : pageText;
if (_sidecar.pageText == next) return;
_replace(pageText: next, clearPageText: next == null);
}
/// The document-body search text loaded from the sidecar, or null.
String? get loadedPageText => _sidecar.pageText;
/// Replace the committed strokes for [pageIndex] and schedule a save.
void scheduleStrokeSave(int pageIndex, List<EditorStroke> strokes) {
final next = Map<int, List<EditorStroke>>.from(_sidecar.strokes);
if (strokes.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<EditorStroke>.of(strokes);
}
_replace(strokes: next);
}
/// Replace the standalone-notebook page count and schedule a save. No-op if
/// unchanged. [count] is clamped to at least 1.
void schedulePageCountSave(int count) {
final next = count < 1 ? 1 : count;
if (_sidecar.pageCount == next) return;
_replace(pageCount: next);
}
/// Replace the highlight rects for [pageIndex] and schedule a save.
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
if (highlights.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<SidecarHighlight>.of(highlights);
}
_replace(highlights: next);
}
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
if (texts.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<SidecarText>.of(texts);
}
_replace(texts: next);
}
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
/// and schedule a save.
void scheduleScratchLinkUpsert(ScratchLink link) {
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
final idx = next.indexWhere((s) => s.link.id == link.id);
if (idx == -1) {
next.add(SidecarScratchLink(link: link));
} else {
next[idx] = SidecarScratchLink(
link: link,
scratchpad: next[idx].scratchpad,
);
}
_replace(scratchLinks: next);
}
/// Remove the scratch-link anchor (and its embedded scratchpad) by [linkId].
void scheduleScratchLinkDelete(String linkId) {
final next = _sidecar.scratchLinks
.where((s) => s.link.id != linkId)
.toList(growable: false);
_replace(scratchLinks: List<SidecarScratchLink>.of(next));
}
/// Replace the embedded scratchpad of the anchor [linkId] and schedule a save.
/// No-op if the anchor isn't present.
void scheduleScratchpadSave(String linkId, SidecarScratchpad scratchpad) {
final next = List<SidecarScratchLink>.of(_sidecar.scratchLinks);
final idx = next.indexWhere((s) => s.link.id == linkId);
if (idx == -1) return;
next[idx] = SidecarScratchLink(link: next[idx].link, scratchpad: scratchpad);
_replace(scratchLinks: next);
}
/// Add (or update, by id) a bookmark and schedule a save.
void scheduleBookmarkUpsert(Bookmark bookmark) {
final next = List<Bookmark>.of(_sidecar.bookmarks);
final idx = next.indexWhere((b) => b.id == bookmark.id);
if (idx == -1) {
next.add(bookmark);
} else {
next[idx] = bookmark;
}
_replace(bookmarks: next);
}
/// Remove the bookmark by [bookmarkId] and schedule a save.
void scheduleBookmarkDelete(String bookmarkId) {
final next =
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
_replace(bookmarks: next);
}
/// Replace the whole bookmark list and schedule a save.
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
_replace(bookmarks: List<Bookmark>.of(bookmarks));
}
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
SidecarScratchpad? scratchpadFor(String linkId) {
for (final s in _sidecar.scratchLinks) {
if (s.link.id == linkId) return s.scratchpad;
}
return null;
}
// ── Flush / dispose ────────────────────────────────────────────────────────
/// Write any pending change immediately and wait for it (and any in-flight
/// write) to land. If the debounce timer is still armed, fire one final write
/// of the latest snapshot; otherwise just drain whatever write is in flight.
Future<void> flush() async {
if (_timer != null) {
_timer!.cancel();
_timer = null;
await _write();
return;
}
// No pending edit, but a fire-and-forget timer write may still be running:
// await the chain so the bytes are on disk before we return.
await _writeChain;
}
/// Cancel pending timers. Call [flush] first to persist pending writes.
void dispose() {
if (_disposed) return;
if (_retainCount > 1) {
_retainCount--;
return;
}
_disposed = true;
_timer?.cancel();
_timer = null;
SidecarRepositoryRegistry._unregister(this);
}
// ── Internal ───────────────────────────────────────────────────────────────
/// Build a new sidecar (touching `updatedAt`) from the current one with the
/// given fields replaced, then arm the debounce timer. Snapshot is captured
/// synchronously here so a later edit can't corrupt an in-flight write.
void _replace({
String? title,
int? pageCount,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
String? ocrText,
bool clearOcrText = false,
String? pageText,
bool clearPageText = false,
String? background,
}) {
if (_disposed) return;
_sidecar = BadnoteSidecar(
version: _sidecar.version,
sourceFile: _sidecar.sourceFile,
docType: _sidecar.docType,
title: title ?? _sidecar.title,
pageCount: pageCount ?? _sidecar.pageCount,
rotation: _sidecar.rotation,
createdAt: _sidecar.createdAt,
updatedAt: DateTime.now().toUtc(),
strokes: strokes ?? _sidecar.strokes,
highlights: highlights ?? _sidecar.highlights,
texts: texts ?? _sidecar.texts,
bookmarks: bookmarks ?? _sidecar.bookmarks,
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
pageText: clearPageText ? null : (pageText ?? _sidecar.pageText),
background: background ?? _sidecar.background,
);
_timer?.cancel();
_timer = Timer(_debounce, () {
_timer = null;
// Fire-and-forget; the next schedule simply re-arms the timer and the
// atomic write guarantees no torn file.
_write();
});
}
/// Serialize writes through [_writeChain] so overlapping flushes never race
/// on the temp file. Each link writes the latest in-memory snapshot at the
/// moment it runs; an error in one write doesn't break the chain for the next.
Future<void> _write() {
final next = _writeChain.then((_) async {
final snapshot = _sidecar;
await SidecarStore.writeAtomic(sidecarFile, snapshot);
});
// Keep the chain alive past a failed write (e.g. transient FS error).
_writeChain = next.catchError((_) {});
return next;
}
static String _basename(String path) {
final norm = path.replaceAll('\\', '/');
final i = norm.lastIndexOf('/');
return i == -1 ? norm : norm.substring(i + 1);
}
}

View File

@@ -39,13 +39,7 @@ class LiveInkPainter extends CustomPainter {
isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
canvas.drawPath(path, paintForEditorStroke(stroke));
}
@override

View File

@@ -58,13 +58,10 @@ class StaticInkPainter extends CustomPainter {
final path = buildStrokeOutline(stroke, pageSize,
isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
rec.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
// One drawPath per stroke ⇒ highlighter self-overlap never darkens;
// cross-stroke overlap darkens via BlendMode.multiply (closes
// TODO(brush-opacity); shared resolver with the live + PenCanvas paths).
rec.drawPath(path, paintForEditorStroke(stroke));
}
return recorder.endRecording();
});

10
lib/editor/stroke.dart Normal file
View File

@@ -0,0 +1,10 @@
// Canonical stroke model surface.
//
// Historical baggage had three parallel types (PenStroke / EditorStroke /
// InkStroke). New code MUST import from this barrel and prefer [EditorStroke]
// for engine/storage. UI adapters convert at the edge.
//
// Do not add a fourth model.
export '../engine/stroke_model.dart' show EditorStroke, EditorPoint, EditorTool;
export '../canvas/pen_stroke.dart' show PenStroke, PenPoint, PenStrokeKind;

View File

@@ -0,0 +1,57 @@
// lib/editor/ui/page_nav_shortcuts.dart
//
// Shared keyboard page navigation for PDF / slide / office editors.
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class PreviousPageIntent extends Intent {
const PreviousPageIntent();
}
class NextPageIntent extends Intent {
const NextPageIntent();
}
class FirstPageIntent extends Intent {
const FirstPageIntent();
}
class LastPageIntent extends Intent {
const LastPageIntent();
}
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
Widget pageNavShortcuts({
required Widget child,
required VoidCallback? onPrevious,
required VoidCallback? onNext,
VoidCallback? onFirst,
VoidCallback? onLast,
}) {
return Focus(
autofocus: true,
child: CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
onPrevious?.call(),
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
onNext?.call(),
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
onNext?.call(),
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
onNext?.call(),
if (onFirst != null)
const SingleActivator(LogicalKeyboardKey.home): onFirst,
if (onLast != null)
const SingleActivator(LogicalKeyboardKey.end): onLast,
},
child: child,
),
);
}

View File

@@ -1,6 +1,25 @@
import 'package:flutter/material.dart';
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
String _shapeNameForGamma(double gamma) {
if ((gamma - 0.6).abs() < 0.05) return 'soft';
if ((gamma - 1.0).abs() < 0.05) return 'linear';
if ((gamma - 2.0).abs() < 0.05) return 'quadratic';
if ((gamma - 3.0).abs() < 0.05) return 'cubic';
if ((gamma - 0.5).abs() < 0.05) return 'sqrt';
return 'soft';
}
PressureCurve _curveForName(String name) => switch (name) {
'linear' => PressureCurve.shaped(PressureCurveShape.linear),
'quadratic' => PressureCurve.shaped(PressureCurveShape.quadratic),
'cubic' => PressureCurve.shaped(PressureCurveShape.cubic),
'sqrt' => PressureCurve.shaped(PressureCurveShape.sqrt),
'logarithmic' => PressureCurve.shaped(PressureCurveShape.soft), // gamma proxy
_ => PressureCurve.shaped(PressureCurveShape.soft),
};
/// Shows a Material You modal bottom sheet for configuring pen input.
///
@@ -111,6 +130,32 @@ class _PenSettingsSheet extends StatelessWidget {
formatValue: (v) => v.toStringAsFixed(2),
onChanged: controller.setPressureGamma,
),
_LabeledRow(
label: 'Curve Preset (rnote)',
child: DropdownMenu<String>(
initialSelection: _shapeNameForGamma(config.pressureGamma),
onSelected: (name) {
if (name == null) return;
final shaped = _curveForName(name);
controller.setPressureGamma(shaped.gamma);
},
dropdownMenuEntries: const [
DropdownMenuEntry(value: 'soft', label: 'Soft (γ≈0.6)'),
DropdownMenuEntry(value: 'linear', label: 'Linear'),
DropdownMenuEntry(
value: 'quadratic', label: 'Quadratic / Pow2'),
DropdownMenuEntry(value: 'cubic', label: 'Cubic'),
DropdownMenuEntry(value: 'sqrt', label: 'Sqrt (pencil)'),
],
),
),
const Padding(
padding: EdgeInsets.only(left: 8, bottom: 8),
child: Text(
'笔刷自带曲线优先(钢笔=二次/Pow2铅笔=平方根)。全局 gamma 作后备。',
style: TextStyle(fontSize: 12),
),
),
// ── Input ─────────────────────────────────────────────────
_SectionHeader(
@@ -161,6 +206,31 @@ class _PenSettingsSheet extends StatelessWidget {
formatValue: (v) => v.toStringAsFixed(4),
onChanged: controller.setHighlighterWidth,
),
// ── Eraser ────────────────────────────────────────────────
_SectionHeader(
title: 'Eraser',
icon: Icons.cleaning_services_outlined,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Eraser Size',
value: config.eraserRadius,
min: 0.005,
max: 0.1,
divisions: 19,
formatValue: (v) => v.toStringAsFixed(3),
onChanged: controller.setEraserRadius,
),
SwitchListTile(
title: const Text('Stroke Eraser'),
subtitle: const Text(
'Erase a whole stroke on contact (off: erase by segment)',
),
value: config.eraserWholeStroke,
onChanged: controller.setEraserWholeStroke,
contentPadding: EdgeInsets.zero,
),
],
);
},
@@ -240,11 +310,13 @@ class _ActionDropdown extends StatelessWidget {
final ValueChanged<PenButtonAction> onChanged;
static String _label(PenButtonAction action) => switch (action) {
PenButtonAction.none => 'None',
PenButtonAction.eraser => 'Eraser',
PenButtonAction.undo => 'Undo',
PenButtonAction.toggleTool => 'Toggle Tool',
PenButtonAction.pan => 'Pan',
PenButtonAction.none => '',
PenButtonAction.eraser => '橡皮',
PenButtonAction.undo => '撤销',
PenButtonAction.toggleTool => '切换工具',
PenButtonAction.pan => '平移',
PenButtonAction.select => '选择(笔迹)',
PenButtonAction.selectText => '选择文本',
};
@override

254
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,254 @@
{
"@@locale": "en",
"appTitle": "BadNote",
"settings": "Settings",
"search": "Search",
"importPdf": "Import PDF",
"importPpt": "Import PPT",
"importFile": "Import file",
"createNotebook": "Create notebook",
"newNotebookTitle": "New notebook",
"notebookTitleHint": "Notebook title",
"create": "Create",
"untitledNote": "Untitled",
"noNotesYetHint": "No ink notes yet — tap + to create one",
"noDocumentsYet": "No documents yet — tap Import file",
"processingImport": "Importing…",
"importFailed": "Couldn't import that file: {error}",
"@importFailed": {
"placeholders": { "error": { "type": "String" } }
},
"convertNeedsLibreOffice": "Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.",
"unsupportedFileType": "Unsupported file type: {ext}",
"@unsupportedFileType": {
"placeholders": { "ext": { "type": "String" } }
},
"penCanvasBeta": "Pen Canvas (beta)",
"newNote": "New Note",
"open": "Open",
"cancel": "Cancel",
"delete": "Delete",
"deleteNoteTitle": "Delete note?",
"deleteNote": "Delete note",
"openInSplitView": "Open in Split View",
"splitViewSubtitle": "PDF reference + scratchpad",
"removeDocument": "Remove document",
"ok": "OK",
"pickColor": "Pick a color",
"clearSettingsTitle": "Clear all local settings?",
"clear": "Clear",
"settingsReset": "Settings reset to defaults",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"seedColorDesc": "Seed color for Material 3 theme",
"searchHint": "Search notes and documents...",
"searchError": "Search error: {error}",
"@searchError": {
"placeholders": { "error": { "type": "String" } }
},
"noResultsFor": "No results for \"{query}\"",
"@noResultsFor": {
"placeholders": { "query": { "type": "String" } }
},
"typeToSearch": "Type to search your notes and documents",
"sectionNotes": "Notes",
"sectionDocuments": "Documents",
"pageLabel": "Page {page}",
"@pageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"processingPptx": "Processing PPTX...",
"processingPresentation": "Processing presentation...",
"couldNotOpenPresentation": "Could not open presentation.",
"toolPen": "Pen",
"toolHighlighter": "Highlighter",
"toolEraser": "Eraser",
"brushPicker": "Brush",
"brushFountainPen": "Fountain pen",
"brushBallpoint": "Ballpoint",
"brushPencil": "Pencil",
"brushHighlighter": "Highlighter",
"toolSelect": "Select",
"toolShape": "Shape",
"shapePicker": "Shape",
"shapeLine": "Line",
"shapeRectangle": "Rectangle",
"shapeEllipse": "Ellipse",
"shapeArrow": "Arrow",
"actionDeleteSelection": "Delete selection",
"actionUndo": "Undo",
"actionRedo": "Redo",
"fingerDrawingOn": "Finger drawing ON",
"fingerDrawingOff": "Finger drawing OFF (pen only)",
"pages": "Pages",
"penSettings": "Pen settings",
"inputDiagnostic": "Input diagnostic (writes a log file)",
"back": "Back",
"previousPage": "Previous page",
"nextPage": "Next page",
"toolSelectText": "Select text",
"actionHighlightSelection": "Highlight selection",
"toolRemoveHighlight": "Remove highlight (tap a highlight)",
"toolPlaceScratchLink": "Place scratch link",
"toolText": "Text (tap or double-click to add)",
"textPlaceholder": "Type…",
"scratchLinkDeleteTitle": "Delete scratch link?",
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
"toolAddBookmark": "Add bookmark (here or at selection)",
"toolBookmarks": "Bookmarks",
"bookmarksTitle": "Bookmarks",
"bookmarksEmpty": "No bookmarks yet.",
"bookmarkDefaultLabel": "Page {page}",
"@bookmarkDefaultLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkPageLabel": "Page {page}",
"@bookmarkPageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkDeleteTitle": "Delete bookmark?",
"bookmarkDeleteBody": "This removes the saved location.",
"failedToOpenPdf": "Failed to open PDF:\n{error}",
"@failedToOpenPdf": {
"placeholders": { "error": { "type": "String" } }
},
"pdfNoPages": "PDF has no pages.",
"pageOfPages": "{current} / {total}",
"@pageOfPages": {
"placeholders": {
"current": { "type": "int" },
"total": { "type": "int" }
}
},
"libraryTab": "Library",
"boardTab": "Stickies",
"shellTagline": "Ink · Annotate · Know",
"notesSection": "Notes",
"documentsSection": "Documents",
"emptyLibraryTitle": "Nothing here yet",
"emptyLibraryBody": "Create a note, or import PDF / PPT / Word",
"diagnosticsSection": "Diagnostics",
"diagnosticsExport": "Export diagnostic pack",
"diagnosticsExportHint": "Reproduce on Surface, export, and send the zip back",
"diagnosticsToggle": "Input diagnostics overlay",
"penSettingsUnified": "Pen & ink",
"board": "Board",
"boardTitle": "Sticky Board",
"boardOpen": "Sticky note board",
"boardAddCard": "Add card",
"boardNewCardText": "New note",
"boardDeleteCard": "Delete card",
"boardDeleteCardTitle": "Delete this card?",
"boardBacklinks": "Linked from",
"boardNoBacklinks": "Nothing links here yet",
"boardDanglingLink": "No card named \"{target}\"",
"@boardDanglingLink": {
"placeholders": { "target": { "type": "String" } }
},
"close": "Close",
"vaultSetupTitle": "Choose your vault",
"vaultSetupHeadline": "Pick a folder for your notebooks",
"vaultSetupBody": "BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.",
"vaultChooseFolder": "Choose folder",
"vaultMissingTitle": "Your vault folder is missing",
"vaultMissingBody": "The folder you picked can't be found (it may have been moved, deleted, or on a drive that's unplugged). Relocate it or pick a new one.",
"vaultPickFailed": "Couldn't open the folder picker: {error}",
"@vaultPickFailed": {
"placeholders": { "error": { "type": "String" } }
},
"vaultNotWritable": "That folder isn't writable. Please choose another.",
"vaultSection": "Vault",
"vaultFolderLabel": "Vault folder",
"vaultNoneSelected": "No folder selected",
"vaultChangeFolder": "Change vault folder",
"vaultUpdated": "Vault folder updated",
"syncSection": "Sync (WebDAV)",
"syncServerUrl": "Server URL",
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
"syncUsername": "Username",
"syncPassword": "Password",
"syncRemoteFolder": "Remote folder",
"syncRemoteFolderHint": "BadNote",
"syncSave": "Save",
"syncSaved": "Sync settings saved",
"syncTestConnection": "Test connection",
"syncTestOk": "Connection OK",
"syncTestFailed": "Connection failed: {error}",
"@syncTestFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncNow": "Sync now",
"syncRunning": "Syncing…",
"syncNeverRun": "Never synced",
"syncLastRun": "Last synced: {when}",
"@syncLastRun": {
"placeholders": { "when": { "type": "String" } }
},
"syncResultSummary": "{uploaded} uploaded · {downloaded} downloaded · {conflicts} conflicts",
"@syncResultSummary": {
"placeholders": {
"uploaded": { "type": "int" },
"downloaded": { "type": "int" },
"conflicts": { "type": "int" }
}
},
"syncFailed": "Sync failed: {error}",
"@syncFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncAuto": "Sync automatically on launch",
"syncCredentialsNote": "Credentials are stored locally in plain text. Use a dedicated app password.",
"syncNotConfigured": "Enter a server URL to enable sync.",
"settingsDefaults": "Defaults",
"settingsAppearance": "Appearance",
"settingsAbout": "About",
"settingsDefaultTool": "Default tool",
"settingsDefaultColor": "Default color",
"settingsDefaultWidth": "Default stroke width",
"settingsPressureCurve": "Pressure curve",
"settingsClearConfirmBody": "This resets pen defaults and appearance. Notes and documents are not affected.",
"serverSection": "BadNote Server",
"serverUrl": "Server URL",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "Username",
"serverPassword": "Password",
"serverSave": "Save & sign in",
"serverTest": "Test connection",
"serverTestOk": "Connected · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "Connection failed: {error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "Signed in",
"serverHint": "Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.",
"boardEmptyTitle": "No sticky notes yet",
"boardEmptyBody": "Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.",
"relativeJustNow": "Just now",
"relativeMinutesAgo": "{n}m ago",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n}h ago",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "Yesterday",
"diagExported": "Diagnostic pack exported ({bytes} bytes)\nPath copied",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "Export failed: {error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "Processing OCR…",
"notebooksSection": "Notebooks",
"addBlankPage": "Blank page",
"importIntoNotebook": "Import into notebook",
"notebookMembersEmpty": "No pages yet",
"memberCount": "{count} items",
"@memberCount": {
"placeholders": { "count": { "type": "int" } }
},
"textFontSmall": "S",
"textFontMedium": "M",
"textFontLarge": "L",
"textBold": "Bold",
"textDragHint": "Drag to move"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,623 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => 'Settings';
@override
String get search => 'Search';
@override
String get importPdf => 'Import PDF';
@override
String get importPpt => 'Import PPT';
@override
String get importFile => 'Import file';
@override
String get createNotebook => 'Create notebook';
@override
String get newNotebookTitle => 'New notebook';
@override
String get notebookTitleHint => 'Notebook title';
@override
String get create => 'Create';
@override
String get untitledNote => 'Untitled';
@override
String get noNotesYetHint => 'No ink notes yet — tap + to create one';
@override
String get noDocumentsYet => 'No documents yet — tap Import file';
@override
String get processingImport => 'Importing…';
@override
String importFailed(String error) {
return 'Couldn\'t import that file: $error';
}
@override
String get convertNeedsLibreOffice =>
'Importing Word documents needs LibreOffice installed. Convert to PDF first, or install LibreOffice.';
@override
String unsupportedFileType(String ext) {
return 'Unsupported file type: $ext';
}
@override
String get penCanvasBeta => 'Pen Canvas (beta)';
@override
String get newNote => 'New Note';
@override
String get open => 'Open';
@override
String get cancel => 'Cancel';
@override
String get delete => 'Delete';
@override
String get deleteNoteTitle => 'Delete note?';
@override
String get deleteNote => 'Delete note';
@override
String get openInSplitView => 'Open in Split View';
@override
String get splitViewSubtitle => 'PDF reference + scratchpad';
@override
String get removeDocument => 'Remove document';
@override
String get ok => 'OK';
@override
String get pickColor => 'Pick a color';
@override
String get clearSettingsTitle => 'Clear all local settings?';
@override
String get clear => 'Clear';
@override
String get settingsReset => 'Settings reset to defaults';
@override
String get themeSystem => 'System';
@override
String get themeLight => 'Light';
@override
String get themeDark => 'Dark';
@override
String get seedColorDesc => 'Seed color for Material 3 theme';
@override
String get searchHint => 'Search notes and documents...';
@override
String searchError(String error) {
return 'Search error: $error';
}
@override
String noResultsFor(String query) {
return 'No results for \"$query\"';
}
@override
String get typeToSearch => 'Type to search your notes and documents';
@override
String get sectionNotes => 'Notes';
@override
String get sectionDocuments => 'Documents';
@override
String pageLabel(int page) {
return 'Page $page';
}
@override
String get processingPptx => 'Processing PPTX...';
@override
String get processingPresentation => 'Processing presentation...';
@override
String get couldNotOpenPresentation => 'Could not open presentation.';
@override
String get toolPen => 'Pen';
@override
String get toolHighlighter => 'Highlighter';
@override
String get toolEraser => 'Eraser';
@override
String get brushPicker => 'Brush';
@override
String get brushFountainPen => 'Fountain pen';
@override
String get brushBallpoint => 'Ballpoint';
@override
String get brushPencil => 'Pencil';
@override
String get brushHighlighter => 'Highlighter';
@override
String get toolSelect => 'Select';
@override
String get toolShape => 'Shape';
@override
String get shapePicker => 'Shape';
@override
String get shapeLine => 'Line';
@override
String get shapeRectangle => 'Rectangle';
@override
String get shapeEllipse => 'Ellipse';
@override
String get shapeArrow => 'Arrow';
@override
String get actionDeleteSelection => 'Delete selection';
@override
String get actionUndo => 'Undo';
@override
String get actionRedo => 'Redo';
@override
String get fingerDrawingOn => 'Finger drawing ON';
@override
String get fingerDrawingOff => 'Finger drawing OFF (pen only)';
@override
String get pages => 'Pages';
@override
String get penSettings => 'Pen settings';
@override
String get inputDiagnostic => 'Input diagnostic (writes a log file)';
@override
String get back => 'Back';
@override
String get previousPage => 'Previous page';
@override
String get nextPage => 'Next page';
@override
String get toolSelectText => 'Select text';
@override
String get actionHighlightSelection => 'Highlight selection';
@override
String get toolRemoveHighlight => 'Remove highlight (tap a highlight)';
@override
String get toolPlaceScratchLink => 'Place scratch link';
@override
String get toolText => 'Text (tap or double-click to add)';
@override
String get textPlaceholder => 'Type…';
@override
String get scratchLinkDeleteTitle => 'Delete scratch link?';
@override
String get scratchLinkDeleteBody =>
'This removes the anchor and its private scratchpad.';
@override
String get toolAddBookmark => 'Add bookmark (here or at selection)';
@override
String get toolBookmarks => 'Bookmarks';
@override
String get bookmarksTitle => 'Bookmarks';
@override
String get bookmarksEmpty => 'No bookmarks yet.';
@override
String bookmarkDefaultLabel(int page) {
return 'Page $page';
}
@override
String bookmarkPageLabel(int page) {
return 'Page $page';
}
@override
String get bookmarkDeleteTitle => 'Delete bookmark?';
@override
String get bookmarkDeleteBody => 'This removes the saved location.';
@override
String failedToOpenPdf(String error) {
return 'Failed to open PDF:\n$error';
}
@override
String get pdfNoPages => 'PDF has no pages.';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
@override
String get libraryTab => 'Library';
@override
String get boardTab => 'Stickies';
@override
String get shellTagline => 'Ink · Annotate · Know';
@override
String get notesSection => 'Notes';
@override
String get documentsSection => 'Documents';
@override
String get emptyLibraryTitle => 'Nothing here yet';
@override
String get emptyLibraryBody => 'Create a note, or import PDF / PPT / Word';
@override
String get diagnosticsSection => 'Diagnostics';
@override
String get diagnosticsExport => 'Export diagnostic pack';
@override
String get diagnosticsExportHint =>
'Reproduce on Surface, export, and send the zip back';
@override
String get diagnosticsToggle => 'Input diagnostics overlay';
@override
String get penSettingsUnified => 'Pen & ink';
@override
String get board => 'Board';
@override
String get boardTitle => 'Sticky Board';
@override
String get boardOpen => 'Sticky note board';
@override
String get boardAddCard => 'Add card';
@override
String get boardNewCardText => 'New note';
@override
String get boardDeleteCard => 'Delete card';
@override
String get boardDeleteCardTitle => 'Delete this card?';
@override
String get boardBacklinks => 'Linked from';
@override
String get boardNoBacklinks => 'Nothing links here yet';
@override
String boardDanglingLink(String target) {
return 'No card named \"$target\"';
}
@override
String get close => 'Close';
@override
String get vaultSetupTitle => 'Choose your vault';
@override
String get vaultSetupHeadline => 'Pick a folder for your notebooks';
@override
String get vaultSetupBody =>
'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.';
@override
String get vaultChooseFolder => 'Choose folder';
@override
String get vaultMissingTitle => 'Your vault folder is missing';
@override
String get vaultMissingBody =>
'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.';
@override
String vaultPickFailed(String error) {
return 'Couldn\'t open the folder picker: $error';
}
@override
String get vaultNotWritable =>
'That folder isn\'t writable. Please choose another.';
@override
String get vaultSection => 'Vault';
@override
String get vaultFolderLabel => 'Vault folder';
@override
String get vaultNoneSelected => 'No folder selected';
@override
String get vaultChangeFolder => 'Change vault folder';
@override
String get vaultUpdated => 'Vault folder updated';
@override
String get syncSection => 'Sync (WebDAV)';
@override
String get syncServerUrl => 'Server URL';
@override
String get syncServerUrlHint =>
'https://dav.example.com/remote.php/dav/files/me';
@override
String get syncUsername => 'Username';
@override
String get syncPassword => 'Password';
@override
String get syncRemoteFolder => 'Remote folder';
@override
String get syncRemoteFolderHint => 'BadNote';
@override
String get syncSave => 'Save';
@override
String get syncSaved => 'Sync settings saved';
@override
String get syncTestConnection => 'Test connection';
@override
String get syncTestOk => 'Connection OK';
@override
String syncTestFailed(String error) {
return 'Connection failed: $error';
}
@override
String get syncNow => 'Sync now';
@override
String get syncRunning => 'Syncing…';
@override
String get syncNeverRun => 'Never synced';
@override
String syncLastRun(String when) {
return 'Last synced: $when';
}
@override
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
return '$uploaded uploaded · $downloaded downloaded · $conflicts conflicts';
}
@override
String syncFailed(String error) {
return 'Sync failed: $error';
}
@override
String get syncAuto => 'Sync automatically on launch';
@override
String get syncCredentialsNote =>
'Credentials are stored locally in plain text. Use a dedicated app password.';
@override
String get syncNotConfigured => 'Enter a server URL to enable sync.';
@override
String get settingsDefaults => 'Defaults';
@override
String get settingsAppearance => 'Appearance';
@override
String get settingsAbout => 'About';
@override
String get settingsDefaultTool => 'Default tool';
@override
String get settingsDefaultColor => 'Default color';
@override
String get settingsDefaultWidth => 'Default stroke width';
@override
String get settingsPressureCurve => 'Pressure curve';
@override
String get settingsClearConfirmBody =>
'This resets pen defaults and appearance. Notes and documents are not affected.';
@override
String get serverSection => 'BadNote Server';
@override
String get serverUrl => 'Server URL';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => 'Username';
@override
String get serverPassword => 'Password';
@override
String get serverSave => 'Save & sign in';
@override
String get serverTest => 'Test connection';
@override
String serverTestOk(String version) {
return 'Connected · API $version';
}
@override
String serverTestFail(String error) {
return 'Connection failed: $error';
}
@override
String get serverLoggedIn => 'Signed in';
@override
String get serverHint =>
'Optional. Self-hosted vault assist + deferred OCR; notes stay fully offline.';
@override
String get boardEmptyTitle => 'No sticky notes yet';
@override
String get boardEmptyBody =>
'Tap + to add a card. Write [[other-card-id]] in the body to create a backlink.';
@override
String get relativeJustNow => 'Just now';
@override
String relativeMinutesAgo(int n) {
return '${n}m ago';
}
@override
String relativeHoursAgo(int n) {
return '${n}h ago';
}
@override
String get relativeYesterday => 'Yesterday';
@override
String diagExported(int bytes) {
return 'Diagnostic pack exported ($bytes bytes)\nPath copied';
}
@override
String diagExportFail(String error) {
return 'Export failed: $error';
}
@override
String get processingOcr => 'Processing OCR…';
@override
String get notebooksSection => 'Notebooks';
@override
String get addBlankPage => 'Blank page';
@override
String get importIntoNotebook => 'Import into notebook';
@override
String get notebookMembersEmpty => 'No pages yet';
@override
String memberCount(int count) {
return '$count items';
}
@override
String get textFontSmall => 'S';
@override
String get textFontMedium => 'M';
@override
String get textFontLarge => 'L';
@override
String get textBold => 'Bold';
@override
String get textDragHint => 'Drag to move';
}

View File

@@ -0,0 +1,615 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => '设置';
@override
String get search => '搜索';
@override
String get importPdf => '导入 PDF';
@override
String get importPpt => '导入 PPT';
@override
String get importFile => '导入文件';
@override
String get createNotebook => '新建笔记本';
@override
String get newNotebookTitle => '新建笔记本';
@override
String get notebookTitleHint => '笔记本标题';
@override
String get create => '创建';
@override
String get untitledNote => '未命名';
@override
String get noNotesYetHint => '还没有手写笔记——点按 + 新建';
@override
String get noDocumentsYet => '暂无文档——点按“导入文件”';
@override
String get processingImport => '正在导入…';
@override
String importFailed(String error) {
return '无法导入该文件:$error';
}
@override
String get convertNeedsLibreOffice =>
'导入 Word 文档需要安装 LibreOffice。请先转换为 PDF或安装 LibreOffice。';
@override
String unsupportedFileType(String ext) {
return '不支持的文件类型:$ext';
}
@override
String get penCanvasBeta => '手写画布(测试版)';
@override
String get newNote => '新建笔记';
@override
String get open => '打开';
@override
String get cancel => '取消';
@override
String get delete => '删除';
@override
String get deleteNoteTitle => '删除笔记?';
@override
String get deleteNote => '删除笔记';
@override
String get openInSplitView => '分屏打开';
@override
String get splitViewSubtitle => 'PDF 参考 + 草稿纸';
@override
String get removeDocument => '移除文档';
@override
String get ok => '确定';
@override
String get pickColor => '选择颜色';
@override
String get clearSettingsTitle => '清除所有本地设置?';
@override
String get clear => '清除';
@override
String get settingsReset => '设置已恢复默认';
@override
String get themeSystem => '跟随系统';
@override
String get themeLight => '浅色';
@override
String get themeDark => '深色';
@override
String get seedColorDesc => 'Material 3 主题种子色';
@override
String get searchHint => '搜索笔记和文档…';
@override
String searchError(String error) {
return '搜索出错:$error';
}
@override
String noResultsFor(String query) {
return '没有“$query”的结果';
}
@override
String get typeToSearch => '输入以搜索你的笔记和文档';
@override
String get sectionNotes => '笔记';
@override
String get sectionDocuments => '文档';
@override
String pageLabel(int page) {
return '$page';
}
@override
String get processingPptx => '正在处理 PPTX…';
@override
String get processingPresentation => '正在处理演示文稿…';
@override
String get couldNotOpenPresentation => '无法打开演示文稿。';
@override
String get toolPen => '钢笔';
@override
String get toolHighlighter => '荧光笔';
@override
String get toolEraser => '橡皮擦';
@override
String get brushPicker => '笔刷';
@override
String get brushFountainPen => '钢笔';
@override
String get brushBallpoint => '圆珠笔';
@override
String get brushPencil => '铅笔';
@override
String get brushHighlighter => '荧光笔';
@override
String get toolSelect => '选择';
@override
String get toolShape => '形状';
@override
String get shapePicker => '形状';
@override
String get shapeLine => '直线';
@override
String get shapeRectangle => '矩形';
@override
String get shapeEllipse => '椭圆';
@override
String get shapeArrow => '箭头';
@override
String get actionDeleteSelection => '删除所选';
@override
String get actionUndo => '撤销';
@override
String get actionRedo => '重做';
@override
String get fingerDrawingOn => '手指书写:开';
@override
String get fingerDrawingOff => '手指书写:关(仅手写笔)';
@override
String get pages => '页面';
@override
String get penSettings => '手写笔设置';
@override
String get inputDiagnostic => '输入诊断(写入日志文件)';
@override
String get back => '返回';
@override
String get previousPage => '上一页';
@override
String get nextPage => '下一页';
@override
String get toolSelectText => '选择文字';
@override
String get actionHighlightSelection => '高亮所选';
@override
String get toolRemoveHighlight => '移除高亮(点按高亮处)';
@override
String get toolPlaceScratchLink => '放置便签链接';
@override
String get toolText => '文字(点按或双击添加)';
@override
String get textPlaceholder => '输入文字…';
@override
String get scratchLinkDeleteTitle => '删除便签链接?';
@override
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
@override
String get toolAddBookmark => '添加书签(当前位置或所选段落)';
@override
String get toolBookmarks => '书签';
@override
String get bookmarksTitle => '书签';
@override
String get bookmarksEmpty => '还没有书签。';
@override
String bookmarkDefaultLabel(int page) {
return '$page';
}
@override
String bookmarkPageLabel(int page) {
return '$page';
}
@override
String get bookmarkDeleteTitle => '删除书签?';
@override
String get bookmarkDeleteBody => '这会移除保存的位置。';
@override
String failedToOpenPdf(String error) {
return '打开 PDF 失败:\n$error';
}
@override
String get pdfNoPages => 'PDF 没有任何页面。';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
@override
String get libraryTab => '';
@override
String get boardTab => '便利贴';
@override
String get shellTagline => '手写 · 批注 · 知识';
@override
String get notesSection => '笔记';
@override
String get documentsSection => '文档';
@override
String get emptyLibraryTitle => '还没有内容';
@override
String get emptyLibraryBody => '新建笔记,或导入 PDF / PPT / Word';
@override
String get diagnosticsSection => '诊断';
@override
String get diagnosticsExport => '导出诊断包';
@override
String get diagnosticsExportHint => '在 Surface 上复现问题后导出,发回给开发者分析';
@override
String get diagnosticsToggle => '输入诊断叠加层';
@override
String get penSettingsUnified => '笔与墨迹';
@override
String get board => '便利贴板';
@override
String get boardTitle => '便利贴板';
@override
String get boardOpen => '便利贴板';
@override
String get boardAddCard => '添加便利贴';
@override
String get boardNewCardText => '新便利贴';
@override
String get boardDeleteCard => '删除便利贴';
@override
String get boardDeleteCardTitle => '删除这张便利贴?';
@override
String get boardBacklinks => '哪些链接到这里';
@override
String get boardNoBacklinks => '暂无其他便利贴链接到这里';
@override
String boardDanglingLink(String target) {
return '没有名为“$target”的便利贴';
}
@override
String get close => '关闭';
@override
String get vaultSetupTitle => '选择笔记库';
@override
String get vaultSetupHeadline => '为你的笔记本选择一个文件夹';
@override
String get vaultSetupBody =>
'BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库vault。请选择一个你能掌控的文件夹例如同步盘这样你的笔记会跟着文件一起走。';
@override
String get vaultChooseFolder => '选择文件夹';
@override
String get vaultMissingTitle => '笔记库文件夹不见了';
@override
String get vaultMissingBody => '找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。';
@override
String vaultPickFailed(String error) {
return '无法打开文件夹选择器:$error';
}
@override
String get vaultNotWritable => '该文件夹不可写,请另选一个。';
@override
String get vaultSection => '笔记库';
@override
String get vaultFolderLabel => '笔记库文件夹';
@override
String get vaultNoneSelected => '尚未选择文件夹';
@override
String get vaultChangeFolder => '更改笔记库文件夹';
@override
String get vaultUpdated => '笔记库文件夹已更新';
@override
String get syncSection => '同步WebDAV';
@override
String get syncServerUrl => '服务器地址';
@override
String get syncServerUrlHint =>
'https://dav.example.com/remote.php/dav/files/me';
@override
String get syncUsername => '用户名';
@override
String get syncPassword => '密码';
@override
String get syncRemoteFolder => '远程文件夹';
@override
String get syncRemoteFolderHint => 'BadNote';
@override
String get syncSave => '保存';
@override
String get syncSaved => '同步设置已保存';
@override
String get syncTestConnection => '测试连接';
@override
String get syncTestOk => '连接成功';
@override
String syncTestFailed(String error) {
return '连接失败:$error';
}
@override
String get syncNow => '立即同步';
@override
String get syncRunning => '同步中…';
@override
String get syncNeverRun => '尚未同步';
@override
String syncLastRun(String when) {
return '上次同步:$when';
}
@override
String syncResultSummary(int uploaded, int downloaded, int conflicts) {
return '上传 $uploaded · 下载 $downloaded · 冲突 $conflicts';
}
@override
String syncFailed(String error) {
return '同步失败:$error';
}
@override
String get syncAuto => '启动时自动同步';
@override
String get syncCredentialsNote => '凭据以明文保存在本地,建议使用专用的应用密码。';
@override
String get syncNotConfigured => '请输入服务器地址以启用同步。';
@override
String get settingsDefaults => '默认笔迹';
@override
String get settingsAppearance => '外观';
@override
String get settingsAbout => '关于';
@override
String get settingsDefaultTool => '默认工具';
@override
String get settingsDefaultColor => '默认颜色';
@override
String get settingsDefaultWidth => '默认线宽';
@override
String get settingsPressureCurve => '压感曲线';
@override
String get settingsClearConfirmBody => '将重置笔默认值与外观设置。笔记和文档不会受影响。';
@override
String get serverSection => 'BadNote 服务器';
@override
String get serverUrl => '服务器地址';
@override
String get serverUrlHint => 'http://192.168.1.10:8080';
@override
String get serverUsername => '用户名';
@override
String get serverPassword => '密码';
@override
String get serverSave => '保存并登录';
@override
String get serverTest => '测试连接';
@override
String serverTestOk(String version) {
return '连接成功 · API $version';
}
@override
String serverTestFail(String error) {
return '连接失败:$error';
}
@override
String get serverLoggedIn => '已登录';
@override
String get serverHint => '可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。';
@override
String get boardEmptyTitle => '还没有便利贴';
@override
String get boardEmptyBody => '点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。';
@override
String get relativeJustNow => '刚刚';
@override
String relativeMinutesAgo(int n) {
return '$n 分钟前';
}
@override
String relativeHoursAgo(int n) {
return '$n 小时前';
}
@override
String get relativeYesterday => '昨天';
@override
String diagExported(int bytes) {
return '诊断包已导出($bytes 字节)\n路径已复制';
}
@override
String diagExportFail(String error) {
return '导出失败:$error';
}
@override
String get processingOcr => '正在识别文字…';
@override
String get notebooksSection => '笔记本';
@override
String get addBlankPage => '空白页';
@override
String get importIntoNotebook => '导入到笔记本';
@override
String get notebookMembersEmpty => '还没有页面';
@override
String memberCount(int count) {
return '$count';
}
@override
String get textFontSmall => '';
@override
String get textFontMedium => '';
@override
String get textFontLarge => '';
@override
String get textBold => '粗体';
@override
String get textDragHint => '拖动移动';
}

224
lib/l10n/app_zh.arb Normal file
View File

@@ -0,0 +1,224 @@
{
"@@locale": "zh",
"appTitle": "BadNote",
"settings": "设置",
"search": "搜索",
"importPdf": "导入 PDF",
"importPpt": "导入 PPT",
"importFile": "导入文件",
"createNotebook": "新建笔记本",
"newNotebookTitle": "新建笔记本",
"notebookTitleHint": "笔记本标题",
"create": "创建",
"untitledNote": "未命名",
"noNotesYetHint": "还没有手写笔记——点按 + 新建",
"noDocumentsYet": "暂无文档——点按“导入文件”",
"processingImport": "正在导入…",
"importFailed": "无法导入该文件:{error}",
"convertNeedsLibreOffice": "导入 Word 文档需要安装 LibreOffice。请先转换为 PDF或安装 LibreOffice。",
"unsupportedFileType": "不支持的文件类型:{ext}",
"penCanvasBeta": "手写画布(测试版)",
"newNote": "新建笔记",
"open": "打开",
"cancel": "取消",
"delete": "删除",
"deleteNoteTitle": "删除笔记?",
"deleteNote": "删除笔记",
"openInSplitView": "分屏打开",
"splitViewSubtitle": "PDF 参考 + 草稿纸",
"removeDocument": "移除文档",
"ok": "确定",
"pickColor": "选择颜色",
"clearSettingsTitle": "清除所有本地设置?",
"clear": "清除",
"settingsReset": "设置已恢复默认",
"themeSystem": "跟随系统",
"themeLight": "浅色",
"themeDark": "深色",
"seedColorDesc": "Material 3 主题种子色",
"searchHint": "搜索笔记和文档…",
"searchError": "搜索出错:{error}",
"noResultsFor": "没有“{query}”的结果",
"typeToSearch": "输入以搜索你的笔记和文档",
"sectionNotes": "笔记",
"sectionDocuments": "文档",
"pageLabel": "第 {page} 页",
"processingPptx": "正在处理 PPTX…",
"processingPresentation": "正在处理演示文稿…",
"couldNotOpenPresentation": "无法打开演示文稿。",
"toolPen": "钢笔",
"toolHighlighter": "荧光笔",
"toolEraser": "橡皮擦",
"brushPicker": "笔刷",
"brushFountainPen": "钢笔",
"brushBallpoint": "圆珠笔",
"brushPencil": "铅笔",
"brushHighlighter": "荧光笔",
"toolSelect": "选择",
"toolShape": "形状",
"shapePicker": "形状",
"shapeLine": "直线",
"shapeRectangle": "矩形",
"shapeEllipse": "椭圆",
"shapeArrow": "箭头",
"actionDeleteSelection": "删除所选",
"actionUndo": "撤销",
"actionRedo": "重做",
"fingerDrawingOn": "手指书写:开",
"fingerDrawingOff": "手指书写:关(仅手写笔)",
"pages": "页面",
"penSettings": "手写笔设置",
"inputDiagnostic": "输入诊断(写入日志文件)",
"back": "返回",
"previousPage": "上一页",
"nextPage": "下一页",
"toolSelectText": "选择文字",
"actionHighlightSelection": "高亮所选",
"toolRemoveHighlight": "移除高亮(点按高亮处)",
"toolPlaceScratchLink": "放置便签链接",
"toolText": "文字(点按或双击添加)",
"textPlaceholder": "输入文字…",
"scratchLinkDeleteTitle": "删除便签链接?",
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
"toolAddBookmark": "添加书签(当前位置或所选段落)",
"toolBookmarks": "书签",
"bookmarksTitle": "书签",
"bookmarksEmpty": "还没有书签。",
"bookmarkDefaultLabel": "第 {page} 页",
"@bookmarkDefaultLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkPageLabel": "第 {page} 页",
"@bookmarkPageLabel": {
"placeholders": { "page": { "type": "int" } }
},
"bookmarkDeleteTitle": "删除书签?",
"bookmarkDeleteBody": "这会移除保存的位置。",
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
"pdfNoPages": "PDF 没有任何页面。",
"pageOfPages": "{current} / {total}",
"libraryTab": "库",
"boardTab": "便利贴",
"shellTagline": "手写 · 批注 · 知识",
"notesSection": "笔记",
"documentsSection": "文档",
"emptyLibraryTitle": "还没有内容",
"emptyLibraryBody": "新建笔记,或导入 PDF / PPT / Word",
"diagnosticsSection": "诊断",
"diagnosticsExport": "导出诊断包",
"diagnosticsExportHint": "在 Surface 上复现问题后导出,发回给开发者分析",
"diagnosticsToggle": "输入诊断叠加层",
"penSettingsUnified": "笔与墨迹",
"board": "便利贴板",
"boardTitle": "便利贴板",
"boardOpen": "便利贴板",
"boardAddCard": "添加便利贴",
"boardNewCardText": "新便利贴",
"boardDeleteCard": "删除便利贴",
"boardDeleteCardTitle": "删除这张便利贴?",
"boardBacklinks": "哪些链接到这里",
"boardNoBacklinks": "暂无其他便利贴链接到这里",
"boardDanglingLink": "没有名为“{target}”的便利贴",
"close": "关闭",
"vaultSetupTitle": "选择笔记库",
"vaultSetupHeadline": "为你的笔记本选择一个文件夹",
"vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库vault。请选择一个你能掌控的文件夹例如同步盘这样你的笔记会跟着文件一起走。",
"vaultChooseFolder": "选择文件夹",
"vaultMissingTitle": "笔记库文件夹不见了",
"vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。",
"vaultPickFailed": "无法打开文件夹选择器:{error}",
"vaultNotWritable": "该文件夹不可写,请另选一个。",
"vaultSection": "笔记库",
"vaultFolderLabel": "笔记库文件夹",
"vaultNoneSelected": "尚未选择文件夹",
"vaultChangeFolder": "更改笔记库文件夹",
"vaultUpdated": "笔记库文件夹已更新",
"syncSection": "同步WebDAV",
"syncServerUrl": "服务器地址",
"syncServerUrlHint": "https://dav.example.com/remote.php/dav/files/me",
"syncUsername": "用户名",
"syncPassword": "密码",
"syncRemoteFolder": "远程文件夹",
"syncRemoteFolderHint": "BadNote",
"syncSave": "保存",
"syncSaved": "同步设置已保存",
"syncTestConnection": "测试连接",
"syncTestOk": "连接成功",
"syncTestFailed": "连接失败:{error}",
"@syncTestFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncNow": "立即同步",
"syncRunning": "同步中…",
"syncNeverRun": "尚未同步",
"syncLastRun": "上次同步:{when}",
"@syncLastRun": {
"placeholders": { "when": { "type": "String" } }
},
"syncResultSummary": "上传 {uploaded} · 下载 {downloaded} · 冲突 {conflicts}",
"@syncResultSummary": {
"placeholders": {
"uploaded": { "type": "int" },
"downloaded": { "type": "int" },
"conflicts": { "type": "int" }
}
},
"syncFailed": "同步失败:{error}",
"@syncFailed": {
"placeholders": { "error": { "type": "String" } }
},
"syncAuto": "启动时自动同步",
"syncCredentialsNote": "凭据以明文保存在本地,建议使用专用的应用密码。",
"syncNotConfigured": "请输入服务器地址以启用同步。",
"settingsDefaults": "默认笔迹",
"settingsAppearance": "外观",
"settingsAbout": "关于",
"settingsDefaultTool": "默认工具",
"settingsDefaultColor": "默认颜色",
"settingsDefaultWidth": "默认线宽",
"settingsPressureCurve": "压感曲线",
"settingsClearConfirmBody": "将重置笔默认值与外观设置。笔记和文档不会受影响。",
"serverSection": "BadNote 服务器",
"serverUrl": "服务器地址",
"serverUrlHint": "http://192.168.1.10:8080",
"serverUsername": "用户名",
"serverPassword": "密码",
"serverSave": "保存并登录",
"serverTest": "测试连接",
"serverTestOk": "连接成功 · API {version}",
"@serverTestOk": {
"placeholders": { "version": { "type": "String" } }
},
"serverTestFail": "连接失败:{error}",
"@serverTestFail": {
"placeholders": { "error": { "type": "String" } }
},
"serverLoggedIn": "已登录",
"serverHint": "可选。用于自托管 vault 协助同步与延迟 OCR日常笔记仍完全离线。",
"boardEmptyTitle": "还没有便利贴",
"boardEmptyBody": "点按右下角添加卡片。在正文写 [[另一张卡片id]] 可建立双链。",
"relativeJustNow": "刚刚",
"relativeMinutesAgo": "{n} 分钟前",
"@relativeMinutesAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeHoursAgo": "{n} 小时前",
"@relativeHoursAgo": { "placeholders": { "n": { "type": "int" } } },
"relativeYesterday": "昨天",
"diagExported": "诊断包已导出({bytes} 字节)\n路径已复制",
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
"diagExportFail": "导出失败:{error}",
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
"processingOcr": "正在识别文字…",
"notebooksSection": "笔记本",
"addBlankPage": "空白页",
"importIntoNotebook": "导入到笔记本",
"notebookMembersEmpty": "还没有页面",
"memberCount": "{count} 项",
"@memberCount": {
"placeholders": { "count": { "type": "int" } }
},
"textFontSmall": "小",
"textFontMedium": "中",
"textFontLarge": "大",
"textBold": "粗体",
"textDragHint": "拖动移动"
}

View File

@@ -1,20 +1,26 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart';
import 'editor/persistence/sidecar_flush_observer.dart';
import 'theme/app_theme.dart';
import 'diagnostics/badnote_log.dart';
import 'l10n/app_localizations.dart';
import 'providers/settings_provider.dart';
import 'screens/home_screen.dart';
import 'screens/app_shell.dart';
import 'screens/vault_setup_screen.dart';
import 'services/database_service.dart';
import 'services/vault_service.dart';
import 'services/webdav_sync_service.dart';
import 'storage/sqlite_to_sidecar_migrator.dart';
Future<void> main() async {
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
// binding before runApp so the M1 spike's PenCaptureRegion can gate
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
// region mounted it behaves exactly like the default binding.
// Kind-gated PDF pen capture MUST install before runApp — without it
// PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (required before any PdfViewer is built).
pdfrxFlutterInitialize();
@@ -25,14 +31,40 @@ Future<void> main() async {
// Initialize SharedPreferences
await SharedPreferences.getInstance();
// Always-on structured diagnostics (Surface remote debugging).
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.shell, 'app_start');
runApp(const ProviderScope(child: BadNoteApp()));
}
class BadNoteApp extends ConsumerWidget {
class BadNoteApp extends ConsumerStatefulWidget {
const BadNoteApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<BadNoteApp> createState() => _BadNoteAppState();
}
class _BadNoteAppState extends ConsumerState<BadNoteApp> {
// Phase 6 / §F.3: flush any open sidecar repos when the app is suspended or
// closed so the last strokes are never lost to an OS kill. Lives for the whole
// app lifetime (attached here, detached on app teardown).
final SidecarFlushObserver _flushObserver = SidecarFlushObserver();
@override
void initState() {
super.initState();
_flushObserver.attach();
}
@override
void dispose() {
_flushObserver.detach();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
// Material You: prefer the OS dynamic color (Windows/Android system accent);
@@ -52,19 +84,138 @@ class BadNoteApp extends ConsumerWidget {
return MaterialApp(
title: 'BadNote',
themeMode: settings.themeMode,
theme: _theme(lightScheme),
darkTheme: _theme(darkScheme),
home: const HomeScreen(),
theme: AppTheme.fromScheme(lightScheme),
darkTheme: AppTheme.fromScheme(darkScheme),
// i18n: follows the OS language (en / zh) via the system locale.
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const VaultGate(),
);
},
);
}
}
ThemeData _theme(ColorScheme scheme) => ThemeData(
colorScheme: scheme,
useMaterial3: true,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: scheme.brightness).textTheme,
/// Startup gate: shows [HomeScreen] only once a valid vault root folder has been
/// chosen. If none is set — or the saved folder no longer exists — it shows
/// [VaultSetupScreen] first (re-prompting on a missing folder rather than
/// silently scattering data elsewhere). Phase 0: records the vault path only;
/// editors still use SQLite.
class VaultGate extends StatefulWidget {
const VaultGate({super.key});
@override
State<VaultGate> createState() => _VaultGateState();
}
class _VaultGateState extends State<VaultGate> {
VaultService? _vault;
bool _valid = false;
bool _hadStoredPath = false;
bool _loading = true;
bool _migrating = false;
@override
void initState() {
super.initState();
_check();
}
Future<void> _check() async {
final vault = await VaultService.getInstance();
final valid = await vault.vaultRootValid();
if (!mounted) return;
setState(() {
_vault = vault;
_valid = valid;
// A stored-but-invalid path means the chosen folder went missing.
_hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false);
_loading = false;
});
if (valid) {
await _maybeMigrate(vault);
_maybeAutoSync(vault); // fire-and-forget; never blocks the UI
}
}
/// Optionally kick off a WebDAV sync on launch when the user has enabled
/// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a
/// bad config or offline server must never delay or crash startup. Results
/// are surfaced in Settings (last-synced time) rather than interrupting here.
Future<void> _maybeAutoSync(VaultService vault) async {
try {
final prefs = await SharedPreferences.getInstance();
final sync = WebDavSyncService(prefs);
final config = sync.config;
if (!config.autoSync || !config.isConfigured) return;
final root = vault.vaultRoot;
if (root == null || root.isEmpty) return;
final client = sync.buildClient();
if (client == null) return;
try {
await sync.syncNow(vaultRoot: root, client: client);
} finally {
client.close();
}
} catch (_) {
// Auto-sync is best-effort; swallow everything so launch is unaffected.
}
}
/// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B).
/// Gated on [VaultService.vaultMigrationDone]; a fresh install (no legacy DB)
/// is a fast no-op. The live DB is first reopened at the vault cache location
/// so post-migration reads hit the new index, never the renamed legacy file.
Future<void> _maybeMigrate(VaultService vault) async {
// Move the live cache DB to the vault location now that the root is valid.
await DatabaseService.reopen();
if (vault.vaultMigrationDone) return;
if (mounted) setState(() => _migrating = true);
try {
await SqliteToSidecarMigrator(vault).run();
await vault.setVaultMigrationDone();
} catch (_) {
// A failed migration leaves the legacy DB intact (it is only renamed to
// `.premigration` after a successful pass) and the flag unset, so the
// next launch retries. Never block the user from reaching the app.
}
if (mounted) setState(() => _migrating = false);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_migrating) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Migrating your notebooks…'),
],
),
),
);
}
if (_valid) return const AppShell();
return VaultSetupScreen(
vaultService: _vault!,
missing: _hadStoredPath,
onVaultReady: () async {
if (_vault != null) await _maybeMigrate(_vault!);
if (mounted) setState(() => _valid = true);
},
);
}
}

View File

@@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'bookmark.freezed.dart';
part 'bookmark.g.dart';
/// A saved location in a document.
///
/// "Paragraph precision" (user ask: 精确到段落加书签) is expressed by the optional
/// in-page anchor fields below, all normalized to the page in [0,1]:
///
/// * [anchorLeft]/[anchorTop]/[anchorRight]/[anchorBottom] — the bounding rect
/// of the bookmarked text fragment (the FIRST fragment of the current text
/// selection), in NORMALIZED page coords with a top-left origin (the same
/// convention `SidecarHighlight` and the editor's highlight rects use). This
/// is what jump-to scrolls to (via `goToRectInsidePage`), so the bookmark
/// lands on the exact paragraph, not just the page top.
/// * [charIndex] — the character index of the selection start in the page's
/// `fullText` (the true text-position anchor). Stored for fidelity / future
/// reflow-tolerant re-anchoring; not currently used for navigation.
///
/// When no text was selected the anchor falls back to the tapped point: only
/// [anchorTop]/[anchorLeft] are set (a zero-size rect) and [charIndex] is null.
/// All anchor fields are optional and absent from JSON when null, so OLD
/// bookmarks (page-only) still decode and re-encode unchanged (back-compat).
@freezed
abstract class Bookmark with _$Bookmark {
const factory Bookmark({
required String id,
required String documentId,
/// 1-based page number this bookmark lives on.
required int pageNumber,
@Default('') String label,
@Default(0xFF2196F3) int color,
required DateTime createdAt,
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
int? charIndex,
}) = _Bookmark;
factory Bookmark.fromJson(Map<String, dynamic> json) =>

View File

@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
mixin _$Bookmark {
String get id => throw _privateConstructorUsedError;
String get documentId => throw _privateConstructorUsedError;
/// 1-based page number this bookmark lives on.
int get pageNumber => throw _privateConstructorUsedError;
String get label => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
double? get anchorLeft => throw _privateConstructorUsedError;
double? get anchorTop => throw _privateConstructorUsedError;
double? get anchorRight => throw _privateConstructorUsedError;
double? get anchorBottom => throw _privateConstructorUsedError;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
int? get charIndex => throw _privateConstructorUsedError;
/// Serializes this Bookmark to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
String label,
int color,
DateTime createdAt,
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
int? charIndex,
});
}
@@ -74,6 +95,11 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
Object? label = null,
Object? color = null,
Object? createdAt = null,
Object? anchorLeft = freezed,
Object? anchorTop = freezed,
Object? anchorRight = freezed,
Object? anchorBottom = freezed,
Object? charIndex = freezed,
}) {
return _then(
_value.copyWith(
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
anchorLeft: freezed == anchorLeft
? _value.anchorLeft
: anchorLeft // ignore: cast_nullable_to_non_nullable
as double?,
anchorTop: freezed == anchorTop
? _value.anchorTop
: anchorTop // ignore: cast_nullable_to_non_nullable
as double?,
anchorRight: freezed == anchorRight
? _value.anchorRight
: anchorRight // ignore: cast_nullable_to_non_nullable
as double?,
anchorBottom: freezed == anchorBottom
? _value.anchorBottom
: anchorBottom // ignore: cast_nullable_to_non_nullable
as double?,
charIndex: freezed == charIndex
? _value.charIndex
: charIndex // ignore: cast_nullable_to_non_nullable
as int?,
)
as $Val,
);
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
String label,
int color,
DateTime createdAt,
double? anchorLeft,
double? anchorTop,
double? anchorRight,
double? anchorBottom,
int? charIndex,
});
}
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
Object? label = null,
Object? color = null,
Object? createdAt = null,
Object? anchorLeft = freezed,
Object? anchorTop = freezed,
Object? anchorRight = freezed,
Object? anchorBottom = freezed,
Object? charIndex = freezed,
}) {
return _then(
_$BookmarkImpl(
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
anchorLeft: freezed == anchorLeft
? _value.anchorLeft
: anchorLeft // ignore: cast_nullable_to_non_nullable
as double?,
anchorTop: freezed == anchorTop
? _value.anchorTop
: anchorTop // ignore: cast_nullable_to_non_nullable
as double?,
anchorRight: freezed == anchorRight
? _value.anchorRight
: anchorRight // ignore: cast_nullable_to_non_nullable
as double?,
anchorBottom: freezed == anchorBottom
? _value.anchorBottom
: anchorBottom // ignore: cast_nullable_to_non_nullable
as double?,
charIndex: freezed == charIndex
? _value.charIndex
: charIndex // ignore: cast_nullable_to_non_nullable
as int?,
),
);
}
@@ -188,6 +264,11 @@ class _$BookmarkImpl implements _Bookmark {
this.label = '',
this.color = 0xFF2196F3,
required this.createdAt,
this.anchorLeft,
this.anchorTop,
this.anchorRight,
this.anchorBottom,
this.charIndex,
});
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
final String id;
@override
final String documentId;
/// 1-based page number this bookmark lives on.
@override
final int pageNumber;
@override
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
@override
final DateTime createdAt;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
@override
final double? anchorLeft;
@override
final double? anchorTop;
@override
final double? anchorRight;
@override
final double? anchorBottom;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
@override
final int? charIndex;
@override
String toString() {
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)';
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt, anchorLeft: $anchorLeft, anchorTop: $anchorTop, anchorRight: $anchorRight, anchorBottom: $anchorBottom, charIndex: $charIndex)';
}
@override
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
(identical(other.label, label) || other.label == label) &&
(identical(other.color, color) || other.color == color) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
other.createdAt == createdAt) &&
(identical(other.anchorLeft, anchorLeft) ||
other.anchorLeft == anchorLeft) &&
(identical(other.anchorTop, anchorTop) ||
other.anchorTop == anchorTop) &&
(identical(other.anchorRight, anchorRight) ||
other.anchorRight == anchorRight) &&
(identical(other.anchorBottom, anchorBottom) ||
other.anchorBottom == anchorBottom) &&
(identical(other.charIndex, charIndex) ||
other.charIndex == charIndex));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
label,
color,
createdAt,
anchorLeft,
anchorTop,
anchorRight,
anchorBottom,
charIndex,
);
/// Create a copy of Bookmark
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
final String label,
final int color,
required final DateTime createdAt,
final double? anchorLeft,
final double? anchorTop,
final double? anchorRight,
final double? anchorBottom,
final int? charIndex,
}) = _$BookmarkImpl;
factory _Bookmark.fromJson(Map<String, dynamic> json) =
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
String get id;
@override
String get documentId;
/// 1-based page number this bookmark lives on.
@override
int get pageNumber;
@override
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
@override
DateTime get createdAt;
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
/// page-only bookmark JSON omits these keys; they decode to null and the
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
/// explicit null keys, which readers tolerate.
@override
double? get anchorLeft;
@override
double? get anchorTop;
@override
double? get anchorRight;
@override
double? get anchorBottom;
/// Character index of the selection start in the page's `fullText`, or null
/// (tap-fallback / legacy bookmarks).
@override
int? get charIndex;
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@override

View File

@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
label: json['label'] as String? ?? '',
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
createdAt: DateTime.parse(json['createdAt'] as String),
anchorLeft: (json['anchorLeft'] as num?)?.toDouble(),
anchorTop: (json['anchorTop'] as num?)?.toDouble(),
anchorRight: (json['anchorRight'] as num?)?.toDouble(),
anchorBottom: (json['anchorBottom'] as num?)?.toDouble(),
charIndex: (json['charIndex'] as num?)?.toInt(),
);
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
'label': instance.label,
'color': instance.color,
'createdAt': instance.createdAt.toIso8601String(),
'anchorLeft': instance.anchorLeft,
'anchorTop': instance.anchorTop,
'anchorRight': instance.anchorRight,
'anchorBottom': instance.anchorBottom,
'charIndex': instance.charIndex,
};

View File

@@ -0,0 +1,103 @@
// lib/models/scratch_link.dart
//
// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized
// position (nx, ny in [0,1]) on a specific page of a document. Tapping the
// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by
// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page.
import 'package:flutter/foundation.dart';
@immutable
class ScratchLink {
const ScratchLink({
required this.id,
required this.documentId,
required this.pageIndex,
required this.nx,
required this.ny,
this.nw = 0.42,
this.nh = 0.36,
});
/// Stable anchor id (uuid). Doubles as the scratchpad storage key so each
/// anchor gets its own private infinite scratchpad.
final String id;
/// The owning document (the editor's stable document-id for the PDF path).
final String documentId;
/// 0-based page the anchor sits on.
final int pageIndex;
/// Normalized horizontal position on the page, in [0, 1] (top-left of card).
final double nx;
/// Normalized vertical position on the page, in [0, 1] (top-left of card).
final double ny;
/// Expanded card width as a fraction of page width (clamped on write).
final double nw;
/// Expanded card height as a fraction of page height.
final double nh;
ScratchLink copyWith({
String? id,
String? documentId,
int? pageIndex,
double? nx,
double? ny,
double? nw,
double? nh,
}) =>
ScratchLink(
id: id ?? this.id,
documentId: documentId ?? this.documentId,
pageIndex: pageIndex ?? this.pageIndex,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
nw: nw ?? this.nw,
nh: nh ?? this.nh,
);
Map<String, dynamic> toJson() => {
'id': id,
'documentId': documentId,
'pageIndex': pageIndex,
'nx': nx,
'ny': ny,
'nw': nw,
'nh': nh,
};
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
id: json['id'] as String,
documentId: json['documentId'] as String,
pageIndex: (json['pageIndex'] as num).toInt(),
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
nw: (json['nw'] as num?)?.toDouble() ?? 0.42,
nh: (json['nh'] as num?)?.toDouble() ?? 0.36,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ScratchLink &&
runtimeType == other.runtimeType &&
id == other.id &&
documentId == other.documentId &&
pageIndex == other.pageIndex &&
nx == other.nx &&
ny == other.ny &&
nw == other.nw &&
nh == other.nh;
@override
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh);
@override
String toString() =>
'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, '
'nx: $nx, ny: $ny, nw: $nw, nh: $nh)';
}

View File

@@ -1,61 +1,68 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/document.dart';
import '../services/database_service.dart';
import 'note_provider.dart';
import '../services/vault_service.dart';
const _uuid = Uuid();
final vaultServiceProvider = FutureProvider<VaultService>((ref) async {
return VaultService.getInstance();
});
final documentListProvider =
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
DocumentListNotifier.new,
);
/// The home-screen document list is now sourced from a VAULT SCAN (folders
/// under the vault root containing a source file + optional sidecar), NOT the
/// SQLite `documents` table. The sidecar that travels with the file is the
/// source of truth; there is no SQLite cache for this list (the scan is cheap —
/// one directory listing — and always correct).
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
Future<VaultService> get _vault =>
ref.read(vaultServiceProvider.future);
@override
Future<List<Document>> build() async {
final db = await _db;
return db.getAllDocuments();
return _scan();
}
/// Reloads documents from the database and publishes the result to [state]
/// so the UI rebuilds. Used by pull-to-refresh.
Future<List<Document>> _scan() async {
final vault = await _vault;
final notebooks = await vault.scanNotebooks();
return notebooks.map(_toDocument).toList();
}
/// Adapt a scanned [VaultNotebook] into the [Document] shape the home-screen
/// tiles already render. The notebook folder path doubles as a stable id.
Document _toDocument(VaultNotebook nb) {
return Document(
id: nb.folderPath,
filename: nb.filename,
docType: nb.docType,
filePath: nb.sourceFilePath,
pageCount: 0,
createdAt: nb.modified,
updatedAt: nb.modified,
);
}
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after an import.
Future<void> loadDocuments() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllDocuments();
});
}
Future<Document> addDocument({
required String filename,
required String docType,
required String filePath,
int pageCount = 0,
}) async {
final db = await _db;
final now = DateTime.now();
final document = Document(
id: _uuid.v4(),
filename: filename,
docType: docType,
filePath: filePath,
pageCount: pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(document);
state = AsyncData([document, ...state.value ?? []]);
return document;
state = await AsyncValue.guard(_scan);
}
/// Remove a notebook by deleting its folder (source file + sidecar travel
/// together, so removing the folder removes the whole notebook). [id] is the
/// notebook folder path produced by [_toDocument].
Future<void> removeDocument(String id) async {
final db = await _db;
await db.deleteDocument(id);
final dir = Directory(id);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
final current = state.value ?? [];
state = AsyncData(current.where((d) => d.id != id).toList());
}

View File

@@ -1,68 +1,81 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/note.dart';
import '../services/database_service.dart';
const _uuid = Uuid();
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
import '../services/vault_service.dart';
import 'document_provider.dart' show vaultServiceProvider;
/// The home-screen note list is now sourced from a VAULT SCAN of standalone
/// (free-ink) notebook folders — each a folder holding a `notebook.badnote.json`
/// and NO importable source file — NOT the SQLite `notes` table. The sidecar
/// that lives in the folder is the source of truth ("跟着文件走").
///
/// Each scanned note is adapted into the existing [Note] model the home screen
/// already renders: `id` = the synthetic note path (`<folder>/notebook`, also a
/// stable id), `title`, `updatedAt` = the sidecar mtime. Strokes are NOT loaded
/// here — they are hydrated lazily by the editor from the sidecar, so the list
/// stays cheap (one directory listing). Home tiles that show a stroke count will
/// therefore read 0 until the note is opened; the count is no longer cached.
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
NoteListNotifier.new,
);
class NoteListNotifier extends AsyncNotifier<List<Note>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
@override
Future<List<Note>> build() async {
final db = await _db;
return db.getAllNotes();
return _scan();
}
/// Reloads notes from the database and publishes the result to [state] so
/// the UI rebuilds. Used by pull-to-refresh.
Future<List<Note>> _scan() async {
final vault = await _vault;
final notes = await vault.scanNotes();
return notes.map(_toNote).toList();
}
/// Adapt a scanned [VaultNote] into the [Note] shape the home tiles render.
/// `id` is the synthetic note path so opening it re-keys the right sidecar.
Note _toNote(VaultNote n) => Note(
id: n.notePath,
title: n.title,
createdAt: n.modified,
updatedAt: n.modified,
);
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after a note is created or edited.
Future<void> loadNotes() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllNotes();
});
state = await AsyncValue.guard(_scan);
}
/// Create an empty standalone notebook folder with [title] and return the
/// adapted [Note] (whose `id` is the synthetic note path). The home screen
/// opens the editor on it; persistence flows through the sidecar.
Future<Note> createNote({String title = 'Untitled'}) async {
final db = await _db;
final vault = await _vault;
final notePath = await vault.createEmptyNotebook(title);
final now = DateTime.now();
final note = Note(
id: _uuid.v4(),
id: notePath,
title: title,
createdAt: now,
updatedAt: now,
);
await db.insertNote(note);
state = AsyncData([note, ...state.value ?? []]);
return note;
}
Future<void> updateNote(Note note) async {
final db = await _db;
await db.updateNote(note);
final current = state.value ?? [];
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
}
/// Delete a note by removing its notebook folder (the sidecar travels with
/// it). [id] is the synthetic note path `<folder>/notebook`.
Future<void> deleteNote(String id) async {
final db = await _db;
await db.deleteNote(id);
final folder = Directory(File(id).parent.path);
if (await folder.exists()) {
await folder.delete(recursive: true);
}
final current = state.value ?? [];
state = AsyncData(current.where((n) => n.id != id).toList());
}
}
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
final db = await ref.watch(databaseServiceProvider.future);
return db.getNoteById(id);
});

View File

@@ -0,0 +1,46 @@
// lib/providers/notebook_container_provider.dart
//
// Home-screen list of OneNote-style notebook containers: vault folders that
// hold a `notebook.json` manifest (see `storage/notebook_manifest.dart`). This
// mirrors `note_provider.dart` / `document_provider.dart`'s vault-scan pattern
// — the manifest on disk is the source of truth, there is no SQLite cache.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/vault_service.dart';
import 'document_provider.dart' show vaultServiceProvider;
final notebookContainerListProvider = AsyncNotifierProvider<
NotebookContainerListNotifier, List<VaultContainer>>(
NotebookContainerListNotifier.new,
);
class NotebookContainerListNotifier
extends AsyncNotifier<List<VaultContainer>> {
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
@override
Future<List<VaultContainer>> build() => _scan();
Future<List<VaultContainer>> _scan() async {
final vault = await _vault;
return vault.scanContainers();
}
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
/// after a container is created elsewhere.
Future<void> loadContainers() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_scan);
}
/// Create a new notebook container (folder + `notebook.json` + one blank ink
/// page) titled [title], prepend it to the list, and return it so the caller
/// can navigate straight into it.
Future<VaultContainer> createContainer(String title) async {
final vault = await _vault;
final container = await vault.createNotebookContainer(title);
state = AsyncData([container, ...state.value ?? []]);
return container;
}
}

View File

@@ -1,11 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/ocr_service.dart';
import '../services/pdf_text_indexer.dart';
import '../services/pdfrx_page_text_source.dart';
enum OcrStatus { none, processing, done, failed }
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
/// The import-time PDF document-body indexer, wired to the pdfrx-backed embedded
/// text + page-render OCR sources (see [PdfrxPageTextSource]). The import flow
/// fires [PdfTextIndexer.indexPdf] (fire-and-forget) so a scanned PDF's text
/// becomes searchable in the background without blocking the editor opening.
final pdfTextIndexerProvider = Provider<PdfTextIndexer>(
(ref) => PdfTextIndexer(
loadEmbeddedText: PdfrxPageTextSource.loadEmbeddedText,
ocrPages: PdfrxPageTextSource.ocrPages,
),
);
/// Tracks local OCR processing status per note ID.
///
/// This map only ever holds an entry per note that has had OCR triggered in

View File

@@ -1,8 +1,23 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart';
import 'note_provider.dart';
import '../services/vault_search_index.dart';
import 'document_provider.dart' show vaultServiceProvider;
/// The search index, rebuilt by SCANNING the vault sidecars (the source of
/// truth) — NOT the demoted SQLite cache (Phase 6, §B/§F). Bumping
/// [searchIndexEpochProvider] (e.g. after an import or note edit) invalidates
/// this provider so the next read re-scans the vault from disk.
final vaultSearchIndexProvider = FutureProvider<VaultSearchIndex>((ref) async {
ref.watch(searchIndexEpochProvider);
final vault = await ref.watch(vaultServiceProvider.future);
final index = VaultSearchIndex(vault);
await index.rebuild();
return index;
});
/// Bump to force the search index to rebuild from disk (e.g. after an import).
final searchIndexEpochProvider = StateProvider<int>((ref) => 0);
final searchQueryProvider = StateProvider<String>((ref) => '');
@@ -34,59 +49,45 @@ class DocumentSearchHit extends SearchResult {
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return [];
if (query.trim().isEmpty) return [];
// Obtain the DB through the provider graph so this participates in
// initialization and disposal like every other consumer.
final db = await ref.watch(databaseServiceProvider.future);
final index = await ref.watch(vaultSearchIndexProvider.future);
// Run the note and document searches concurrently.
final searches = await Future.wait([
db.searchNotes(query),
db.searchDocuments(query),
]);
final noteHits = searches[0] as List<Note>;
final docHits = searches[1] as List<Map<String, dynamic>>;
final hits = await index.search(query);
final results = <SearchResult>[];
// Add note results.
for (final note in noteHits) {
results.add(NoteSearchHit(note: note, snippet: note.title));
}
// Resolve document metadata without an N+1 loop: collect the distinct
// document ids referenced by the hits, look each up exactly once, then
// build the result list from the cached lookups.
final docIds = <String>{
for (final hit in docHits)
if (hit['document_id'] is String) hit['document_id'] as String,
};
final docEntries = await Future.wait(
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
);
final docsById = <String, Document>{
for (final entry in docEntries)
if (entry.value != null) entry.key: entry.value!,
};
for (final hit in docHits) {
final documentId = hit['document_id'];
if (documentId is! String) continue;
final doc = docsById[documentId];
if (doc == null) continue;
final pageNumber = hit['page_number'];
final content = hit['content'];
results.add(
DocumentSearchHit(
documentId: documentId,
filename: doc.filename,
filePath: doc.filePath,
pageNumber: pageNumber is int ? pageNumber : 0,
snippet: content is String ? content : '',
),
);
for (final hit in hits) {
final entry = hit.entry;
final snippet = hit.snippet.text;
if (entry.isNote) {
// Construct a lightweight Note whose id is the synthetic note path so
// PenNoteScreen re-keys the right sidecar on open. Strokes are hydrated
// lazily by the editor; the search list only needs id/title.
final now = DateTime.now();
results.add(
NoteSearchHit(
note: Note(
id: entry.openPath,
title: entry.title,
createdAt: now,
updatedAt: now,
),
snippet: snippet,
),
);
} else {
results.add(
DocumentSearchHit(
documentId: entry.id,
filename: entry.title,
filePath: entry.openPath,
// The scan-based index matches whole-notebook text, not per-page, so
// the document opens at its first page.
pageNumber: 0,
snippet: snippet,
),
);
}
}
return results;

174
lib/screens/app_shell.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../diagnostics/badnote_log.dart';
import '../diagnostics/diagnostic_chrome.dart';
import '../diagnostics/diagnostic_export.dart';
import '../l10n/app_localizations.dart';
import '../theme/app_theme.dart';
import 'board_screen.dart';
import 'home_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// Unified product shell — single chrome for library, board, search, settings.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
int _index = 0;
final _diagKey = GlobalKey<DiagnosticChromeState>();
@override
void initState() {
super.initState();
BadNoteLog.instance.info(LogSubsystem.shell, 'shell_open');
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final wide = MediaQuery.sizeOf(context).width >= 900;
final destinations = [
_Dest(Icons.menu_book_outlined, Icons.menu_book, l.libraryTab),
_Dest(Icons.sticky_note_2_outlined, Icons.sticky_note_2, l.boardTab),
_Dest(Icons.search, Icons.search, l.search),
_Dest(Icons.tune, Icons.tune, l.settings),
];
final pages = const [
HomeScreen(embeddedInShell: true),
BoardScreen(),
SearchScreen(embeddedInShell: true),
SettingsScreen(embeddedInShell: true),
];
final body = DiagnosticChrome(
key: _diagKey,
child: pages[_index],
);
if (wide) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _index,
onDestinationSelected: _select,
extended: MediaQuery.sizeOf(context).width >= 1200,
labelType: MediaQuery.sizeOf(context).width >= 1200
? NavigationRailLabelType.none
: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.only(top: 12, bottom: 24),
child: Column(
children: [
Text(
'BadNote',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppTokens.copper,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l.shellTagline,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppTokens.inkMuted,
),
),
],
),
),
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: DiagnosticToggleButton(
onToggle: () => _diagKey.currentState?.toggle(),
onExport: () => _diagKey.currentState?.exportPack(),
),
),
),
),
destinations: [
for (final d in destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
VerticalDivider(width: 1, color: AppTokens.rule.withValues(alpha: 0.8)),
Expanded(child: body),
],
),
);
}
return Scaffold(
body: body,
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
destinations: [
for (final d in destinations)
NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: d.label,
),
],
),
],
),
floatingActionButton: FloatingActionButton.small(
heroTag: 'diag_fab',
tooltip: AppLocalizations.of(context).diagnosticsSection,
onPressed: () => _diagKey.currentState?.toggle(),
child: const Icon(Icons.bug_report_outlined),
),
);
}
void _select(int i) {
BadNoteLog.instance.info(LogSubsystem.shell, 'tab', fields: {'index': i});
setState(() => _index = i);
}
}
class _Dest {
const _Dest(this.icon, this.selectedIcon, this.label);
final IconData icon;
final IconData selectedIcon;
final String label;
}
/// Helper used by settings when not embedded — still export packs.
Future<void> exportDiagnosticPack(BuildContext context) async {
try {
final result = await DiagnosticExport.instance.exportPack();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).diagExported(result.bytes)),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).diagExportFail('$e'))),
);
}
}

View File

@@ -0,0 +1,403 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../diagnostics/badnote_log.dart';
import '../editor/board/board.dart';
import '../l10n/app_localizations.dart';
import '../services/database_service.dart';
import '../theme/app_theme.dart';
const _kDefaultBoardId = 'main';
/// Infinite sticky-note board — first-class shell destination (F7).
class BoardScreen extends ConsumerStatefulWidget {
const BoardScreen({super.key});
@override
ConsumerState<BoardScreen> createState() => _BoardScreenState();
}
class _BoardScreenState extends ConsumerState<BoardScreen> {
Board _board = Board.empty;
bool _loading = true;
String? _selectedId;
final _transform = TransformationController();
Timer? _saveDebounce;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_saveDebounce?.cancel();
_transform.dispose();
super.dispose();
}
Future<void> _load() async {
final db = await DatabaseService.getInstance();
final board = await db.loadBoard(_kDefaultBoardId);
if (!mounted) return;
setState(() {
_board = board;
_loading = false;
});
BadNoteLog.instance.info(
LogSubsystem.board,
'board_loaded',
fields: {'cards': board.length},
);
}
void _scheduleSave() {
_saveDebounce?.cancel();
_saveDebounce = Timer(const Duration(milliseconds: 400), () async {
final db = await DatabaseService.getInstance();
await db.saveBoardCards(_kDefaultBoardId, _board.cards);
BadNoteLog.instance.debug(
LogSubsystem.board,
'board_saved',
fields: {'cards': _board.length},
);
});
}
void _addCard() {
final l = AppLocalizations.of(context);
final id = const Uuid().v4();
// Place near viewport center in scene coords.
final matrix = _transform.value;
final inv = Matrix4.inverted(matrix);
final center = MatrixUtils.transformPoint(
inv,
Offset(
MediaQuery.sizeOf(context).width / 2,
MediaQuery.sizeOf(context).height / 2,
),
);
setState(() {
_board = _board.add(
BoardCard(
id: id,
position: center - const Offset(120, 80),
size: const Size(240, 160),
text: l.boardNewCardText,
),
);
_selectedId = id;
});
_scheduleSave();
}
void _deleteSelected() {
final id = _selectedId;
if (id == null) return;
final l = AppLocalizations.of(context);
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.boardDeleteCardTitle),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.boardDeleteCard),
),
],
),
).then((ok) {
if (ok != true) return;
setState(() {
_board = _board.removeById(id);
_selectedId = null;
});
_scheduleSave();
});
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
final selected = _selectedId != null ? _board.cardById(_selectedId!) : null;
final backlinks =
selected != null ? _board.backlinksOf(selected.id) : <String>{};
final isEmpty = _board.length == 0;
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
title: Text(l.boardTitle),
actions: [
IconButton(
tooltip: l.boardAddCard,
onPressed: _addCard,
icon: const Icon(Icons.add),
),
if (_selectedId != null)
IconButton(
tooltip: l.boardDeleteCard,
onPressed: _deleteSelected,
icon: const Icon(Icons.delete_outline),
),
],
),
body: isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sticky_note_2_outlined,
size: 72,
color: AppTokens.copper.withValues(alpha: 0.85),
),
const SizedBox(height: 20),
Text(
l.boardEmptyTitle,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l.boardEmptyBody,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTokens.inkMuted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _addCard,
icon: const Icon(Icons.add),
label: Text(l.boardAddCard),
),
],
),
),
)
: Row(
children: [
Expanded(
child: InteractiveViewer(
transformationController: _transform,
constrained: false,
boundaryMargin: const EdgeInsets.all(2000),
minScale: 0.25,
maxScale: 3,
child: SizedBox(
width: 4000,
height: 3000,
child: CustomPaint(
painter: _BoardGridPainter(
color: AppTokens.rule.withValues(alpha: 0.45),
),
child: Stack(
children: [
for (final card in _board.cards)
Positioned(
left: card.position.dx,
top: card.position.dy,
width: card.size.width,
height: card.size.height,
child: _StickyCard(
card: card,
selected: card.id == _selectedId,
onTap: () => setState(() => _selectedId = card.id),
onDrag: (delta) {
setState(() {
_board = _board.moveCard(
card.id,
card.position + delta,
);
});
_scheduleSave();
},
onTextChanged: (text) {
setState(() {
_board = _board.setText(card.id, text);
});
_scheduleSave();
},
),
),
],
),
),
),
),
),
if (selected != null)
SizedBox(
width: 260,
child: Material(
elevation: 1,
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.all(AppTokens.chromePad),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.boardBacklinks,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (backlinks.isEmpty)
Text(
l.boardNoBacklinks,
style: Theme.of(context).textTheme.bodySmall,
)
else
...backlinks.map((id) {
final c = _board.cardById(id);
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(c?.text.split('\n').first ?? id),
onTap: () => setState(() => _selectedId = id),
);
}),
const Divider(),
Text(
'[[links]]',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
Text(
l.boardEmptyBody,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTokens.inkMuted,
),
),
],
),
),
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addCard,
icon: const Icon(Icons.sticky_note_2),
label: Text(l.boardAddCard),
),
);
}
}
class _StickyCard extends StatefulWidget {
const _StickyCard({
required this.card,
required this.selected,
required this.onTap,
required this.onDrag,
required this.onTextChanged,
});
final BoardCard card;
final bool selected;
final VoidCallback onTap;
final ValueChanged<Offset> onDrag;
final ValueChanged<String> onTextChanged;
@override
State<_StickyCard> createState() => _StickyCardState();
}
class _StickyCardState extends State<_StickyCard> {
late final TextEditingController _controller =
TextEditingController(text: widget.card.text);
@override
void didUpdateWidget(covariant _StickyCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.card.text != widget.card.text &&
_controller.text != widget.card.text) {
_controller.text = widget.card.text;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
onPanUpdate: (d) => widget.onDrag(d.delta),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTokens.sticky,
borderRadius: BorderRadius.circular(AppTokens.radiusSm),
border: Border.all(
color: widget.selected ? AppTokens.copper : AppTokens.rule,
width: widget.selected ? 2 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: TextField(
controller: _controller,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTokens.ink,
),
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
onChanged: widget.onTextChanged,
),
),
);
}
}
class _BoardGridPainter extends CustomPainter {
_BoardGridPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1;
const step = 48.0;
for (double x = 0; x < size.width; x += step) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (double y = 0; y < size.height; y += step) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(covariant _BoardGridPainter oldDelegate) =>
oldDelegate.color != color;
}

View File

@@ -1,84 +1,88 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart';
import '../models/document.dart';
import '../models/note.dart';
import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/notebook_container_provider.dart';
import '../providers/ocr_provider.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart';
import '../providers/search_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/canvas/office_document_screen.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
import 'ppt_annotator_screen.dart';
import '../services/vault_service.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import 'notebook_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
import 'split_view_screen.dart';
// [M1] Relative date helper — no new package dependencies.
String _formatDate(DateTime d) {
// Relative date helper — localized.
String _formatDate(BuildContext context, DateTime d) {
final l = AppLocalizations.of(context);
final now = DateTime.now();
final diff = now.difference(d);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inSeconds < 60) return l.relativeJustNow;
if (diff.inMinutes < 60) return l.relativeMinutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l.relativeHoursAgo(diff.inHours);
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
return 'Yesterday';
return l.relativeYesterday;
}
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
}
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
const HomeScreen({super.key, this.embeddedInShell = false});
/// When true, chrome (settings/search) is owned by [AppShell].
final bool embeddedInShell;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(noteListProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('BadNote'),
centerTitle: true,
title: Text(embeddedInShell ? l.libraryTab : l.appTitle),
centerTitle: !embeddedInShell,
actions: [
if (!embeddedInShell) ...[
IconButton(
icon: const Icon(Icons.settings),
tooltip: l.settings,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: l.search,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SearchScreen()),
);
},
),
],
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Import PDF',
onPressed: () => _importPdf(context),
),
IconButton(
icon: const Icon(Icons.slideshow),
tooltip: 'Import PPT',
onPressed: () => _importPptx(context),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
// New pen-first canvas editor (beta).
IconButton(
icon: const Icon(Icons.draw_outlined),
tooltip: 'Pen Canvas (beta)',
onPressed: () => openM1Spike(context),
icon: const Icon(Icons.file_open),
tooltip: l.importFile,
onPressed: () => _importFile(context, ref),
),
],
),
floatingActionButton: FloatingActionButton(
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createAndOpenNote(context, ref),
child: const Icon(Icons.add),
icon: const Icon(Icons.add),
label: Text(l.createNotebook),
),
body: notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
@@ -86,8 +90,10 @@ class HomeScreen extends ConsumerWidget {
data: (notes) {
final documentsAsync = ref.watch(documentListProvider);
final documents = documentsAsync.valueOrNull ?? [];
final containersAsync = ref.watch(notebookContainerListProvider);
final containers = containersAsync.valueOrNull ?? [];
if (notes.isEmpty && documents.isEmpty) {
if (notes.isEmpty && documents.isEmpty && containers.isEmpty) {
return _buildEmptyState(context, ref);
}
return RefreshIndicator(
@@ -95,19 +101,40 @@ class HomeScreen extends ConsumerWidget {
await Future.wait([
ref.read(noteListProvider.notifier).loadNotes(),
ref.read(documentListProvider.notifier).loadDocuments(),
ref
.read(notebookContainerListProvider.notifier)
.loadContainers(),
]);
},
child: CustomScrollView(
slivers: [
// Notes section header always shown when documents exist
if (containers.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l.notebooksSection,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_ContainerTile(container: containers[index]),
childCount: containers.length,
),
),
],
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
l.notesSection,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
fontWeight: FontWeight.bold,
),
),
),
),
@@ -128,7 +155,7 @@ class HomeScreen extends ConsumerWidget {
),
child: Center(
child: Text(
'No ink notes yet — tap + to create one',
l.noNotesYetHint,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
@@ -139,19 +166,19 @@ class HomeScreen extends ConsumerWidget {
),
),
),
// Documents section header always shown when notes exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Recent Documents',
l.documentsSection,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
fontWeight: FontWeight.bold,
),
),
),
),
if (documents.isNotEmpty)
// continue existing document list below — marker for patch
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
@@ -160,7 +187,6 @@ class HomeScreen extends ConsumerWidget {
),
)
else
// [M2] Per-section empty hint when notes exist but documents don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
@@ -169,7 +195,7 @@ class HomeScreen extends ConsumerWidget {
),
child: Center(
child: Text(
'No documents yet — import a PDF or PPT',
l.noDocumentsYet,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
@@ -189,54 +215,179 @@ class HomeScreen extends ConsumerWidget {
);
}
/// "Create notebook" (OneNote style): prompt a title (defaulting to
/// Untitled), create the notebook container FOLDER + `notebook.json` (with
/// one blank ink page) via `VaultService.createNotebookContainer`, then open
/// [NotebookScreen] on it.
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final note = await ref.read(noteListProvider.notifier).createNote();
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
}
}
Future<void> _importPdf(BuildContext context) async {
final pdfService = PdfService();
final filePath = await pdfService.pickPdfFile();
if (filePath != null && context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
),
);
}
}
Future<void> _importPptx(BuildContext context) async {
final pptxService = PptxService();
final filePath = await pptxService.openPptxFile();
if (filePath == null || !context.mounted) return;
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
final title = await _promptNotebookTitle(context);
if (title == null) return; // cancelled
final l = context.mounted ? AppLocalizations.of(context) : null;
final resolved = title.trim().isEmpty
? (l?.untitledNote ?? 'Untitled')
: title.trim();
final container = await ref
.read(notebookContainerListProvider.notifier)
.createContainer(resolved);
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
builder: (_) => NotebookScreen(
folderPath: container.folderPath,
title: container.title,
),
),
);
}
}
/// Ask for a notebook title. Returns the entered string (possibly empty →
/// caller defaults it), or null if the user cancelled.
Future<String?> _promptNotebookTitle(BuildContext context) {
final l = AppLocalizations.of(context);
final controller = TextEditingController(text: l.untitledNote);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.newNotebookTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l.notebookTitleHint),
onSubmitted: (v) => Navigator.of(ctx).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: Text(l.create),
),
],
),
);
}
/// Single top-level "Import file" action (sibling of "Create notebook"):
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
/// open the IN-VAULT copy in the right editor (routed by extension).
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
final l = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: VaultService.importableExtensions.toList(),
);
final picked = result?.files;
if (picked == null || picked.isEmpty) return;
final pickedPath = picked.first.path;
if (pickedPath == null) return;
final messenger = context.mounted ? ScaffoldMessenger.of(context) : null;
messenger?.showSnackBar(SnackBar(content: Text(l.processingImport)));
try {
final vault = await ref.read(vaultServiceProvider.future);
final vaultPath = await vault.createNotebook(pickedPath);
// Refresh the documents list so the new notebook shows on return.
await ref.read(documentListProvider.notifier).loadDocuments();
// For a PDF, index its document body (embedded text layer, or background
// OCR of a rasterized/scanned PDF) into the sidecar so search covers it.
// Fire-and-forget: import returns and opens the editor immediately.
_indexPdfInBackground(ref, vaultPath);
if (!context.mounted) return;
await _openVaultFile(context, ref, vaultPath);
} catch (e) {
messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
}
}
/// Kick off background document-body indexing for an in-vault PDF (no-op for
/// other types). Runs detached from the import await chain so the editor opens
/// immediately; on completion it bumps the search-index epoch so the newly
/// indexed text is searchable. Idempotency and graceful OCR degradation live in
/// [PdfTextIndexer]; failures here are swallowed (search just misses the body).
void _indexPdfInBackground(WidgetRef ref, String vaultPath) {
final ext = p.extension(vaultPath).replaceFirst('.', '').toLowerCase();
if (ext != 'pdf') return;
final indexer = ref.read(pdfTextIndexerProvider);
unawaited(() async {
final indexed = await indexer.indexPdf(vaultPath);
if (indexed != null && indexed.isNotEmpty) {
// Force the next search to re-scan the vault (picks up the new pageText).
final epoch = ref.read(searchIndexEpochProvider.notifier);
epoch.state = epoch.state + 1;
}
}());
}
/// Route an in-vault [filePath] to the correct editor by extension:
/// pdf → [PenEditorScreen]; pptx/docx → native [OfficeDocumentScreen];
/// legacy .ppt may still use image fallback.
Future<void> _openVaultFile(
BuildContext context,
WidgetRef ref,
String filePath,
) async {
final l = AppLocalizations.of(context);
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
switch (ext) {
case 'pdf':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: filePath),
),
);
case 'pptx':
case 'docx':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => OfficeDocumentScreen(filePath: filePath),
),
);
case 'ppt':
// Legacy binary PPT — try native-ish image path for now.
await _openPresentation(context, filePath);
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.unsupportedFileType(ext))),
);
}
}
Future<void> _openPresentation(
BuildContext context,
String filePath,
) async {
final l = AppLocalizations.of(context);
final pptxService = PptxService();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (!context.mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
@@ -249,14 +400,14 @@ class HomeScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
Text(
'No notes yet',
AppLocalizations.of(context).emptyLibraryTitle,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first note',
AppLocalizations.of(context).emptyLibraryBody,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
@@ -265,19 +416,13 @@ class HomeScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _createAndOpenNote(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Note'),
label: Text(AppLocalizations.of(context).createNotebook),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPdf(context),
icon: const Icon(Icons.picture_as_pdf),
label: const Text('Import PDF'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPptx(context),
icon: const Icon(Icons.slideshow),
label: const Text('Import PPT'),
onPressed: () => _importFile(context, ref),
icon: const Icon(Icons.file_open),
label: Text(AppLocalizations.of(context).importFile),
),
],
),
@@ -285,6 +430,45 @@ class HomeScreen extends ConsumerWidget {
}
}
/// Simple tile for a OneNote-style notebook container on the home screen.
/// Tapping opens [NotebookScreen] on the container's folder.
class _ContainerTile extends StatelessWidget {
const _ContainerTile({required this.container});
final VaultContainer container;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final dateStr = _formatDate(context, container.modified);
return ListTile(
leading: Icon(
Icons.menu_book,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
container.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${l.memberCount(container.memberCount)} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotebookScreen(
folderPath: container.folderPath,
title: container.title,
),
),
);
},
);
}
}
class _NoteTile extends ConsumerStatefulWidget {
final Note note;
const _NoteTile({required this.note});
@@ -300,7 +484,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
Widget build(BuildContext context) {
final note = widget.note;
// [M1] Use relative date helper
final dateStr = _formatDate(note.updatedAt);
final dateStr = _formatDate(context, note.updatedAt);
final ocrStatusMap = ref.watch(ocrStatusProvider);
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
@@ -332,8 +516,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stroke count is no longer cached in the vault scan (strokes
// load lazily in the editor), so the tile shows only the date.
Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
dateStr,
style: Theme.of(context).textTheme.bodySmall,
),
if (note.tags.isNotEmpty)
@@ -373,7 +559,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
);
},
onLongPress: () => _confirmDelete(context),
@@ -423,7 +609,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
if (!mounted) return;
if (result == 'open') {
Navigator.of(this.context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
);
} else if (result == 'delete') {
_confirmDelete(this.context);
@@ -471,7 +657,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
Widget build(BuildContext context) {
final document = widget.document;
// [M1] Use relative date helper
final dateStr = _formatDate(document.updatedAt);
final dateStr = _formatDate(context, document.updatedAt);
final isPdf = document.docType == 'pdf';
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
@@ -497,32 +683,18 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
// [H2] Trailing row: split-view (PDF only) + remove
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
IconButton(
icon: Icon(
Icons.vertical_split,
color: _hovering
? Theme.of(context).colorScheme.primary
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Open in Split View',
onPressed: () => _openSplitView(context),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
],
// [H2] Trailing remove button. Split view is now reached only by
// tapping a scratch-link anchor inside the PDF editor, so the
// standalone split-view entry was removed.
trailing: IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
// [L2] Routing bug fix: route by docType
onTap: () => _openDocument(context),
@@ -532,22 +704,34 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
);
}
// [L2] Route by docType: pdf → PdfAnnotatorScreen, ppt/pptx → PptAnnotatorScreen
// Route by docType: pdf → PenEditorScreen; pptx/docx → native OfficeDocumentScreen.
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
final l = AppLocalizations.of(context);
if (isPdf) {
if (document.docType == 'pdf') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
),
);
} else {
// PPT/PPTX: convert to images then push PptAnnotatorScreen
return;
}
if (document.docType == 'docx' || document.docType == 'pptx') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => OfficeDocumentScreen(filePath: document.filePath),
),
);
return;
}
{
// Legacy .ppt: convert to images then push PenSlideScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Processing presentation...')),
SnackBar(content: Text(l.processingPresentation)),
);
}
final pptxService = PptxService();
@@ -556,13 +740,13 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Could not open presentation.')),
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
builder: (_) => PenSlideScreen(
filePath: document.filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
@@ -572,19 +756,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
}
}
void _openSplitView(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.document.filePath,
documentId: widget.document.id,
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final isPdf = widget.document.docType == 'pdf';
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
@@ -604,17 +776,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
],
),
),
if (isPdf)
PopupMenuItem(
value: 'split',
child: Row(
children: const [
Icon(Icons.vertical_split),
SizedBox(width: 8),
Text('Open in Split View'),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
@@ -636,15 +797,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
if (!mounted) return;
if (result == 'open') {
_openDocument(this.context);
} else if (result == 'split') {
_openSplitView(this.context);
} else if (result == 'remove') {
_confirmDelete(this.context);
}
}
void _showDocumentMenu(BuildContext context) {
final isPdf = widget.document.docType == 'pdf';
showModalBottomSheet(
context: context,
builder: (ctx) {
@@ -652,16 +810,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
ListTile(
leading: const Icon(Icons.vertical_split),
title: const Text('Open in Split View'),
subtitle: const Text('PDF reference + scratchpad'),
onTap: () {
Navigator.of(ctx).pop();
_openSplitView(context);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text(

View File

@@ -1,303 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
class NoteEditorScreen extends ConsumerStatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
final UndoManager _undoManager = UndoManager();
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
String _title = 'Untitled';
final TextEditingController _titleController = TextEditingController();
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _zoomController = TransformationController();
double _zoomLevel = 1.0;
bool _isDirty = false;
Note? get _existingNote => widget.note;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
@override
void initState() {
super.initState();
if (_existingNote != null) {
_title = _existingNote!.title;
for (final stroke in _existingNote!.strokes) {
_undoManager.addStroke(stroke);
}
}
_titleController.text = _title;
}
@override
void dispose() {
_titleController.dispose();
_zoomController.dispose();
super.dispose();
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_undoManager.addStroke(stroke);
_isDirty = true;
});
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _undoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_isDirty = true;
}
});
}
void _undo() {
setState(() {
_undoManager.undo();
_isDirty = true;
});
}
void _redo() {
setState(() {
_undoManager.redo();
_isDirty = true;
});
}
Future<void> _save() async {
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
Note savedNote;
if (_existingNote != null) {
final updated = _existingNote!.copyWith(
title: _title,
strokes: _undoManager.currentStrokes.toList(),
updatedAt: now,
);
await notifier.updateNote(updated);
savedNote = updated;
} else {
final note = await notifier.createNote(title: _title);
final updated = note.copyWith(
strokes: _undoManager.currentStrokes.toList(),
);
await notifier.updateNote(updated);
savedNote = updated;
}
if (!mounted) return;
setState(() {
_isDirty = false;
});
_runLocalOcr(savedNote);
}
/// Run local OCR and index results for search.
void _runLocalOcr(Note note) {
final noteId = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.processing,
};
ref
.read(ocrServiceProvider)
.processNote(note)
.then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.done,
};
})
.catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.failed,
};
});
}
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomReset() {
_applyZoom(1.0);
}
void _applyZoom(double level) {
setState(() => _zoomLevel = level);
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
}
Future<void> _saveAndNotify() async {
await _save();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Saved')));
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _isDirty) _save();
},
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveAndNotify,
},
child: Focus(
autofocus: true,
child: Scaffold(
appBar: AppBar(
title: SizedBox(
height: 40,
child: TextField(
controller: _titleController,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Note title...',
contentPadding: const EdgeInsets.symmetric(vertical: 8),
suffix: _isDirty
? const Text(
'',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
)
: null,
),
onChanged: (value) {
_title = value;
setState(() => _isDirty = true);
},
),
),
actions: [
IconButton(
icon: const Icon(Icons.check),
tooltip: 'Save',
onPressed: _saveAndNotify,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) =>
setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomReset,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: InteractiveViewer(
transformationController: _zoomController,
minScale: 0.5,
maxScale: 5.0,
child: InkCanvas(
strokes: _undoManager.currentStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,248 @@
// lib/screens/notebook_screen.dart
//
// OneNote-style notebook container screen: lists the members (pages/imported
// documents) of one vault folder holding a `notebook.json` manifest, and
// routes taps to the matching editor by `NotebookMemberKind`. The open-by-type
// routing intentionally MIRRORS `home_screen.dart`'s `_DocumentTile._openDocument`
// so a member opens in exactly the editor its extension would on the home
// screen (pdf → PenEditorScreen; pptx/docx → OfficeDocumentScreen; legacy ppt →
// image-converted PenSlideScreen; note → PenNoteScreen).
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../editor/canvas/office_document_screen.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/document_provider.dart' show vaultServiceProvider;
import '../services/pptx_service.dart';
import '../services/vault_service.dart';
import '../storage/notebook_manifest.dart';
/// Shows one notebook container's table of contents and lets the user add a
/// blank ink page or import a file into it.
class NotebookScreen extends ConsumerStatefulWidget {
const NotebookScreen({
super.key,
required this.folderPath,
required this.title,
});
/// Absolute path to the notebook container folder (holds `notebook.json`).
final String folderPath;
/// Title shown before the manifest loads (and as a fallback if it has none).
final String title;
@override
ConsumerState<NotebookScreen> createState() => _NotebookScreenState();
}
class _NotebookScreenState extends ConsumerState<NotebookScreen> {
NotebookManifest? _manifest;
bool _loading = true;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
final manifest = await NotebookManifest.read(widget.folderPath);
if (!mounted) return;
setState(() {
_manifest = manifest;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final members = _manifest?.members ?? const [];
final title = (_manifest?.title.trim().isNotEmpty ?? false)
? _manifest!.title.trim()
: widget.title;
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: [
IconButton(
icon: const Icon(Icons.note_add_outlined),
tooltip: l.addBlankPage,
onPressed: _addBlankPage,
),
IconButton(
icon: const Icon(Icons.file_open),
tooltip: l.importIntoNotebook,
onPressed: _importFile,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: members.isEmpty
? Center(
child: Text(
l.notebookMembersEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: RefreshIndicator(
onRefresh: _reload,
child: ListView.builder(
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return _MemberTile(
member: member,
onTap: () => _openMember(member),
);
},
),
),
);
}
Future<void> _addBlankPage() async {
final vault = await ref.read(vaultServiceProvider.future);
await vault.addBlankPageToContainer(widget.folderPath);
await _reload();
}
Future<void> _importFile() async {
final l = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: VaultService.importableExtensions.toList(),
);
final pickedPath = result?.files.first.path;
if (pickedPath == null || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(SnackBar(content: Text(l.processingImport)));
try {
final vault = await ref.read(vaultServiceProvider.future);
await vault.importFileIntoContainer(widget.folderPath, pickedPath);
await _reload();
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
}
}
/// Route [member] to the correct editor by kind — same switch as
/// `home_screen.dart`'s `_DocumentTile._openDocument`.
Future<void> _openMember(NotebookMember member) async {
final vault = await ref.read(vaultServiceProvider.future);
final absolutePath = vault.memberAbsolutePath(widget.folderPath, member);
if (!mounted) return;
switch (member.kind) {
case NotebookMemberKind.note:
final now = DateTime.now();
final note = Note(
id: absolutePath,
title: member.title,
createdAt: now,
updatedAt: now,
);
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
);
case NotebookMemberKind.pdf:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: absolutePath),
),
);
case NotebookMemberKind.pptx:
case NotebookMemberKind.docx:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => OfficeDocumentScreen(filePath: absolutePath),
),
);
case NotebookMemberKind.ppt:
// Legacy binary PPT — same image-fallback path as the home screen.
await _openLegacyPpt(absolutePath);
}
}
Future<void> _openLegacyPpt(String filePath) async {
final l = AppLocalizations.of(context);
final pptxService = PptxService();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
class _MemberTile extends StatelessWidget {
const _MemberTile({required this.member, required this.onTap});
final NotebookMember member;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_iconFor(member.kind), color: _colorFor(member.kind)),
title: Text(
member.title.isEmpty ? 'Untitled' : member.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(_kindLabel(member.kind)),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
);
}
String _kindLabel(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => '空白页 · 手写',
NotebookMemberKind.pdf => 'PDF · 批注',
NotebookMemberKind.pptx => 'PPTX · 幻灯片',
NotebookMemberKind.ppt => 'PPT · 幻灯片',
NotebookMemberKind.docx => 'DOCX · 文档',
};
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => Icons.edit_note,
NotebookMemberKind.pdf => Icons.picture_as_pdf,
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Icons.slideshow,
NotebookMemberKind.docx => Icons.description,
};
Color _colorFor(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => Colors.blue,
NotebookMemberKind.pdf => Colors.red,
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Colors.orange,
NotebookMemberKind.docx => Colors.indigo,
};
}

View File

@@ -1,981 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:uuid/uuid.dart';
import '../models/bookmark.dart';
import '../models/document.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/camera_service.dart';
import '../services/database_service.dart';
import '../services/pdf_service.dart';
import '../services/thumbnail_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
import '../widgets/page_thumbnail_sidebar.dart';
import '../widgets/pdf_annotation_layer.dart';
import 'pdf_text_search.dart';
import 'split_view_screen.dart';
const _uuid = Uuid();
/// Actions available in the AppBar overflow menu.
enum _OverflowAction { pageManagement, cameraInsert, export }
/// Full-screen PDF viewer with ink annotation overlay.
///
/// Displays a PDF page-by-page with a transparent [PdfAnnotationLayer]
/// on top for pen/marker/eraser annotations. Annotations are stored
/// per page in normalized [0, 1] coordinates and exported via [PdfService].
/// Annotations and bookmarks are persisted to the database.
class PdfAnnotatorScreen extends StatefulWidget {
final String filePath;
final int initialPage;
const PdfAnnotatorScreen({
super.key,
required this.filePath,
this.initialPage = 0,
});
@override
State<PdfAnnotatorScreen> createState() => _PdfAnnotatorScreenState();
}
class _PdfAnnotatorScreenState extends State<PdfAnnotatorScreen> {
final PdfService _pdfService = PdfService();
final CameraService _cameraService = CameraService();
final PdfViewerController _viewerController = PdfViewerController();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
int _currentPage = 0;
int _pageCount = 0;
int _pdfMutationVersion = 0;
String _fileName = '';
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
InteractionMode _interactionMode = InteractionMode.draw;
double _zoomLevel = 1.0;
bool _showThumbnails = false;
String? _currentDocumentId;
final Map<int, UndoManager> _undoManagers = {};
final Map<int, List<InkStroke>> _annotations = {};
List<Bookmark> _bookmarks = [];
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_loadPdfInfo();
}
Future<void> _loadPdfInfo() async {
final info = await _pdfService.getPdfInfo(widget.filePath);
final count = await _pdfService.getPageCount(widget.filePath);
if (mounted) {
setState(() {
_fileName = info['fileName'] as String;
_pageCount = count;
});
await _ensureDocumentExists();
await _loadAllAnnotations();
await _loadBookmarks();
}
}
Future<void> _ensureDocumentExists() async {
final db = await DatabaseService.getInstance();
final existing = await db.getDocumentByPath(widget.filePath);
if (existing == null) {
final now = DateTime.now();
final newDoc = Document(
id: _uuid.v4(),
filename: _fileName,
docType: 'pdf',
filePath: widget.filePath,
pageCount: _pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(newDoc);
_currentDocumentId = newDoc.id;
} else {
_currentDocumentId = existing.id;
}
}
Future<void> _loadAllAnnotations() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
for (int i = 0; i < _pageCount; i++) {
final json = await db.getAnnotations(_currentDocumentId!, i);
if (json != null && json.isNotEmpty) {
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
_annotations[i] = list
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
_undoManagers[i] = UndoManager();
for (final stroke in _annotations[i]!) {
_undoManagers[i]!.addStroke(stroke);
}
}
}
if (mounted) setState(() {});
}
void _saveCurrentPageAnnotations({int? page}) async {
if (_currentDocumentId == null) return;
// Capture the page index and serialize its strokes SYNCHRONOUSLY, before
// any await. Otherwise a concurrent navigation could change _currentPage
// while this is suspended, causing the wrong page's data to be saved.
final targetPage = page ?? _currentPage;
final documentId = _currentDocumentId!;
final strokesJson = jsonEncode(
_annotations[targetPage]?.map((s) => s.toJson()).toList() ?? [],
);
final db = await DatabaseService.getInstance();
await db.saveAnnotations(documentId, targetPage, strokesJson);
}
void _onPageChanged(int page) {
// Save the page we are leaving, not the one we are navigating to.
_saveCurrentPageAnnotations(page: _currentPage);
setState(() {
_currentPage = page;
});
}
UndoManager _getUndoManager(int page) {
return _undoManagers.putIfAbsent(page, UndoManager.new);
}
List<InkStroke> _getCurrentStrokes() {
return _annotations[_currentPage] ?? [];
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_annotations.putIfAbsent(_currentPage, () => []);
_annotations[_currentPage]!.add(stroke);
_getUndoManager(_currentPage).addStroke(stroke);
});
_saveCurrentPageAnnotations();
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final pageStrokes = _annotations[_currentPage];
if (pageStrokes == null) return;
final original = pageStrokes.where((s) => s.id == strokeId).firstOrNull;
if (original != null) {
_getUndoManager(
_currentPage,
).removeStroke(original, replacements: replacements);
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
}
});
_saveCurrentPageAnnotations();
}
void _undo() {
setState(() {
_getUndoManager(_currentPage).undo();
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
});
_saveCurrentPageAnnotations();
}
void _redo() {
setState(() {
_getUndoManager(_currentPage).redo();
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
});
_saveCurrentPageAnnotations();
}
// -- Bookmarks --
bool get _isCurrentPageBookmarked =>
_bookmarks.any((b) => b.pageNumber == _currentPage);
Future<void> _loadBookmarks() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
final bookmarks = await db.getBookmarks(_currentDocumentId!);
if (mounted) {
setState(() {
_bookmarks = bookmarks;
});
}
}
Future<void> _toggleBookmark() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
if (_isCurrentPageBookmarked) {
final existing = _bookmarks.firstWhere(
(b) => b.pageNumber == _currentPage,
);
await db.deleteBookmark(existing.id);
setState(() {
_bookmarks.removeWhere((b) => b.id == existing.id);
});
} else {
final label = await _showBookmarkDialog();
if (label == null) return;
final bookmark = Bookmark(
id: _uuid.v4(),
documentId: _currentDocumentId!,
pageNumber: _currentPage,
label: label,
createdAt: DateTime.now(),
);
await db.insertBookmark(bookmark);
setState(() {
_bookmarks.add(bookmark);
_bookmarks.sort((a, b) => a.pageNumber.compareTo(b.pageNumber));
});
}
}
Future<String?> _showBookmarkDialog() async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Add Bookmark'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Label (optional)',
labelText: 'Bookmark label',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: const Text('Add'),
),
],
);
},
);
}
Future<void> _deleteBookmark(Bookmark bookmark) async {
final db = await DatabaseService.getInstance();
await db.deleteBookmark(bookmark.id);
setState(() {
_bookmarks.removeWhere((b) => b.id == bookmark.id);
});
}
void _jumpToPage(int page) {
_saveCurrentPageAnnotations();
_viewerController.jumpToPage(page + 1);
}
// -- Zoom --
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_viewerController.zoomLevel = newLevel;
setState(() => _zoomLevel = newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_viewerController.zoomLevel = newLevel;
setState(() => _zoomLevel = newLevel);
}
void _zoomFitWidth() {
_viewerController.zoomLevel = 1.0;
setState(() => _zoomLevel = 1.0);
}
// -- Search --
void _openSearch() {
showDialog(
context: context,
builder: (_) => PdfTextSearchDialog(viewerController: _viewerController),
);
}
// -- Export --
Future<void> _exportPdf() async {
try {
final outputPath = await _pdfService.exportAnnotatedPdf(
widget.filePath,
_annotations,
);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Exported to: $outputPath')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
// -- Page Management --
void _showPageManagementSheet() {
showModalBottomSheet(
context: context,
builder: (context) {
final canDelete = _pageCount > 1;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.rotate_right),
title: const Text('Rotate Page 90\u00B0'),
subtitle: Text('Page ${_currentPage + 1}'),
onTap: () {
Navigator.of(context).pop();
_rotateCurrentPage();
},
),
ListTile(
leading: Icon(
Icons.delete_outline,
color: canDelete ? null : Colors.grey,
),
title: Text(
'Delete Page',
style: TextStyle(color: canDelete ? null : Colors.grey),
),
subtitle: Text(
canDelete
? 'Page ${_currentPage + 1}'
: 'Cannot delete the only page',
),
enabled: canDelete,
onTap: canDelete
? () {
Navigator.of(context).pop();
_deleteCurrentPage();
}
: null,
),
ListTile(
leading: const Icon(Icons.note_add_outlined),
title: const Text('Insert Blank Page After Current'),
subtitle: Text('After page ${_currentPage + 1}'),
onTap: () {
Navigator.of(context).pop();
_insertBlankPageAfterCurrent();
},
),
],
),
);
},
);
}
/// Transform a stroke's normalized [0,1] points to match a 90° clockwise
/// page rotation: a point at (x, y) maps to (1 - y, x). Used to keep
/// existing annotations glued to the page content after the page itself is
/// physically rotated (PDF /Rotate).
InkStroke _rotateStroke90CW(InkStroke stroke) {
return stroke.copyWith(
points: stroke.points
.map((p) => p.copyWith(x: 1.0 - p.y, y: p.x))
.toList(),
);
}
Future<void> _rotateCurrentPage() async {
final rotatedPage = _currentPage;
final success = await _pdfService.rotatePage(widget.filePath, rotatedPage);
if (!success || !mounted) return;
// Invalidate thumbnail for the rotated page.
if (_currentDocumentId != null) {
await ThumbnailService.invalidatePage(_currentDocumentId!, rotatedPage);
}
setState(() {
_pdfMutationVersion++;
// The page is physically rotated 90° CW, so transform existing stored
// annotations the same way to keep them aligned with the page content.
// New strokes drawn afterwards are already captured in the rotated frame.
final existing = _annotations[rotatedPage];
if (existing != null && existing.isNotEmpty) {
_annotations[rotatedPage] = existing.map(_rotateStroke90CW).toList();
// Undo history holds pre-rotation coordinates; reset it for this page
// so undo/redo cannot reintroduce misaligned strokes.
_undoManagers.remove(rotatedPage);
}
});
// Persist the transformed annotations for the rotated page.
_saveCurrentPageAnnotations(page: rotatedPage);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Rotated page ${_currentPage + 1}')),
);
}
}
Future<void> _deleteCurrentPage() async {
// Confirm.
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Page'),
content: Text(
'Delete page ${_currentPage + 1}? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
final success = await _pdfService.deletePage(widget.filePath, _currentPage);
if (!success || !mounted) return;
if (_currentDocumentId != null) {
final db = await DatabaseService.getInstance();
// Delete annotation/bookmark/ocr data for the removed page.
await db.deletePageData(_currentDocumentId!, _currentPage);
// Remap higher-indexed data down by 1.
await db.remapAnnotationsAfterDelete(_currentDocumentId!, _currentPage);
await db.remapBookmarksAfterDelete(_currentDocumentId!, _currentPage);
// Update stored page count.
final newCount = _pageCount - 1;
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
// Invalidate all thumbnails (page indices shifted).
await ThumbnailService.invalidateAll(_currentDocumentId!);
}
// Shift in-memory annotations down.
final newAnnotations = <int, List<InkStroke>>{};
for (final entry in _annotations.entries) {
if (entry.key < _currentPage) {
newAnnotations[entry.key] = entry.value;
} else if (entry.key > _currentPage) {
newAnnotations[entry.key - 1] = entry.value;
}
// entry.key == _currentPage is dropped.
}
_annotations
..clear()
..addAll(newAnnotations);
// Shift undo managers.
final newUndoManagers = <int, UndoManager>{};
for (final entry in _undoManagers.entries) {
if (entry.key < _currentPage) {
newUndoManagers[entry.key] = entry.value;
} else if (entry.key > _currentPage) {
newUndoManagers[entry.key - 1] = entry.value;
}
}
_undoManagers
..clear()
..addAll(newUndoManagers);
// Shift bookmarks in memory.
_bookmarks.removeWhere((b) => b.pageNumber == _currentPage);
for (int i = 0; i < _bookmarks.length; i++) {
if (_bookmarks[i].pageNumber > _currentPage) {
_bookmarks[i] = Bookmark(
id: _bookmarks[i].id,
documentId: _bookmarks[i].documentId,
pageNumber: _bookmarks[i].pageNumber - 1,
label: _bookmarks[i].label,
color: _bookmarks[i].color,
createdAt: _bookmarks[i].createdAt,
);
}
}
setState(() {
_pageCount = _pageCount - 1;
if (_currentPage >= _pageCount) {
_currentPage = _pageCount - 1;
}
_pdfMutationVersion++;
});
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Page deleted')));
}
}
Future<void> _insertBlankPageAfterCurrent() async {
final success = await _pdfService.insertBlankPage(
widget.filePath,
_currentPage,
);
if (!success || !mounted) return;
final insertedIndex = _currentPage + 1;
if (_currentDocumentId != null) {
final db = await DatabaseService.getInstance();
await db.remapAnnotationsAfterInsert(_currentDocumentId!, insertedIndex);
await db.remapBookmarksAfterInsert(_currentDocumentId!, insertedIndex);
final newCount = _pageCount + 1;
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
await ThumbnailService.invalidateAll(_currentDocumentId!);
}
// Shift in-memory annotations up by 1 for pages >= insertedIndex.
final newAnnotations = <int, List<InkStroke>>{};
for (final entry in _annotations.entries) {
if (entry.key < insertedIndex) {
newAnnotations[entry.key] = entry.value;
} else {
newAnnotations[entry.key + 1] = entry.value;
}
}
_annotations
..clear()
..addAll(newAnnotations);
final newUndoManagers = <int, UndoManager>{};
for (final entry in _undoManagers.entries) {
if (entry.key < insertedIndex) {
newUndoManagers[entry.key] = entry.value;
} else {
newUndoManagers[entry.key + 1] = entry.value;
}
}
_undoManagers
..clear()
..addAll(newUndoManagers);
// Shift bookmarks in memory.
for (int i = 0; i < _bookmarks.length; i++) {
if (_bookmarks[i].pageNumber >= insertedIndex) {
_bookmarks[i] = Bookmark(
id: _bookmarks[i].id,
documentId: _bookmarks[i].documentId,
pageNumber: _bookmarks[i].pageNumber + 1,
label: _bookmarks[i].label,
color: _bookmarks[i].color,
createdAt: _bookmarks[i].createdAt,
);
}
}
setState(() {
_pageCount = _pageCount + 1;
_pdfMutationVersion++;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Blank page inserted after page ${_currentPage + 1}'),
),
);
}
}
// -- Camera Insert --
Future<void> _showCameraInsertDialog() async {
final source = await showDialog<String>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Insert Image'),
children: [
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop('camera'),
child: const ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Camera'),
),
),
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop('gallery'),
child: const ListTile(
leading: Icon(Icons.photo_library),
title: Text('Gallery'),
),
),
],
),
);
if (source == null || !mounted) return;
final String? imagePath;
if (source == 'camera') {
imagePath = await _cameraService.capturePhoto();
} else {
imagePath = await _cameraService.pickFromGallery();
}
if (imagePath == null || !mounted) return;
final result = await _pdfService.insertImageOnPage(
widget.filePath,
_currentPage,
imagePath,
);
if (result != null && mounted) {
if (_currentDocumentId != null) {
await ThumbnailService.invalidatePage(
_currentDocumentId!,
_currentPage,
);
}
setState(() {
_pdfMutationVersion++;
});
// Save current annotations so they overlay the image.
_saveCurrentPageAnnotations();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Image inserted on page ${_currentPage + 1}')),
);
}
} else if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to insert image')));
}
}
// -- Bookmark drawer --
Widget _buildBookmarkDrawer() {
return Drawer(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Bookmarks',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: _bookmarks.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
const Text(
'No bookmarks yet',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
const Text(
'Tap the bookmark icon in the toolbar\nto bookmark the current page.',
textAlign: TextAlign.center,
),
],
),
)
: ListView.builder(
itemCount: _bookmarks.length,
itemBuilder: (context, index) {
final bookmark = _bookmarks[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Color(bookmark.color),
radius: 6,
),
title: Text(
bookmark.label.isEmpty
? 'Page ${bookmark.pageNumber + 1}'
: bookmark.label,
),
subtitle: Text('Page ${bookmark.pageNumber + 1}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete bookmark',
onPressed: () => _deleteBookmark(bookmark),
),
onTap: () {
Navigator.of(context).pop();
_jumpToPage(bookmark.pageNumber);
},
onLongPress: () => _deleteBookmark(bookmark),
);
},
),
),
],
),
);
}
// -- UI --
@override
Widget build(BuildContext context) {
final undoManager = _getUndoManager(_currentPage);
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
IconButton(
icon: const Icon(Icons.vertical_split),
tooltip: 'Open in Split View',
onPressed: () {
if (_currentDocumentId == null) return;
_saveCurrentPageAnnotations();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.filePath,
documentId: _currentDocumentId!,
),
),
);
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search in PDF (Ctrl+F)',
onPressed: _openSearch,
),
IconButton(
icon: Icon(
_isCurrentPageBookmarked ? Icons.bookmark : Icons.bookmark_border,
),
tooltip: 'Toggle bookmark',
onPressed: _toggleBookmark,
),
IconButton(
icon: const Icon(Icons.menu_book),
tooltip: 'Bookmarks',
onPressed: () => _scaffoldKey.currentState?.openEndDrawer(),
),
IconButton(
icon: Icon(
_showThumbnails
? Icons.view_sidebar
: Icons.view_sidebar_outlined,
),
tooltip: 'Toggle page thumbnails',
onPressed: () => setState(() => _showThumbnails = !_showThumbnails),
),
PopupMenuButton<_OverflowAction>(
icon: const Icon(Icons.more_vert),
tooltip: 'More actions',
onSelected: (action) {
switch (action) {
case _OverflowAction.pageManagement:
_showPageManagementSheet();
case _OverflowAction.cameraInsert:
_showCameraInsertDialog();
case _OverflowAction.export:
_exportPdf();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _OverflowAction.pageManagement,
child: ListTile(
leading: Icon(Icons.pages),
title: Text('Page Management'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: _OverflowAction.cameraInsert,
child: ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Insert Image'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: _OverflowAction.export,
child: ListTile(
leading: Icon(Icons.save_alt),
title: Text('Export PDF'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
endDrawer: _buildBookmarkDrawer(),
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
_openSearch,
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveCurrentPageAnnotations,
const SingleActivator(LogicalKeyboardKey.escape): () {
if (_interactionMode != InteractionMode.navigate) {
setState(() => _interactionMode = InteractionMode.navigate);
}
},
},
child: Focus(
autofocus: true,
child: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: undoManager.canUndo,
canRedo: undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) =>
setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
onPreviousPage: _currentPage > 0
? () => _viewerController.previousPage()
: null,
onNextPage: _currentPage < _pageCount - 1
? () => _viewerController.nextPage()
: null,
pageInfo: '${_currentPage + 1} / $_pageCount',
interactionMode: _interactionMode,
onInteractionModeChanged: (mode) =>
setState(() => _interactionMode = mode),
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomFitWidth,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: Row(
children: [
if (_showThumbnails && _currentDocumentId != null)
PageThumbnailSidebar(
documentId: _currentDocumentId!,
filePath: widget.filePath,
pageCount: _pageCount,
currentPage: _currentPage,
onPageTap: _jumpToPage,
bookmarkedPages: _bookmarks
.map((b) => b.pageNumber)
.toSet(),
),
Expanded(
child: Stack(
children: [
SfPdfViewer.file(
File(widget.filePath),
key: ValueKey('pdf-$_pdfMutationVersion'),
controller: _viewerController,
initialPageNumber: _currentPage + 1,
onPageChanged: (PdfPageChangedDetails details) {
_onPageChanged(details.newPageNumber - 1);
},
),
Positioned.fill(
child: _interactionMode == InteractionMode.navigate
? IgnorePointer(
child: PdfAnnotationLayer(
strokes: _getCurrentStrokes(),
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
filled: _filled,
interactionMode: _interactionMode,
),
)
: PdfAnnotationLayer(
strokes: _getCurrentStrokes(),
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
filled: _filled,
interactionMode: _interactionMode,
),
),
],
),
),
],
),
),
],
),
),
),
);
}
@override
void dispose() {
_saveCurrentPageAnnotations();
_viewerController.dispose();
super.dispose();
}
}

View File

@@ -1,528 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Per-slide annotation state. The [UndoManager] is the single source of
/// truth for a slide's strokes; [strokes] reflects its current contents so
/// the live canvas and the PDF export always render what was actually drawn.
class _SlideAnnotations {
final UndoManager undoManager = UndoManager();
List<InkStroke> get strokes => undoManager.currentStrokes;
}
/// Screen that displays PPTX slides with an ink annotation overlay.
///
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
/// sits on top of each slide so the user can annotate freely. Annotations are
/// stored per-slide and can be exported as a PDF.
class PptAnnotatorScreen extends StatefulWidget {
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
const PptAnnotatorScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
@override
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
}
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
late final PageController _pageController;
late final Map<int, _SlideAnnotations> _annotations;
int _currentPage = 0;
bool _isDrawing = false;
bool _showTextPanel = false;
// Set to true once the unsaved-annotations warning SnackBar has been shown.
bool _hasShownUnsavedWarning = false;
// Toolbar state
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// Derived
late final String _fileName;
late final int _slideCount;
late final String _extractedText;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
UndoManager get _currentUndoManager =>
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
@override
void initState() {
super.initState();
_fileName = p.basename(widget.filePath);
_slideCount = widget.slideImagePaths.length;
_extractedText = widget.extractedText ?? '';
_pageController = PageController();
_annotations = {};
for (var i = 0; i < _slideCount; i++) {
_annotations[i] = _SlideAnnotations();
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// -- Drawing callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_currentUndoManager.addStroke(stroke);
});
// Warn once per session that PPT annotations are not auto-saved.
if (!_hasShownUnsavedWarning) {
_hasShownUnsavedWarning = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
),
duration: Duration(seconds: 5),
),
);
});
}
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _currentUndoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_currentUndoManager.removeStroke(original, replacements: replacements);
}
});
}
// -- Export --
Future<void> _exportPdf() async {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
if (!mounted) return;
final dir = await _getExportDir();
final baseName = p.basenameWithoutExtension(_fileName);
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _getExportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
// Draw slide image
final imgPath = widget.slideImagePaths[i];
try {
final imgBytes = await File(imgPath).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imgW = bitmap.width.toDouble();
final imgH = bitmap.height.toDouble();
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
final drawW = imgW * scale;
final drawH = imgH * scale;
final offX = (pageSize.width - drawW) / 2;
final offY = (pageSize.height - drawH) / 2;
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
page.graphics.drawImage(bitmap, imgRect);
// Draw ink strokes
final annots = _annotations[i];
if (annots != null && annots.strokes.isNotEmpty) {
// KNOWN LIMITATION: strokes are captured in the live viewer's
// full-fill pixel space (the InkCanvas is Positioned.fill over the
// whole slide area, while the slide image is BoxFit.contain inside
// it). The scale below is derived from the PDF page layout, not the
// live widget size, so exported ink can be misaligned/scaled wrong.
// A correct fix normalizes strokes to [0,1] of the *rendered image
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
// to the PDF draw rect here. Requires on-device visual verification.
final imgAspect = imgW / imgH;
final pageAspect = pageSize.width / pageSize.height;
double widgetW, widgetH;
if (imgAspect > pageAspect) {
widgetW = pageSize.width;
widgetH = pageSize.width / imgAspect;
} else {
widgetH = pageSize.height;
widgetW = pageSize.height * imgAspect;
}
final scaleX = drawW / widgetW;
final scaleY = drawH / widgetH;
for (final stroke in annots.strokes) {
if (stroke.tool == PenTool.eraser) continue;
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final pdfColor = PdfColor(r, g, b);
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final pt1 = stroke.points[j];
final pt2 = stroke.points[j + 1];
path.addLine(
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
);
}
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// -- UI --
@override
Widget build(BuildContext context) {
// No slides to annotate: show an empty state and skip the toolbar, which
// would otherwise dereference a non-existent slide's annotation state.
if (_slideCount == 0) {
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
),
body: const Center(child: Text('No slides to display')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (_extractedText.isNotEmpty)
IconButton(
icon: Icon(
_showTextPanel
? Icons.text_snippet
: Icons.text_snippet_outlined,
),
tooltip: 'Toggle extracted text',
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Export as PDF',
onPressed: _exportPdf,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _currentUndoManager.canUndo,
canRedo: _currentUndoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
Expanded(
child: Row(
children: [
Expanded(child: _buildSlideViewer()),
if (_showTextPanel) _buildTextPanel(),
],
),
),
_buildPageIndicator(),
],
),
);
}
Widget _buildSlideViewer() {
if (_slideCount == 0) {
return const Center(child: Text('No slides to display'));
}
return Listener(
onPointerDown: (_) => setState(() => _isDrawing = true),
onPointerUp: (_) => setState(() => _isDrawing = false),
child: PageView.builder(
controller: _pageController,
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
itemCount: _slideCount,
onPageChanged: (page) => setState(() => _currentPage = page),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: Stack(
children: [
// Slide image (background)
Positioned.fill(
child: Image.file(
File(widget.slideImagePaths[index]),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey.shade200,
child: Center(
child: Text(
'Slide ${index + 1}',
style: TextStyle(
fontSize: 24,
color: Colors.grey.shade500,
),
),
),
),
),
),
// Ink annotation overlay (foreground)
Positioned.fill(
child: InkCanvas(
strokes: _annotations[index]?.strokes ?? [],
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
],
),
);
},
),
);
}
Widget _buildPageIndicator() {
if (_slideCount == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Previous button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0
? () => _pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
// Dot row for small decks; compact text counter for large decks.
if (_slideCount <= 12)
...List.generate(_slideCount, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Container(
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
);
})
else
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
// Next button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < _slideCount - 1
? () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
const Spacer(),
// Slide counter is always shown at the trailing end for dot mode;
// the compact text above already serves this role for large decks.
if (_slideCount <= 12)
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildTextPanel() {
return SizedBox(
width: 280,
child: Card(
margin: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Row(
children: [
const Icon(Icons.text_fields, size: 18),
const SizedBox(width: 8),
Text(
'Extracted Text',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _showTextPanel = false),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(
_extractedText,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
// -- Dialogs --
void _undo() {
setState(() => _currentUndoManager.undo());
}
void _redo() {
setState(() => _currentUndoManager.redo());
}
}

View File

@@ -3,13 +3,16 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
const SearchScreen({super.key, this.embeddedInShell = false});
final bool embeddedInShell;
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
@@ -42,16 +45,17 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
@override
Widget build(BuildContext context) {
final results = ref.watch(searchResultsProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search notes and documents...',
decoration: InputDecoration(
hintText: l.searchHint,
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
hintStyle: const TextStyle(color: Colors.grey),
),
onChanged: _onQueryChanged,
onSubmitted: (value) {
@@ -72,14 +76,14 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
body: results.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Search error: $e')),
error: (e, _) => Center(child: Text(l.searchError('$e'))),
data: (hits) {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) {
return const Center(
return Center(
child: Text(
'Type to search your notes and documents',
style: TextStyle(color: Colors.grey),
l.typeToSearch,
style: const TextStyle(color: Colors.grey),
),
);
}
@@ -95,7 +99,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
),
const SizedBox(height: 16),
Text(
'No results for "$query"',
l.noResultsFor(query),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
@@ -114,7 +118,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
l.sectionNotes,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
@@ -129,7 +133,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Documents',
l.sectionDocuments,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
@@ -177,7 +181,7 @@ class _NoteSearchResultTile extends StatelessWidget {
onTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
},
);
}
@@ -209,7 +213,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Page ${pageNumber + 1}',
AppLocalizations.of(context).pageLabel(pageNumber + 1),
style: Theme.of(context).textTheme.bodySmall,
),
if (snippet.isNotEmpty)
@@ -220,7 +224,7 @@ class _DocumentSearchResultTile extends StatelessWidget {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
PenEditorScreen(pdfPath: filePath, initialPage: pageNumber),
),
);
},

View File

@@ -1,15 +1,24 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_localizations.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../screens/app_shell.dart' show exportDiagnosticPack;
import '../services/badnote_server_client.dart';
import '../services/vault_service.dart';
import '../services/webdav_sync_service.dart';
import '../utils/stroke_stabilizer.dart';
/// Material 3 settings screen for BadNote.
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
const SettingsScreen({super.key, this.embeddedInShell = false});
final bool embeddedInShell;
void _showColorPicker(
BuildContext context,
@@ -20,8 +29,9 @@ class SettingsScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (context) {
final l = AppLocalizations.of(context);
return AlertDialog(
title: const Text('Pick a color'),
title: Text(l.pickColor),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
@@ -31,14 +41,14 @@ class SettingsScreen extends ConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(l.cancel),
),
TextButton(
onPressed: () {
onPicked(pickerColor);
Navigator.of(context).pop();
},
child: const Text('OK'),
child: Text(l.ok),
),
],
);
@@ -50,25 +60,23 @@ class SettingsScreen extends ConsumerWidget {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all local settings?'),
content: const Text(
'This will reset pen defaults and appearance settings. '
'Notes and documents are not affected.',
),
title: Text(AppLocalizations.of(ctx).clearSettingsTitle),
content: Text(AppLocalizations.of(ctx).settingsClearConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
child: Text(AppLocalizations.of(ctx).cancel),
),
TextButton(
onPressed: () {
ref.read(settingsProvider).clearAllData();
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings reset to defaults')),
SnackBar(
content: Text(AppLocalizations.of(context).settingsReset)),
);
},
child: const Text('Clear'),
child: Text(AppLocalizations.of(ctx).clear),
),
],
),
@@ -81,18 +89,34 @@ class SettingsScreen extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: embeddedInShell
? AppBar(title: Text(AppLocalizations.of(context).settings))
: AppBar(title: Text(AppLocalizations.of(context).settings)),
body: ListView(
children: [
_SectionHeader(title: 'Defaults', icon: Icons.tune),
_SectionHeader(
title: AppLocalizations.of(context).diagnosticsSection,
icon: Icons.bug_report_outlined,
),
ListTile(
title: Text(AppLocalizations.of(context).diagnosticsExport),
subtitle: Text(AppLocalizations.of(context).diagnosticsExportHint),
trailing: const Icon(Icons.ios_share),
onTap: () => exportDiagnosticPack(context),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).settingsDefaults,
icon: Icons.tune,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Default Tool',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultTool,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PenTool>(
@@ -112,9 +136,9 @@ class SettingsScreen extends ConsumerWidget {
},
),
const SizedBox(height: 16),
const Text(
'Default Color',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultColor,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
@@ -147,9 +171,9 @@ class SettingsScreen extends ConsumerWidget {
],
),
const SizedBox(height: 16),
const Text(
'Default Stroke Width',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsDefaultWidth,
style: const TextStyle(fontWeight: FontWeight.w500),
),
Slider(
value: settings.defaultStrokeWidth,
@@ -160,9 +184,9 @@ class SettingsScreen extends ConsumerWidget {
onChanged: settings.setDefaultStrokeWidth,
),
const SizedBox(height: 16),
const Text(
'Pressure Curve',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsPressureCurve,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PressureCurveType>(
@@ -209,33 +233,36 @@ class SettingsScreen extends ConsumerWidget {
),
),
const Divider(),
_SectionHeader(title: 'Appearance', icon: Icons.palette),
_SectionHeader(
title: AppLocalizations.of(context).settingsAppearance,
icon: Icons.palette,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Theme Mode',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).settingsAppearance,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
SegmentedButton<ThemeMode>(
segments: const [
segments: [
ButtonSegment(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
label: Text(AppLocalizations.of(context).themeSystem),
icon: const Icon(Icons.brightness_auto),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
label: Text(AppLocalizations.of(context).themeLight),
icon: const Icon(Icons.light_mode),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
label: Text(AppLocalizations.of(context).themeDark),
icon: const Icon(Icons.dark_mode),
),
],
selected: {settings.themeMode},
@@ -244,9 +271,9 @@ class SettingsScreen extends ConsumerWidget {
},
),
const SizedBox(height: 16),
const Text(
'Color Scheme Seed',
style: TextStyle(fontWeight: FontWeight.w500),
Text(
AppLocalizations.of(context).seedColorDesc,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
@@ -272,14 +299,44 @@ class SettingsScreen extends ConsumerWidget {
),
),
const SizedBox(width: 12),
const Text('Seed color for Material 3 theme'),
Text(AppLocalizations.of(context).seedColorDesc),
],
),
],
),
),
const Divider(),
_SectionHeader(title: 'About', icon: Icons.info),
_SectionHeader(
title: AppLocalizations.of(context).vaultSection,
icon: Icons.folder_special,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _VaultSettings(),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).syncSection,
icon: Icons.cloud_sync,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _SyncSettings(),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).serverSection,
icon: Icons.dns_outlined,
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _ServerSettings(),
),
const Divider(),
_SectionHeader(
title: AppLocalizations.of(context).settingsAbout,
icon: Icons.info,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
@@ -317,6 +374,520 @@ class SettingsScreen extends ConsumerWidget {
}
}
/// Shows the current vault folder path and lets the user re-pick it. Re-uses
/// the same `getDirectoryPath` flow as the first-run [VaultSetupScreen],
/// persisting the choice through [VaultService.setVaultRoot].
class _VaultSettings extends StatefulWidget {
const _VaultSettings();
@override
State<_VaultSettings> createState() => _VaultSettingsState();
}
class _VaultSettingsState extends State<_VaultSettings> {
VaultService? _vault;
String? _path;
bool _busy = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final vault = await VaultService.getInstance();
if (!mounted) return;
setState(() {
_vault = vault;
_path = vault.vaultRoot;
});
}
Future<void> _changeFolder() async {
final vault = _vault;
if (vault == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
setState(() => _busy = true);
try {
final path = await FilePicker.platform.getDirectoryPath(
dialogTitle: l.vaultSetupTitle,
lockParentWindow: true,
);
if (path != null) {
await vault.setVaultRoot(path);
if (!mounted) return;
setState(() => _path = path);
messenger.showSnackBar(SnackBar(content: Text(l.vaultUpdated)));
}
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l.vaultPickFailed(e.toString()))),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.vaultFolderLabel,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
(_path == null || _path!.isEmpty) ? l.vaultNoneSelected : _path!,
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _busy ? null : _changeFolder,
icon: const Icon(Icons.drive_folder_upload),
label: Text(l.vaultChangeFolder),
),
],
);
}
}
/// WebDAV sync configuration + actions. Persists config through
/// [WebDavSyncService] (SharedPreferences-backed), exposes Test connection /
/// Sync now buttons, the last-synced time and last result, and an
/// "auto-sync on launch" toggle (default OFF). All network ops are
/// time-bounded inside the service and surface friendly errors here.
class _SyncSettings extends StatefulWidget {
const _SyncSettings();
@override
State<_SyncSettings> createState() => _SyncSettingsState();
}
class _SyncSettingsState extends State<_SyncSettings> {
WebDavSyncService? _sync;
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _folderCtrl = TextEditingController();
bool _autoSync = false;
bool _busy = false;
bool _testing = false;
bool _obscure = true;
DateTime? _lastSync;
SyncResult? _lastResult;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
_folderCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final sync = WebDavSyncService(prefs);
if (!mounted) return;
final c = sync.config;
setState(() {
_sync = sync;
_urlCtrl.text = c.baseUrl;
_userCtrl.text = c.username;
_passCtrl.text = c.password;
_folderCtrl.text = c.remoteRoot;
_autoSync = c.autoSync;
_lastSync = sync.lastSyncTime;
});
}
WebDavConfig _currentConfig() => WebDavConfig(
baseUrl: _urlCtrl.text,
username: _userCtrl.text,
password: _passCtrl.text,
remoteRoot: _folderCtrl.text,
autoSync: _autoSync,
);
Future<void> _save() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l.syncSaved)));
}
Future<void> _testConnection() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
setState(() => _testing = true);
final client = sync.buildClient();
try {
if (client == null) {
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
return;
}
await client.testConnection();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l.syncTestOk)));
} catch (e) {
if (!mounted) return;
messenger
.showSnackBar(SnackBar(content: Text(l.syncTestFailed(e.toString()))));
} finally {
client?.close();
if (mounted) setState(() => _testing = false);
}
}
Future<void> _syncNow() async {
final sync = _sync;
if (sync == null) return;
final l = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
await sync.saveConfig(_currentConfig());
final vault = await VaultService.getInstance();
final root = vault.vaultRoot;
if (root == null || root.isEmpty) {
messenger.showSnackBar(SnackBar(content: Text(l.vaultNoneSelected)));
return;
}
setState(() => _busy = true);
final client = sync.buildClient();
try {
if (client == null) {
messenger.showSnackBar(SnackBar(content: Text(l.syncNotConfigured)));
return;
}
final result = await sync.syncNow(vaultRoot: root, client: client);
if (!mounted) return;
setState(() {
_lastResult = result;
_lastSync = sync.lastSyncTime;
});
messenger.showSnackBar(SnackBar(
content: Text(result.ok
? l.syncResultSummary(
result.uploaded, result.downloaded, result.conflicts)
: l.syncFailed(result.error ?? '')),
));
} finally {
client?.close();
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final configured = _urlCtrl.text.trim().isNotEmpty;
final lastResult = _lastResult;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncServerUrl,
hintText: l.syncServerUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
TextField(
controller: _userCtrl,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncUsername,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: _obscure,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncPassword,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _folderCtrl,
autocorrect: false,
decoration: InputDecoration(
labelText: l.syncRemoteFolder,
hintText: l.syncRemoteFolderHint,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 8),
Text(
l.syncCredentialsNote,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(l.syncAuto),
value: _autoSync,
onChanged: (v) {
setState(() => _autoSync = v);
_save();
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: (_busy || _testing) ? null : _save,
icon: const Icon(Icons.save),
label: Text(l.syncSave),
),
OutlinedButton.icon(
onPressed: (!configured || _busy || _testing)
? null
: _testConnection,
icon: _testing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_tethering),
label: Text(l.syncTestConnection),
),
FilledButton.icon(
onPressed: (!configured || _busy || _testing) ? null : _syncNow,
icon: _busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(_busy ? l.syncRunning : l.syncNow),
),
],
),
const SizedBox(height: 12),
Text(
_lastSync == null
? l.syncNeverRun
: l.syncLastRun(_lastSync!.toLocal().toString()),
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
),
if (lastResult != null && lastResult.ok) ...[
const SizedBox(height: 4),
Text(
l.syncResultSummary(lastResult.uploaded, lastResult.downloaded,
lastResult.conflicts),
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
),
],
],
);
}
}
class _ServerSettings extends StatefulWidget {
const _ServerSettings();
@override
State<_ServerSettings> createState() => _ServerSettingsState();
}
class _ServerSettingsState extends State<_ServerSettings> {
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
bool _busy = false;
bool _loggedIn = false;
String? _status;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final cfg = await BadNoteServerConfig.load(prefs);
if (!mounted) return;
setState(() {
_urlCtrl.text = cfg.baseUrl;
_userCtrl.text = cfg.username;
_passCtrl.text = cfg.password;
_loggedIn = cfg.isLoggedIn;
});
}
Future<void> _saveAndLogin() async {
final l = AppLocalizations.of(context);
setState(() => _busy = true);
try {
final prefs = await SharedPreferences.getInstance();
var cfg = BadNoteServerConfig(
baseUrl: _urlCtrl.text.trim(),
username: _userCtrl.text.trim(),
password: _passCtrl.text,
);
final client = BadNoteServerClient(cfg);
cfg = await client.registerOrLogin(
username: cfg.username,
password: cfg.password,
);
await cfg.save(prefs);
client.close();
if (!mounted) return;
setState(() {
_loggedIn = true;
_status = l.serverLoggedIn;
});
} catch (e) {
if (!mounted) return;
setState(() => _status = l.serverTestFail('$e'));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _test() async {
final l = AppLocalizations.of(context);
setState(() => _busy = true);
try {
final cfg = BadNoteServerConfig(baseUrl: _urlCtrl.text.trim());
final client = BadNoteServerClient(cfg);
final health = await client.health();
client.close();
if (!mounted) return;
setState(() {
_status = l.serverTestOk('${health['version'] ?? health['api']}');
});
} catch (e) {
if (!mounted) return;
setState(() => _status = l.serverTestFail('$e'));
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.serverHint,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
decoration: InputDecoration(
labelText: l.serverUrl,
hintText: l.serverUrlHint,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.url,
),
const SizedBox(height: 8),
TextField(
controller: _userCtrl,
decoration: InputDecoration(
labelText: l.serverUsername,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 8),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: l.serverPassword,
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton(
onPressed: _busy ? null : _saveAndLogin,
child: Text(l.serverSave),
),
OutlinedButton(
onPressed: _busy ? null : _test,
child: Text(l.serverTest),
),
if (_loggedIn)
Chip(
avatar: const Icon(Icons.check_circle, size: 16),
label: Text(l.serverLoggedIn),
),
],
),
if (_status != null) ...[
const SizedBox(height: 8),
Text(_status!, style: Theme.of(context).textTheme.bodySmall),
],
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;

View File

@@ -1,30 +1,55 @@
// lib/screens/split_view_screen.dart
//
// Anchor-keyed split view: opened by tapping a PDF-anchored scratch link.
// LEFT pane = the source PDF (pdfrx PdfViewer, read-only reference + page
// nav), opened at the anchor's page.
// RIGHT pane = an INFINITE freehand scratchpad that BELONGS TO THE ANCHOR,
// keyed by [scratchLinkId] (not the documentId). Each anchor has
// its own private scratch space, persisted via the existing
// scratchpad storage (InkStroke JSON, format unchanged).
//
// The right pane reuses the pen-first PenCanvas world-coord engine and the
// Material You brush palette shared with pen_note_screen (BrushPickerButton /
// ToolButton / color dots), replacing the old AnnotationToolbar.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:uuid/uuid.dart';
import '../editor/canvas/pen_canvas.dart';
import '../editor/canvas/pen_palette_widgets.dart';
import '../editor/canvas/pen_stroke.dart';
import '../editor/engine/brush.dart';
import '../editor/input/pen_config.dart' show kDefaultEraserRadius;
import '../editor/notebook/ink_stroke_adapter.dart';
import '../editor/persistence/sidecar_repository.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/database_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
import '../storage/badnote_sidecar.dart';
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
/// Split-view derivation surface for a single scratch-link anchor: left pane =
/// the reference PDF (at [initialPage]), right pane = the anchor's private
/// infinite scratchpad. Scratchpad strokes persist inside the source file's
/// SIDECAR (`<filePath>.badnote.json`), embedded in the anchor's
/// `scratchLinks[id].scratchpad` (keyed by [scratchLinkId]). Strokes keep the
/// absolute world-pixel [InkStroke] format unchanged.
class SplitViewScreen extends StatefulWidget {
final String filePath;
final String documentId;
/// The owning anchor id. Selects which `scratchLinks[].scratchpad` in the
/// sidecar this is the private scratch space for.
final String scratchLinkId;
/// 0-based page the anchor sits on; the left PDF opens here.
final int initialPage;
const SplitViewScreen({
super.key,
required this.filePath,
required this.documentId,
required this.scratchLinkId,
this.initialPage = 0,
});
@override
@@ -34,34 +59,46 @@ class SplitViewScreen extends StatefulWidget {
class _SplitViewState extends State<SplitViewScreen> {
// -- PDF (left pane) --
final PdfViewerController _pdfController = PdfViewerController();
int _currentPage = 0;
int _currentPage = 0; // 0-based
int _pageCount = 0;
String _fileName = '';
// -- Split divider --
double _leftPaneFraction = 0.5;
bool _isDraggingDivider = false;
// -- Scratchpad (right pane) --
// Infinite WORLD: strokes stored in absolute world pixels ([InkStroke],
// unchanged persistence format), rendered through PenCanvas by normalizing
// against the CURRENT world size. World auto-expands without moving ink.
final UndoManager _undoManager = UndoManager();
List<InkStroke> _strokes = [];
double _canvasWidth = 4000;
double _canvasHeight = 4000;
static const _uuid = Uuid();
// -- Tool state --
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _scratchTransform = TransformationController();
bool _scratchCentered = false;
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
// -- Tool state (new Material You brush palette) --
CanvasTool _tool = CanvasTool.pen;
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
// -- Auto-save debounce --
Timer? _saveTimer;
bool _dirty = false;
// -- Page link markers (optional feature) --
final List<_PageLink> _pageLinks = [];
/// Sidecar persistence for the source file (the scratchpad is embedded in the
/// anchor's `scratchLinks[id].scratchpad`). Null until [_loadScratchpad]
/// resolves.
SidecarRepository? _repo;
static const double _edgeThreshold = 200.0;
static const double _expandAmount = 1000.0;
@@ -69,30 +106,52 @@ class _SplitViewState extends State<SplitViewScreen> {
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_loadScratchpad();
}
@override
void dispose() {
_saveTimer?.cancel();
_saveImmediate();
_pdfController.dispose();
// Schedule a final flush; retain-counted repo may still be held by the
// PDF editor, so dispose only drops our retain.
if (_dirty) {
unawaited(_saveImmediate());
} else {
unawaited(_repo?.flush() ?? Future<void>.value());
}
_repo?.dispose();
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
_scratchTransform.dispose();
super.dispose();
}
// -- Persistence --
// -- Persistence (sidecar's scratchLinks[id].scratchpad, keyed by anchor id) --
Future<void> _loadScratchpad() async {
final db = await DatabaseService.getInstance();
final strokes = await db.loadScratchpad(widget.documentId);
if (mounted) {
setState(() {
_strokes = strokes;
for (final s in strokes) {
_undoManager.addStroke(s);
}
});
final repo = await SidecarRepository.open(widget.filePath, docType: 'pdf');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
final pad = repo.scratchpadFor(widget.scratchLinkId);
setState(() {
if (pad != null) {
// Restore the world size so the infinite canvas reopens at its grown
// extent (previously always reset to 4000×4000).
_canvasWidth = pad.canvasWidth;
_canvasHeight = pad.canvasHeight;
}
final strokes = pad?.strokes ?? const <InkStroke>[];
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
// manager (shapes/text have no pen-canvas representation).
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
_strokes = freehand;
for (final s in freehand) {
_undoManager.addStroke(s);
}
});
}
void _scheduleSave() {
@@ -103,15 +162,26 @@ class _SplitViewState extends State<SplitViewScreen> {
Future<void> _saveImmediate() async {
if (!_dirty) return;
final repo = _repo;
if (repo == null) return;
// Keep dirty until schedule succeeds so a race during load can't swallow ink.
repo.scheduleScratchpadSave(
widget.scratchLinkId,
SidecarScratchpad(
canvasWidth: _canvasWidth,
canvasHeight: _canvasHeight,
strokes: List<InkStroke>.of(_strokes),
),
);
_dirty = false;
final db = await DatabaseService.getInstance();
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
await db.saveScratchpad(widget.documentId, json);
await repo.flush();
}
// -- Scratchpad stroke callbacks --
void _onStrokeComplete(InkStroke stroke) {
void _onStrokeComplete(PenStroke pen) {
final stroke = inkStrokeFromPen(pen, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now());
setState(() {
_strokes.add(stroke);
_undoManager.addStroke(stroke);
@@ -120,13 +190,17 @@ class _SplitViewState extends State<SplitViewScreen> {
_scheduleSave();
}
void _onErase(String strokeId, List<InkStroke> replacements) {
void _onErase(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_strokes = List.from(_undoManager.currentStrokes);
}
final original = _strokes[index];
final inkReplacements = [
for (final r in replacements)
inkStrokeFromPen(r, _worldSize,
id: _uuid.v4(), createdAt: DateTime.now()),
];
_undoManager.removeStroke(original, replacements: inkReplacements);
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
@@ -190,71 +264,40 @@ class _SplitViewState extends State<SplitViewScreen> {
setState(() => _isDraggingDivider = false);
}
// -- PDF page navigation --
// -- PDF page navigation (left pane) --
void _prevPage() {
if (_currentPage > 0) {
_pdfController.previousPage();
}
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
}
void _nextPage() {
if (_currentPage < _pageCount - 1) {
_pdfController.nextPage();
_pdfController.goToPage(pageNumber: _currentPage + 2);
}
}
// -- Page link creation (long-press on left pane) --
CanvasTool get _activeTool => _tool;
void _onPdfLongPress(int pageNumber) {
// Place a page link marker at the current scratchpad viewport center.
// We approximate the viewport center as (0, 0) since InteractiveViewer
// manages its own transform — the user can reposition by panning.
setState(() {
_pageLinks.add(
_PageLink(
pageNumber: pageNumber,
position: const Offset(100, 100), // default top-left area
),
);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Page link marker added for page $pageNumber')),
);
}
void _onPageLinkTap(_PageLink link) {
_pdfController.jumpToPage(link.pageNumber);
setState(() {
_currentPage = link.pageNumber - 1;
});
}
void _deletePageLink(_PageLink link) {
setState(() {
_pageLinks.remove(link);
});
}
double get _strokeWidth => _tool == CanvasTool.highlighter
? _highlighterWidthFraction
: _penWidthFraction;
// -- Build --
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(
_fileName.isEmpty ? 'Split View' : _fileName,
style: const TextStyle(fontSize: 16),
),
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_saveImmediate();
Navigator.of(context).pop();
onPressed: () async {
await _saveImmediate();
if (context.mounted) Navigator.of(context).pop();
},
),
actions: [
// Left pane page navigation
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page (PDF)',
@@ -275,7 +318,6 @@ class _SplitViewState extends State<SplitViewScreen> {
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
),
const SizedBox(width: 8),
// Canvas info
Tooltip(
message:
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
@@ -293,7 +335,6 @@ class _SplitViewState extends State<SplitViewScreen> {
),
body: Column(
children: [
// Label clarifying that the toolbar controls the scratchpad pane.
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Align(
@@ -301,47 +342,22 @@ class _SplitViewState extends State<SplitViewScreen> {
child: Text(
'Scratchpad tools',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
// Toolbar (applies to scratchpad only)
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
// Split view body
_buildBrushPalette(cs),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final leftWidth = totalWidth * _leftPaneFraction;
final rightWidth =
totalWidth - leftWidth - 12; // 12px divider hit area
final rightWidth = totalWidth - leftWidth - 12;
return Row(
children: [
// Left pane: PDF reference (read-only)
SizedBox(width: leftWidth, child: _buildPdfPane()),
// Draggable divider: 12px hit area, 4px visual strip.
GestureDetector(
onHorizontalDragStart: _onDividerDragStart,
onHorizontalDragUpdate: (d) =>
@@ -362,7 +378,6 @@ class _SplitViewState extends State<SplitViewScreen> {
),
),
),
// Right pane: Infinite scratchpad
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
],
);
@@ -374,96 +389,188 @@ class _SplitViewState extends State<SplitViewScreen> {
);
}
Widget _buildPdfPane() {
return Stack(
children: [
GestureDetector(
onLongPress: () {
// Long-press on PDF to create page link marker
_onPdfLongPress(_currentPage + 1);
},
child: SfPdfViewer.file(
File(widget.filePath),
controller: _pdfController,
canShowScrollHead: true,
canShowScrollStatus: true,
onPageChanged: (PdfPageChangedDetails details) {
setState(() {
_currentPage = details.newPageNumber - 1;
});
},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
setState(() {
_pageCount = details.document.pages.count;
_fileName = widget.filePath.split(Platform.pathSeparator).last;
});
},
),
),
// Page link markers overlay (on PDF pane, showing linked pages)
if (_pageLinks.isNotEmpty)
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
],
);
}
Widget _buildPageLinkChips() {
return Wrap(
spacing: 4,
runSpacing: 4,
children: _pageLinks.map((link) {
return GestureDetector(
onTap: () => _onPageLinkTap(link),
onLongPress: () => _deletePageLink(link),
child: Chip(
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
label: Text(
'p${link.pageNumber}',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
backgroundColor: Colors.blue.shade600,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
);
}).toList(),
);
}
Widget _buildScratchpadPane() {
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: InteractiveViewer(
constrained: false,
minScale: 0.25,
maxScale: 8.0,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: InkCanvas(
strokes: _strokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: PressureCurve(type: _pressureCurveType),
stabilizationLevel: _stabilizationLevel,
filled: _filled,
interactionMode: InteractionMode.draw,
/// Material You brush palette (shared chrome with the pen-first editors):
/// brush picker + highlighter + eraser, undo/redo, color dots.
Widget _buildBrushPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
tooltip: 'Brush',
labelFor: brushLabelEn,
// The scratchpad keeps a single shared color (no per-brush color
// memory in this surface); show it for every brush.
colorFor: (_) => _color,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undoManager.canUndo ? _undo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _undoManager.canRedo ? _redo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
],
),
),
),
);
}
}
/// A marker linking a scratchpad position to a specific PDF page.
class _PageLink {
final int pageNumber;
final Offset position;
Widget _colorDot(Color c, ColorScheme cs) {
final selected =
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
return GestureDetector(
onTap: () => setState(() {
_color = c;
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
const _PageLink({required this.pageNumber, required this.position});
Widget _buildPdfPane() {
// Read-only reference PDF on the same engine (pdfrx) as the rest of the
// app, opened at the anchor's page.
return PdfViewer.file(
widget.filePath,
controller: _pdfController,
params: PdfViewerParams(
onViewerReady: (document, controller) {
if (!mounted) return;
setState(() {
_pageCount = document.pages.length;
});
final target = widget.initialPage.clamp(0, _pageCount - 1);
if (target > 0) {
controller.goToPage(pageNumber: target + 1);
}
},
onPageChanged: (pageNumber) {
if (pageNumber == null || !mounted) return;
final idx = pageNumber - 1;
if (idx != _currentPage) setState(() => _currentPage = idx);
},
),
);
}
Widget _buildScratchpadPane() {
return LayoutBuilder(
builder: (context, constraints) {
if (!_scratchCentered) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_frameScratchpad(
Size(constraints.maxWidth, constraints.maxHeight));
setState(() => _scratchCentered = true);
});
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: PenCanvas(
pageSize: _worldSize,
strokes: penStrokesFromInk(_strokes, _worldSize),
transformationController: _scratchTransform,
tool: _activeTool,
brush: _penBrush,
color: _color,
strokeWidth: _strokeWidth,
eraserRadius: kDefaultEraserRadius,
minScale: 0.1,
maxScale: 8.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Colors.white),
),
);
},
);
}
/// Position the scratchpad so existing ink is on-screen (fit its bbox into
/// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1.
void _frameScratchpad(Size pane) {
if (pane.isEmpty) return;
if (_strokes.isEmpty) {
_scratchTransform.value = Matrix4.identity();
return;
}
double minX = double.infinity, minY = double.infinity;
double maxX = -double.infinity, maxY = -double.infinity;
for (final s in _strokes) {
for (final p in s.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
}
if (minX > maxX) {
_scratchTransform.value = Matrix4.identity();
return;
}
const pad = 80.0;
final boxW = (maxX - minX) + pad * 2;
final boxH = (maxY - minY) + pad * 2;
final scale = (pane.width / boxW < pane.height / boxH
? pane.width / boxW
: pane.height / boxH)
.clamp(0.15, 1.5);
final cx = (minX + maxX) / 2;
final cy = (minY + maxY) / 2;
final tx = pane.width / 2 - scale * cx;
final ty = pane.height / 2 - scale * cy;
_scratchTransform.value = Matrix4.identity()
..setEntry(0, 0, scale)
..setEntry(1, 1, scale)
..setEntry(2, 2, scale)
..setTranslationRaw(tx, ty, 0);
}
}

View File

@@ -0,0 +1,158 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart';
import '../services/vault_service.dart';
/// First-run (and re-prompt) gate that asks the user to pick a vault root
/// folder — the single folder under which all notebooks will live, like an
/// Obsidian vault.
///
/// On a successful, writable selection the chosen path is persisted via
/// [VaultService] and [onVaultReady] is invoked so the host can proceed to the
/// home screen. When [missing] is true the screen shows a "your vault folder is
/// missing" message instead of the first-run copy (the saved folder no longer
/// exists).
class VaultSetupScreen extends StatefulWidget {
const VaultSetupScreen({
super.key,
required this.vaultService,
required this.onVaultReady,
this.missing = false,
});
final VaultService vaultService;
/// Called after a valid, writable vault root has been persisted.
final VoidCallback onVaultReady;
/// Whether a previously-chosen folder went missing (changes the copy).
final bool missing;
@override
State<VaultSetupScreen> createState() => _VaultSetupScreenState();
}
class _VaultSetupScreenState extends State<VaultSetupScreen> {
bool _busy = false;
String? _error;
Future<void> _pickFolder() async {
final l = AppLocalizations.of(context);
setState(() {
_busy = true;
_error = null;
});
try {
// getDirectoryPath is Desktop/Windows supported by file_picker.
// lockParentWindow makes the native Windows dialog modal.
final path = await FilePicker.platform.getDirectoryPath(
dialogTitle: l.vaultSetupTitle,
lockParentWindow: true,
);
if (path == null) {
// User cancelled the native dialog.
if (mounted) setState(() => _busy = false);
return;
}
if (!await _isWritable(path)) {
if (mounted) {
setState(() {
_busy = false;
_error = l.vaultNotWritable;
});
}
return;
}
await widget.vaultService.setVaultRoot(path);
if (mounted) widget.onVaultReady();
} catch (e) {
if (mounted) {
setState(() {
_busy = false;
_error = l.vaultPickFailed(e.toString());
});
}
}
}
/// Probe writability by creating and deleting a temp file in [path]. The
/// native folder dialog can hand back a read-only location on Windows, so we
/// validate before committing it as the vault.
Future<bool> _isWritable(String path) async {
final probe = File(p.join(path, '.badnote-write-probe'));
try {
await probe.writeAsString('ok', flush: true);
await probe.delete();
return true;
} catch (_) {
return false;
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
widget.missing
? Icons.folder_off_outlined
: Icons.folder_special_outlined,
size: 56,
color: colorScheme.primary,
),
const SizedBox(height: 24),
Text(
widget.missing ? l.vaultMissingTitle : l.vaultSetupHeadline,
style: textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text(
widget.missing ? l.vaultMissingBody : l.vaultSetupBody,
style: textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: textTheme.bodyMedium
?.copyWith(color: colorScheme.error),
),
],
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _busy ? null : _pickFolder,
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.folder_open),
label: Text(l.vaultChooseFolder),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,139 @@
// HTTP client for the optional self-hosted BadNote Server (/api/v1).
// WebDAV remains the primary NAS sync path; this client covers health, auth,
// vault manifest assist, and OCR job submit/poll.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class BadNoteServerConfig {
const BadNoteServerConfig({
this.baseUrl = '',
this.username = '',
this.password = '',
this.token = '',
this.userId = '',
});
final String baseUrl;
final String username;
final String password;
final String token;
final String userId;
bool get isConfigured => baseUrl.trim().isNotEmpty;
bool get isLoggedIn => token.isNotEmpty;
Uri? uri(String path) {
final base = baseUrl.trim().replaceAll(RegExp(r'/+$'), '');
if (base.isEmpty) return null;
final p = path.startsWith('/') ? path : '/$path';
return Uri.parse('$base$p');
}
BadNoteServerConfig copyWith({
String? baseUrl,
String? username,
String? password,
String? token,
String? userId,
}) =>
BadNoteServerConfig(
baseUrl: baseUrl ?? this.baseUrl,
username: username ?? this.username,
password: password ?? this.password,
token: token ?? this.token,
userId: userId ?? this.userId,
);
static const _kBase = 'badnote.server.baseUrl';
static const _kUser = 'badnote.server.username';
static const _kPass = 'badnote.server.password';
static const _kToken = 'badnote.server.token';
static const _kUid = 'badnote.server.userId';
static Future<BadNoteServerConfig> load(SharedPreferences prefs) async {
return BadNoteServerConfig(
baseUrl: prefs.getString(_kBase) ?? '',
username: prefs.getString(_kUser) ?? '',
password: prefs.getString(_kPass) ?? '',
token: prefs.getString(_kToken) ?? '',
userId: prefs.getString(_kUid) ?? '',
);
}
Future<void> save(SharedPreferences prefs) async {
await prefs.setString(_kBase, baseUrl);
await prefs.setString(_kUser, username);
await prefs.setString(_kPass, password);
await prefs.setString(_kToken, token);
await prefs.setString(_kUid, userId);
}
}
class BadNoteServerClient {
BadNoteServerClient(this.config, {http.Client? httpClient})
: _http = httpClient ?? http.Client();
BadNoteServerConfig config;
final http.Client _http;
Map<String, String> get _authHeaders => {
if (config.token.isNotEmpty) 'Authorization': 'Bearer ${config.token}',
};
Future<Map<String, dynamic>> health() async {
final uri = config.uri('/api/v1/health');
if (uri == null) throw StateError('server URL not set');
final res = await _http.get(uri).timeout(const Duration(seconds: 8));
if (res.statusCode != 200) {
throw StateError('health ${res.statusCode}: ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
Future<BadNoteServerConfig> registerOrLogin({
required String username,
required String password,
}) async {
final registerUri = config.uri('/api/v1/auth/register');
if (registerUri == null) throw StateError('server URL not set');
var res = await _http.post(
registerUri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
if (res.statusCode == 409) {
final loginUri = config.uri('/api/v1/auth/login')!;
res = await _http.post(
loginUri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'username': username, 'password': password}),
);
}
if (res.statusCode != 200 && res.statusCode != 201) {
throw StateError('auth ${res.statusCode}: ${res.body}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
config = config.copyWith(
username: username,
password: password,
token: body['token'] as String? ?? '',
userId: body['user_id'] as String? ?? '',
);
return config;
}
Future<Map<String, dynamic>> vaultManifest() async {
final uri = config.uri('/api/v1/vault/manifest');
if (uri == null) throw StateError('server URL not set');
final res = await _http.get(uri, headers: _authHeaders);
if (res.statusCode != 200) {
throw StateError('manifest ${res.statusCode}: ${res.body}');
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
void close() => _http.close();
}

View File

@@ -1,11 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' show Offset, Size;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:uuid/uuid.dart';
import '../editor/board/board.dart';
import '../editor/engine/stroke_model.dart';
import '../models/bookmark.dart';
import '../models/document.dart' as doc;
import '../models/ink_point.dart';
@@ -13,6 +18,8 @@ import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pointer_device_kind.dart';
import '../models/scratch_link.dart';
import 'vault_service.dart';
class DatabaseService {
static DatabaseService? _instance;
@@ -28,25 +35,83 @@ class DatabaseService {
return service;
}
/// Test-only: drop the cached singleton so the next [getInstance] re-opens a
/// fresh database (e.g. after pointing PathProviderPlatform at a new temp
/// dir). Closes the current handle if one is open.
@visibleForTesting
static Future<void> resetForTest() async {
final existing = _instance;
_instance = null;
if (existing != null) {
await existing._database.close();
}
}
Database get database => _database;
/// Re-resolve the DB location and reopen the singleton there. Called once the
/// vault root becomes valid at startup so the live database moves from the
/// legacy app-documents `badnote.db` to the vault cache
/// `<vault>/.badnote/index.sqlite` (§A.1). No-op-safe: if the resolved path is
/// unchanged it simply reopens the same file. Closes the previous handle.
static Future<DatabaseService> reopen() async {
final existing = _instance;
if (existing != null) {
await existing._database.close();
_instance = null;
}
return getInstance();
}
Future<void> _initialize() async {
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final dir = await getApplicationDocumentsDirectory();
final dbPath = p.join(dir.path, 'badnote.db');
final dbPath = await _resolveDbPath();
// Ensure the parent dir exists (the vault's hidden `.badnote/` cache dir is
// not guaranteed to exist yet on first run).
await Directory(p.dirname(dbPath)).create(recursive: true);
_database = await openDatabase(
dbPath,
version: 6,
version: 8,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
/// The application-documents path of the LEGACY (pre-vault) database. This is
/// the location [DatabaseService] used before the file-based re-architecture;
/// the one-time migrator reads from here, then renames it to `.premigration`.
static Future<String> legacyDbPath() async {
final dir = await getApplicationDocumentsDirectory();
return p.join(dir.path, 'badnote.db');
}
/// Resolve where the live database should live. When a valid vault root is
/// set, the DB is the vault's rebuildable cache/index at
/// `<vault>/.badnote/index.sqlite` (§A.1). Otherwise (no vault yet — e.g. a
/// fresh first run before the gate, or tests) fall back to the legacy
/// app-documents `badnote.db` so the app still works.
Future<String> _resolveDbPath() async {
String? root;
try {
final prefs = await SharedPreferences.getInstance();
root = prefs.getString(VaultService.vaultRootKey);
} catch (_) {
// SharedPreferences may be unavailable (e.g. a unit test that mocks only
// the path provider). Fall back to the legacy app-documents location so
// the DB still opens — it is never the source of truth anyway.
root = null;
}
if (root != null && root.isNotEmpty && await Directory(root).exists()) {
return p.join(root, '.badnote', 'index.sqlite');
}
return legacyDbPath();
}
Future<void> _onCreate(Database db, int version) async {
// Core tables (original v1)
await db.execute('''
@@ -184,6 +249,53 @@ class DatabaseService {
created_at INTEGER NOT NULL
)
''');
// Sticky-note board cards (v7): F7 双链 + 无限便利贴.
await _createBoardCardsTable(db);
// PDF-anchored scratch links (v8).
await _createScratchLinksTable(db);
}
/// PDF-anchored scratch links table (v8). One row per [ScratchLink] anchor.
/// The anchor [id] doubles as the storage key for its private scratchpad
/// (reused from the [scratchpads] table — see [saveScratchpad]).
Future<void> _createScratchLinksTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE scratch_links (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
page_index INTEGER NOT NULL,
nx REAL NOT NULL,
ny REAL NOT NULL,
created_at TEXT NOT NULL
)
''');
await db.execute(
'CREATE INDEX idx_scratch_links_doc ON scratch_links(document_id)',
);
}
/// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the
/// set of rows sharing a [board_id]. Geometry is stored as plain columns
/// (rows, not a blob) so a board round-trips and could be queried later.
Future<void> _createBoardCardsTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE board_cards (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
w REAL NOT NULL,
h REAL NOT NULL,
text TEXT NOT NULL DEFAULT '',
ordinal INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
)
''');
await db.execute(
'CREATE INDEX idx_board_cards_board ON board_cards(board_id)',
);
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -191,6 +303,20 @@ class DatabaseService {
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
if (oldVersion < 5) await _migrateV4toV5(db);
if (oldVersion < 6) await _migrateV5toV6(db);
if (oldVersion < 7) await _migrateV6toV7(db);
if (oldVersion < 8) await _migrateV7toV8(db);
}
Future<void> _migrateV7toV8(Database db) async {
await db.transaction((txn) async {
await _createScratchLinksTable(txn);
});
}
Future<void> _migrateV6toV7(Database db) async {
await db.transaction((txn) async {
await _createBoardCardsTable(txn);
});
}
Future<void> _migrateV5toV6(Database db) async {
@@ -897,4 +1023,285 @@ class DatabaseService {
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
}
// ── Board cards CRUD (F7 双链 + 无限便利贴) ──────────────────────────
/// Replace ALL cards for [boardId] with [cards]. Geometry is persisted as
/// rows (id, x, y, w, h, text) so the board survives restart. The whole
/// replace runs in one transaction so a crash mid-save cannot leave a
/// half-written board.
Future<void> saveBoardCards(
String boardId,
List<BoardCard> cards,
) async {
final now = DateTime.now().millisecondsSinceEpoch;
await _database.transaction((txn) async {
await txn.delete(
'board_cards',
where: 'board_id = ?',
whereArgs: [boardId],
);
for (var i = 0; i < cards.length; i++) {
final c = cards[i];
await txn.insert('board_cards', {
'id': c.id,
'board_id': boardId,
'x': c.position.dx,
'y': c.position.dy,
'w': c.size.width,
'h': c.size.height,
'text': c.text,
'ordinal': i,
'updated_at': now,
});
}
});
}
/// Load the [Board] for [boardId] (empty board when nothing is stored).
Future<Board> loadBoard(String boardId) async {
final rows = await _database.query(
'board_cards',
where: 'board_id = ?',
whereArgs: [boardId],
orderBy: 'ordinal ASC',
);
return Board([
for (final row in rows)
BoardCard(
id: row['id'] as String,
position: Offset(
(row['x'] as num).toDouble(),
(row['y'] as num).toDouble(),
),
size: Size(
(row['w'] as num).toDouble(),
(row['h'] as num).toDouble(),
),
text: row['text'] as String? ?? '',
),
]);
}
// ── Scratch links CRUD (PDF-anchored scratchpad tabs) ──────────────────
/// Insert or replace a [ScratchLink] anchor. The anchor's private scratchpad
/// lives in the [scratchpads] table keyed by [ScratchLink.id] — saved/loaded
/// via [saveScratchpad] / [loadScratchpad].
Future<void> saveScratchLink(ScratchLink link) async {
await _database.insert(
'scratch_links',
{
'id': link.id,
'document_id': link.documentId,
'page_index': link.pageIndex,
'nx': link.nx,
'ny': link.ny,
'created_at': DateTime.now().toIso8601String(),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Load all anchors for [documentId], oldest first.
Future<List<ScratchLink>> loadScratchLinks(String documentId) async {
final rows = await _database.query(
'scratch_links',
where: 'document_id = ?',
whereArgs: [documentId],
orderBy: 'created_at ASC',
);
return rows
.map(
(row) => ScratchLink(
id: row['id'] as String,
documentId: row['document_id'] as String,
pageIndex: row['page_index'] as int,
nx: (row['nx'] as num).toDouble(),
ny: (row['ny'] as num).toDouble(),
),
)
.toList();
}
/// Delete an anchor and its private scratchpad (the scratchpad row keyed by
/// the anchor id), so a deleted anchor leaves no orphaned ink behind.
Future<void> deleteScratchLink(String id) async {
await _database.transaction((txn) async {
await txn.delete('scratch_links', where: 'id = ?', whereArgs: [id]);
await txn.delete('scratchpads', where: 'document_id = ?', whereArgs: [id]);
});
}
// ── RAW legacy reads (one-time SQLite→sidecar migration, Phase 5) ───────────
//
// These operate on an arbitrary [Database] handle (the LEGACY db the migrator
// opens directly), NOT the live [_database] cache, so the migrator can read
// pre-migration data without touching the relocated index. They reuse this
// class's row-parsers so the JSON shapes stay identical to the live reads.
/// All `documents` rows from [db], oldest first (stable migration order).
static Future<List<doc.Document>> rawAllDocuments(Database db) async {
if (!await _tableExists(db, 'documents')) return const [];
final rows = await db.query('documents', orderBy: 'created_at ASC');
final dummy = DatabaseService._();
return rows.map(dummy._documentFromRow).toList();
}
/// All `notes` rows (with their `strokes`) from [db], oldest first.
static Future<List<Note>> rawAllNotes(Database db) async {
if (!await _tableExists(db, 'notes')) return const [];
final rows = await db.query('notes', orderBy: 'created_at ASC');
final dummy = DatabaseService._();
final notes = <Note>[];
for (final row in rows) {
final strokeRows = await db.query(
'strokes',
where: 'note_id = ?',
whereArgs: [row['id'] as String],
orderBy: 'created_at ASC',
);
final strokes = strokeRows.map(dummy._strokeFromRow).toList();
final tagsJson = jsonDecode(row['tags'] as String) as List;
notes.add(Note(
id: row['id'] as String,
title: row['title'] as String,
strokes: strokes,
createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String),
tags: tagsJson.cast<String>(),
));
}
return notes;
}
/// Committed editor strokes for [documentId] from [db], grouped by 0-based
/// page index. Parses the `ink.host_id = "doc:<documentId>:page:<i>"` scheme
/// (see [EditorRepository.loadDocument]) and decodes each `stroke_json`
/// straight into an [EditorStroke]. Returns `{}` when there is no `ink` table
/// or no rows.
static Future<Map<int, List<EditorStroke>>> rawStrokesByPage(
Database db,
String documentId,
) async {
if (!await _tableExists(db, 'ink')) return <int, List<EditorStroke>>{};
final rows = await db.query(
'ink',
where: 'host_kind = ? AND host_id LIKE ?',
whereArgs: ['page', 'doc:$documentId:page:%'],
orderBy: 'host_id ASC, ordinal ASC',
);
final out = <int, List<EditorStroke>>{};
for (final row in rows) {
final hostId = row['host_id'] as String;
final pageIndex = _pageIndexFromHostId(hostId);
if (pageIndex == null) continue;
final json =
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
out.putIfAbsent(pageIndex, () => []).add(EditorStroke.fromJson(json));
}
return out;
}
/// Bookmarks for [documentId] from [db] (empty when no `bookmarks` table).
static Future<List<Bookmark>> rawBookmarks(
Database db,
String documentId,
) async {
if (!await _tableExists(db, 'bookmarks')) return const [];
final rows = await db.query(
'bookmarks',
where: 'document_id = ?',
whereArgs: [documentId],
orderBy: 'page_number ASC',
);
final dummy = DatabaseService._();
return rows.map(dummy._bookmarkFromRow).toList();
}
/// Scratch-link anchors for [documentId] from [db] (empty when no table).
static Future<List<ScratchLink>> rawScratchLinks(
Database db,
String documentId,
) async {
if (!await _tableExists(db, 'scratch_links')) return const [];
final rows = await db.query(
'scratch_links',
where: 'document_id = ?',
whereArgs: [documentId],
orderBy: 'created_at ASC',
);
return rows
.map(
(row) => ScratchLink(
id: row['id'] as String,
documentId: row['document_id'] as String,
pageIndex: row['page_index'] as int,
nx: (row['nx'] as num).toDouble(),
ny: (row['ny'] as num).toDouble(),
),
)
.toList();
}
/// Scratchpad strokes stored under [key] (an anchor id) from [db]. Empty when
/// there is no `scratchpads` table or no row.
static Future<List<InkStroke>> rawScratchpad(
Database db,
String key,
) async {
if (!await _tableExists(db, 'scratchpads')) return const [];
final rows = await db.query(
'scratchpads',
where: 'document_id = ?',
whereArgs: [key],
);
if (rows.isEmpty) return const [];
final json = rows.first['strokes_json'] as String;
if (json.isEmpty || json == '[]') return const [];
final list = jsonDecode(json) as List<dynamic>;
return list
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
}
/// Raw legacy per-page `annotation_json` blobs for [documentId] from [db],
/// keyed by page number. These belong to the DEAD pre-editor annotation path
/// (§1 `annotations` table); the migrator copies them verbatim into the
/// sidecar's `legacyAnnotations` so nothing is silently dropped.
static Future<Map<int, String>> rawLegacyAnnotations(
Database db,
String documentId,
) async {
if (!await _tableExists(db, 'annotations')) return <int, String>{};
final rows = await db.query(
'annotations',
where: 'document_id = ?',
whereArgs: [documentId],
orderBy: 'page_number ASC',
);
final out = <int, String>{};
for (final row in rows) {
out[row['page_number'] as int] = row['annotation_json'] as String;
}
return out;
}
/// Parse the 0-based page index out of an `ink.host_id` of the form
/// `doc:<documentId>:page:<pageIndex>`. Returns null on an unexpected shape.
static int? _pageIndexFromHostId(String hostId) {
final i = hostId.lastIndexOf(':page:');
if (i == -1) return null;
return int.tryParse(hostId.substring(i + ':page:'.length));
}
/// True iff [name] is an existing table in [db]. Lets the raw readers tolerate
/// a legacy DB that predates a given table (older schema versions).
static Future<bool> _tableExists(Database db, String name) async {
final rows = await db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
[name],
);
return rows.isNotEmpty;
}
}

View File

@@ -1,3 +1,7 @@
import 'dart:io';
import '../diagnostics/badnote_log.dart';
import '../editor/persistence/sidecar_repository.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import 'database_service.dart';
@@ -6,8 +10,17 @@ import 'stroke_rasterizer.dart';
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
class OcrService {
/// Extract searchable text from [note] and merge into the local FTS index.
/// Extract searchable text from [note] and persist it for search. The text is
/// written to the note's `*.badnote.json` sidecar `ocrText` field — the
/// vault-scan source of truth the Phase 6 search index reads — and also
/// appended to the (rebuildable) SQLite FTS cache so legacy callers keep
/// working. [note.id] is the synthetic note path, which is exactly the
/// `sourceFilePath` the editor opened its [SidecarRepository] with.
Future<void> processNote(Note note) async {
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_start', fields: {
'note': note.id,
'strokes': note.strokes.length,
});
final parts = <String>[];
for (final stroke in note.strokes) {
@@ -33,13 +46,35 @@ class OcrService {
final recognized = await OcrEngine.recognizeImage(png);
if (recognized != null && recognized.isNotEmpty) {
parts.add(recognized);
BadNoteLog.instance.info(LogSubsystem.diag, 'ocr_handwriting', fields: {
'chars': recognized.length,
});
}
}
}
final combined = parts.join(' ').trim();
if (combined.isEmpty) return;
if (combined.isEmpty) {
BadNoteLog.instance.debug(LogSubsystem.diag, 'ocr_empty');
return;
}
// Persist into the note's sidecar so the vault-scan search index finds it.
// Prefer the editor's already-open repo (same in-memory sidecar — no race);
// if the note is closed, open/flush/dispose a transient handle.
final open = SidecarRepositoryRegistry.forPath(note.id);
if (open != null) {
open.scheduleOcrTextSave(combined);
await open.flush();
} else if (await File('${note.id}$kSidecarSuffix').exists()) {
final repo = await SidecarRepository.open(note.id, docType: 'notebook');
repo.scheduleOcrTextSave(combined);
await repo.flush();
repo.dispose();
}
// Also keep the legacy SQLite FTS cache warm (rebuildable; not the source
// of truth). Harmless if the row is never read.
final db = await DatabaseService.getInstance();
await db.appendOcrToFts(note.id, combined);
}

View File

@@ -0,0 +1,120 @@
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:path/path.dart' as p;
import 'package:xml/xml.dart';
import '../../diagnostics/badnote_log.dart';
import 'office_document.dart';
/// Native DOCX parser — block-level structure for BadNote annotation pages.
class DocxParser {
Future<ParsedDocx> parse(String docxPath, {Directory? cacheDir}) async {
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_start', fields: {
'path': docxPath,
});
final bytes = await File(docxPath).readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
Directory out = cacheDir ??
Directory(p.join(Directory.systemTemp.path, 'badnote_docx_${DateTime.now().millisecondsSinceEpoch}'));
if (!await out.exists()) await out.create(recursive: true);
final documentXml = _decode(_find(archive, 'word/document.xml'));
if (documentXml == null) {
return ParsedDocx(sourcePath: docxPath, blocks: const []);
}
// Media map from relationships.
final media = <String, String>{};
final rels = _decode(_find(archive, 'word/_rels/document.xml.rels'));
if (rels != null) {
try {
final relDoc = XmlDocument.parse(rels);
for (final rel in relDoc.findAllElements('Relationship')) {
final id = rel.getAttribute('Id');
final type = rel.getAttribute('Type') ?? '';
final target = rel.getAttribute('Target') ?? '';
if (id == null || !type.contains('image') || target.isEmpty) continue;
final mediaPath = p.normalize(p.join('word', target));
final file = _find(archive, mediaPath);
if (file?.content is! List<int>) continue;
final outPath = p.join(out.path, p.basename(mediaPath));
await File(outPath).writeAsBytes(file!.content as List<int>);
media[id] = outPath;
}
} catch (_) {}
}
final blocks = <DocBlock>[];
try {
final doc = XmlDocument.parse(documentXml);
for (final pEl in doc.findAllElements('w:p')) {
final style = pEl
.findElements('w:pPr')
.expand((e) => e.findElements('w:pStyle'))
.map((e) => e.getAttribute('w:val') ?? '')
.firstWhere((s) => s.isNotEmpty, orElse: () => '');
final texts = pEl.findAllElements('w:t').map((t) => t.innerText).join();
final blips = pEl.findAllElements('a:blip');
for (final blip in blips) {
final embed = blip.getAttribute('r:embed') ?? blip.getAttribute('embed');
if (embed != null && media[embed] != null) {
blocks.add(DocBlock(
type: DocBlockType.image,
text: '',
imagePath: media[embed],
));
}
}
if (texts.trim().isEmpty && blips.isEmpty) continue;
if (texts.trim().isEmpty) continue;
final isHeading = style.toLowerCase().startsWith('heading') ||
RegExp(r'^Heading\s*\d', caseSensitive: false).hasMatch(style);
final level = int.tryParse(RegExp(r'(\d+)').firstMatch(style)?.group(1) ?? '') ??
(isHeading ? 1 : 0);
blocks.add(DocBlock(
type: isHeading ? DocBlockType.heading : DocBlockType.paragraph,
text: texts,
level: level,
));
}
// Tables
for (final row in doc.findAllElements('w:tr')) {
final cells = row
.findElements('w:tc')
.map((tc) => tc.findAllElements('w:t').map((t) => t.innerText).join())
.where((s) => s.trim().isNotEmpty)
.join(' | ');
if (cells.isEmpty) continue;
blocks.add(DocBlock(type: DocBlockType.tableRow, text: cells));
}
} catch (e) {
BadNoteLog.instance.warn(LogSubsystem.office, 'docx_parse_error', fields: {
'error': '$e',
});
}
BadNoteLog.instance.info(LogSubsystem.office, 'docx_parse_done', fields: {
'blocks': blocks.length,
});
return ParsedDocx(sourcePath: docxPath, blocks: blocks);
}
Future<String> extractText(String docxPath) async {
final parsed = await parse(docxPath);
return parsed.plainText;
}
static ArchiveFile? _find(Archive archive, String name) {
final n = name.replaceAll('\\', '/');
for (final f in archive.files) {
if (f.name.replaceAll('\\', '/') == n) return f;
}
return null;
}
static String? _decode(ArchiveFile? file) {
if (file == null) return null;
return String.fromCharCodes(file.content);
}
}

View File

@@ -0,0 +1,98 @@
/// Shared OOXML document models for native Word/PPT parsing.
library;
class OfficeTextRun {
const OfficeTextRun({
required this.text,
this.x = 0,
this.y = 0,
this.width = 0,
this.height = 0,
this.fontSize = 18,
});
final String text;
final double x;
final double y;
final double width;
final double height;
final double fontSize;
}
class OfficeImage {
const OfficeImage({
required this.bytesPath,
this.x = 0,
this.y = 0,
this.width = 0,
this.height = 0,
});
final String bytesPath;
final double x;
final double y;
final double width;
final double height;
}
class OfficeSlide {
const OfficeSlide({
required this.index,
required this.width,
required this.height,
this.runs = const [],
this.images = const [],
this.plainText = '',
});
final int index;
final double width;
final double height;
final List<OfficeTextRun> runs;
final List<OfficeImage> images;
final String plainText;
}
class ParsedPptx {
const ParsedPptx({
required this.sourcePath,
required this.slides,
});
final String sourcePath;
final List<OfficeSlide> slides;
String get allText =>
slides.map((s) => '--- Slide ${s.index + 1} ---\n${s.plainText}').join('\n\n');
/// Alias used by [PptxService.extractText].
String get plainText => allText;
}
enum DocBlockType { heading, paragraph, tableRow, image }
class DocBlock {
const DocBlock({
required this.type,
required this.text,
this.level = 0,
this.imagePath,
});
final DocBlockType type;
final String text;
final int level;
final String? imagePath;
}
class ParsedDocx {
const ParsedDocx({
required this.sourcePath,
required this.blocks,
});
final String sourcePath;
final List<DocBlock> blocks;
String get plainText => blocks.map((b) => b.text).where((t) => t.isNotEmpty).join('\n');
}

View File

@@ -0,0 +1,164 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:archive/archive.dart';
import 'package:path/path.dart' as p;
import 'package:xml/xml.dart';
import '../../diagnostics/badnote_log.dart';
import 'office_document.dart';
/// Native PPTX parser — no LibreOffice. Reads OOXML zip + slide XML.
class PptxParser {
/// EMUs per English inch (Office drawing unit).
static const double _emuPerInch = 914400;
static const double _defaultDpi = 96;
Future<ParsedPptx> parse(String pptxPath, {Directory? cacheDir, String? cacheDirPath}) async {
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_start', fields: {
'path': pptxPath,
});
final bytes = await File(pptxPath).readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
Directory out = cacheDir ??
(cacheDirPath != null
? Directory(cacheDirPath)
: Directory(p.join(Directory.systemTemp.path, 'badnote_pptx_${DateTime.now().millisecondsSinceEpoch}')));
if (!await out.exists()) await out.create(recursive: true);
// Default slide size (widescreen 13.333" x 7.5") in pixels at 96dpi.
double slideW = 13.333 * _defaultDpi;
double slideH = 7.5 * _defaultDpi;
final sldSz = _file(archive, 'ppt/presentation.xml');
if (sldSz != null) {
try {
final doc = XmlDocument.parse(sldSz);
final candidates = [
...doc.findAllElements('sldSz', namespace: '*'),
...doc.findAllElements('p:sldSz'),
];
final el = candidates.isEmpty ? null : candidates.first;
if (el != null) {
final cx = int.tryParse(el.getAttribute('cx') ?? '') ?? 0;
final cy = int.tryParse(el.getAttribute('cy') ?? '') ?? 0;
if (cx > 0 && cy > 0) {
slideW = cx / _emuPerInch * _defaultDpi;
slideH = cy / _emuPerInch * _defaultDpi;
}
}
} catch (_) {}
}
final slideFiles = archive.files
.where((f) =>
f.name.startsWith('ppt/slides/slide') &&
f.name.endsWith('.xml') &&
!f.name.contains('_rels'))
.toList()
..sort((a, b) => _slideNum(a.name).compareTo(_slideNum(b.name)));
final slides = <OfficeSlide>[];
for (var i = 0; i < slideFiles.length; i++) {
final file = slideFiles[i];
final xml = _decode(file);
if (xml == null) continue;
final runs = <OfficeTextRun>[];
final images = <OfficeImage>[];
final textBuf = StringBuffer();
try {
final doc = XmlDocument.parse(xml);
for (final t in doc.findAllElements('a:t')) {
final text = t.innerText;
if (text.isEmpty) continue;
textBuf.writeln(text);
// Approximate: stack text vertically when no transform is parsed.
runs.add(OfficeTextRun(
text: text,
x: 48,
y: 48.0 + runs.length * 28,
width: math.max(120, slideW - 96),
height: 28,
));
}
// Extract images referenced by this slide's relationships.
final relsName =
'ppt/slides/_rels/slide${_slideNum(file.name)}.xml.rels';
final relsXml = _file(archive, relsName);
if (relsXml != null) {
final relsDoc = XmlDocument.parse(relsXml);
for (final rel in relsDoc.findAllElements('Relationship')) {
final type = rel.getAttribute('Type') ?? '';
if (!type.contains('image')) continue;
var target = rel.getAttribute('Target') ?? '';
if (target.isEmpty) continue;
// Targets are relative to ppt/slides/ → often ../media/image1.png
final mediaPath = p.normalize(p.join('ppt/slides', target));
final media = _archiveFile(archive, mediaPath) ??
_archiveFile(archive, target.replaceFirst('../', 'ppt/'));
if (media == null) continue;
final content = media.content;
final outPath = p.join(out.path, p.basename(mediaPath));
await File(outPath).writeAsBytes(content);
images.add(OfficeImage(
bytesPath: outPath,
x: 80,
y: slideH * 0.35,
width: slideW * 0.4,
height: slideH * 0.4,
));
}
}
} catch (e) {
BadNoteLog.instance.warn(LogSubsystem.office, 'slide_parse_error', fields: {
'slide': file.name,
'error': '$e',
});
}
slides.add(OfficeSlide(
index: i,
width: slideW,
height: slideH,
runs: runs,
images: images,
plainText: textBuf.toString().trim(),
));
}
BadNoteLog.instance.info(LogSubsystem.office, 'pptx_parse_done', fields: {
'slides': slides.length,
});
return ParsedPptx(sourcePath: pptxPath, slides: slides);
}
Future<String> extractText(String pptxPath) async {
final parsed = await parse(pptxPath);
return parsed.allText;
}
static int _slideNum(String name) {
final m = RegExp(r'slide(\d+)\.xml').firstMatch(name);
return int.tryParse(m?.group(1) ?? '') ?? 0;
}
static String? _file(Archive archive, String name) {
final f = _archiveFile(archive, name);
return _decode(f);
}
static ArchiveFile? _archiveFile(Archive archive, String name) {
final normalized = name.replaceAll('\\', '/');
for (final f in archive.files) {
if (f.name.replaceAll('\\', '/') == normalized) return f;
}
return null;
}
static String? _decode(ArchiveFile? file) {
if (file == null) return null;
return String.fromCharCodes(file.content);
}
}

View File

@@ -308,6 +308,12 @@ class PdfService {
// ONE shared recipe with the on-screen painter (R7): export can no longer
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
//
// TODO(brush-persist) / TODO(brush-export): InkStroke does not persist the
// brush, so export can only use the legacy (no-brush) recipe — it does NOT
// yet pass `brush:` here. Once the brush is persisted on InkStroke, resolve
// brushProfileFor(stroke.brush) and pass it so export matches the brush-aware
// screen geometry (taper/caps/per-brush thinning) for non-fountain brushes.
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: pixelWidth,

View File

@@ -0,0 +1,198 @@
// lib/services/pdf_text_indexer.dart
//
// Import-time document-body text indexing for file-backed PDF notebooks, so the
// vault-scan search index (VaultSearchIndex) covers the underlying document —
// not just the user's annotations. Three text sources now feed search:
// (a) handwriting → OCR'd into the sidecar's `ocrText` (OcrService),
// (b) a PDF text layer → its embedded printed text, captured here,
// (c) a RASTERIZED PDF → background OCR of the rendered pages, captured here.
//
// Flow (kicked off after VaultService.createNotebook, fire-and-forget):
// 1. IDEMPOTENCY: if the sidecar already carries `pageText`, do nothing.
// 2. EXTRACT the embedded text layer per page (pdfrx `loadText`).
// 3. DECIDE text-layer vs rasterized: sum the embedded text length across all
// pages; if it clears [textLayerThreshold] the PDF has a usable text layer
// and we persist that. Otherwise the PDF is rasterized (scanned image, no
// text) and we OCR each rendered page.
// 4. PERSIST the per-page text (joined by form-feed) into the sidecar's
// `pageText` field — writing THROUGH an open SidecarRepository when the
// editor already has the doc open (no race), else a transient handle.
//
// GRACEFUL DEGRADATION / HONESTY:
// * The OCR engine is whatever OcrService/OcrEngine resolves to: the bundled
// ONNX PP-OCR recognizer if present, else the native Windows OCR
// MethodChannel, else NONE. On a platform/CI without any backend, OCR
// returns null and a rasterized PDF simply gets no `pageText` — no crash,
// and the embedded-text path still works.
// * pdfrx render + native OCR can only be exercised on-device. The pdfrx-
// backed loaders are injected through [PdfTextIndexer] so unit tests fake
// them; the production wiring lives in [PdfrxPageTextSource].
import 'dart:async';
import 'dart:io';
import '../editor/persistence/sidecar_repository.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/sidecar_store.dart';
/// Extracts a PDF's embedded text layer, one entry per page (page order). An
/// empty list (or all-empty entries) means "no usable text layer".
typedef PdfEmbeddedTextLoader = Future<List<String>> Function(String pdfPath);
/// OCRs the rendered pages of a rasterized PDF, returning one entry per page
/// (page order). Entries may be empty where a page yielded nothing. Returns an
/// empty list when no OCR backend is available (a clean no-op).
typedef PdfPageOcrRunner = Future<List<String>> Function(String pdfPath);
/// Indexes a PDF's document body into its sidecar's `pageText` at import time.
///
/// Stateless apart from the two injected text sources; safe to construct per
/// import. All disk/native work is awaited internally — call [indexPdf] without
/// awaiting (fire-and-forget) from the import handler to keep import snappy.
class PdfTextIndexer {
PdfTextIndexer({
required PdfEmbeddedTextLoader loadEmbeddedText,
required PdfPageOcrRunner ocrPages,
this.textLayerThreshold = 16,
}) : _loadEmbeddedText = loadEmbeddedText,
_ocrPages = ocrPages;
final PdfEmbeddedTextLoader _loadEmbeddedText;
final PdfPageOcrRunner _ocrPages;
/// Minimum total embedded-text length (across all pages, after trimming) for a
/// PDF to count as having a usable text layer. Below this it is treated as
/// rasterized and routed to OCR. Small on purpose: a scanned PDF typically
/// yields zero or a few stray ligature chars, while any real text page clears
/// it easily.
final int textLayerThreshold;
/// The page separator stored inside `pageText` (form feed). The search index
/// treats `pageText` as a flat blob, so this is purely cosmetic / future-proof.
static const String pageSeparator = '\f';
/// Index the PDF at [pdfPath] (an in-vault copy) into its sidecar's `pageText`.
///
/// Idempotent: returns immediately if the sidecar already has non-empty
/// `pageText`. Never throws — any failure (unreadable PDF, missing OCR backend)
/// degrades to leaving `pageText` unset. Returns the text it persisted (for
/// tests), or null when nothing was indexed.
Future<String?> indexPdf(String pdfPath) async {
try {
// 1. Idempotency: skip a doc whose body has already been indexed.
final existing = await _currentPageText(pdfPath);
if (existing != null && existing.trim().isNotEmpty) return null;
// 2. Embedded text layer.
final embedded = await _loadEmbeddedText(pdfPath);
final embeddedLen = embedded.fold<int>(
0,
(sum, page) => sum + page.trim().length,
);
// 3. Text-layer vs rasterized decision.
List<String> pages;
if (embeddedLen >= textLayerThreshold) {
pages = embedded;
} else {
// Rasterized (scanned, no text layer) → background OCR.
pages = await _ocrPages(pdfPath);
}
final joined = _joinPages(pages);
if (joined.isEmpty) return null;
// 4. Persist into the sidecar (through an open repo if the editor holds it).
await _persistPageText(pdfPath, joined);
return joined;
} catch (_) {
// Background indexing must never surface an error to the import flow.
return null;
}
}
/// Whether the embedded text in [embedded] clears [textLayerThreshold], i.e.
/// the PDF has a usable text layer (false → rasterized, needs OCR). Exposed for
/// unit-testing the decision in isolation.
bool hasUsableTextLayer(List<String> embedded) {
final len = embedded.fold<int>(0, (sum, p) => sum + p.trim().length);
return len >= textLayerThreshold;
}
static String _joinPages(List<String> pages) {
final nonEmpty = pages.map((p) => p.trim()).where((p) => p.isNotEmpty);
return nonEmpty.join(pageSeparator).trim();
}
/// Read the sidecar's current `pageText` (for the idempotency check), from the
/// open repo if present else from disk. Null when no sidecar exists yet.
Future<String?> _currentPageText(String pdfPath) async {
final open = SidecarRepositoryRegistry.forPath(pdfPath);
if (open != null) return open.loadedPageText;
final sidecar = await SidecarStore.read(
File('$pdfPath$kSidecarSuffix'),
);
return sidecar?.pageText;
}
/// Write [pageText] into the sidecar. Prefer the editor's already-open repo
/// (same in-memory sidecar — no race); otherwise merge into the on-disk
/// sidecar (creating one if the editor hasn't yet).
Future<void> _persistPageText(String pdfPath, String pageText) async {
final open = SidecarRepositoryRegistry.forPath(pdfPath);
if (open != null) {
open.schedulePageTextSave(pageText);
await open.flush();
return;
}
final file = File('$pdfPath$kSidecarSuffix');
final current = await SidecarStore.read(file);
final merged = _withPageText(current, pdfPath, pageText);
await SidecarStore.writeAtomic(file, merged);
}
/// Build a sidecar carrying [pageText], preserving every other field of
/// [current] (or a fresh minimal sidecar when none exists yet).
static BadnoteSidecar _withPageText(
BadnoteSidecar? current,
String pdfPath,
String pageText,
) {
if (current == null) {
return BadnoteSidecar(
sourceFile: _basename(pdfPath),
docType: 'pdf',
createdAt: DateTime.now().toUtc(),
updatedAt: DateTime.now().toUtc(),
pageText: pageText,
);
}
return BadnoteSidecar(
version: current.version,
sourceFile: current.sourceFile,
docType: current.docType,
title: current.title,
pageCount: current.pageCount,
rotation: current.rotation,
createdAt: current.createdAt,
updatedAt: DateTime.now().toUtc(),
strokes: current.strokes,
highlights: current.highlights,
texts: current.texts,
bookmarks: current.bookmarks,
scratchLinks: current.scratchLinks,
legacyAnnotations: current.legacyAnnotations,
ocrText: current.ocrText,
pageText: pageText,
legacyId: current.legacyId,
background: current.background,
);
}
static String _basename(String path) {
final norm = path.replaceAll('\\', '/');
final i = norm.lastIndexOf('/');
return i == -1 ? norm : norm.substring(i + 1);
}
}

View File

@@ -0,0 +1,111 @@
// lib/services/pdfrx_page_text_source.dart
//
// Production wiring for [PdfTextIndexer]'s two injected text sources, backed by
// pdfrx (the same engine the editor renders with). Kept SEPARATE from
// PdfTextIndexer so the indexer's logic (the threshold decision, idempotency,
// sidecar persistence) is unit-testable without the native pdfium/OCR stack —
// only this file touches pdfrx, dart:ui, and the OCR engine, and it is exercised
// on-device, not in CI.
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:pdfrx/pdfrx.dart';
import 'ocr_engine.dart';
/// pdfrx-backed loaders for [PdfTextIndexer].
class PdfrxPageTextSource {
const PdfrxPageTextSource._();
/// Load the embedded text layer of every page (page order). Each entry is a
/// page's raw text (possibly empty). Returns an empty list on any failure, so
/// the indexer treats the PDF as having no text layer (→ OCR fallback).
static Future<List<String>> loadEmbeddedText(String pdfPath) async {
PdfDocument? doc;
try {
doc = await PdfDocument.openFile(pdfPath);
final out = <String>[];
for (final page in doc.pages) {
final raw = await page.loadText();
out.add(raw?.fullText ?? '');
}
return out;
} catch (_) {
return const [];
} finally {
await doc?.dispose();
}
}
/// Render each page and OCR it (page order). Returns one entry per page
/// (empty where nothing was recognized), or an empty list when the PDF can't
/// be opened. Honours the OCR engine's own graceful no-op: when no backend is
/// available every page comes back empty.
///
/// Rendering is done at [renderScale]× the page's native 72-dpi size to give
/// the recognizer enough resolution on scanned scans without exploding memory.
static Future<List<String>> ocrPages(
String pdfPath, {
double renderScale = 2.0,
}) async {
PdfDocument? doc;
try {
doc = await PdfDocument.openFile(pdfPath);
final out = <String>[];
for (final page in doc.pages) {
final text = await _ocrOnePage(page, renderScale);
out.add(text ?? '');
}
return out;
} catch (_) {
return const [];
} finally {
await doc?.dispose();
}
}
static Future<String?> _ocrOnePage(PdfPage page, double renderScale) async {
PdfImage? image;
try {
final fullWidth = page.width * renderScale;
final fullHeight = page.height * renderScale;
image = await page.render(
fullWidth: fullWidth,
fullHeight: fullHeight,
);
if (image == null) return null;
final png = await _bgraToPng(image.pixels, image.width, image.height);
if (png == null) return null;
return OcrEngine.recognizeImage(png);
} catch (_) {
return null;
} finally {
image?.dispose();
}
}
/// Encode pdfrx's BGRA8888 raw pixels as PNG (the format [OcrEngine] expects).
static Future<Uint8List?> _bgraToPng(
Uint8List bgra,
int width,
int height,
) async {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
bgra,
width,
height,
ui.PixelFormat.bgra8888,
completer.complete,
);
final image = await completer.future;
try {
final data = await image.toByteData(format: ui.ImageByteFormat.png);
return data?.buffer.asUint8List();
} finally {
image.dispose();
}
}
}

View File

@@ -5,24 +5,83 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Service for processing PPTX files: text extraction, image conversion, file picking.
import 'office/office_document.dart';
import 'office/pptx_parser.dart';
/// Service for processing PPTX files: text extraction, structured slide parse,
/// optional LibreOffice image conversion, and file picking.
///
/// PPTX files are ZIP archives containing XML. We extract text from
/// `ppt/slides/slide*.xml` `<a:t>` elements and convert slides to images
/// using LibreOffice (headless) or generate placeholder images as fallback.
/// **Native OOXML parsing is primary** ([PptxParser] via `package:archive` +
/// `package:xml`). LibreOffice (`soffice`) is an optional fallback ONLY when
/// the native path fails AND the binary is present on the machine.
class PptxService {
static const _uuid = Uuid();
final PptxParser _parser;
PptxService({PptxParser? parser}) : _parser = parser ?? PptxParser();
/// Extract all text content from a PPTX file.
///
/// PPTX is a ZIP archive. Slide text lives in `ppt/slides/slide*.xml`
/// inside `<a:t>` (ASCII text) elements within `<a:r>` (run) or
/// `<a:p>` (paragraph) nodes.
/// Prefers the native [PptxParser]. Falls back to a legacy unzip+regex path
/// only if native parsing throws.
Future<String> extractText(String pptxPath) async {
try {
final parsed = await _parser.parse(pptxPath);
return parsed.plainText;
} catch (_) {
return _extractTextLegacy(pptxPath);
}
}
/// Parse PPTX into structured slides (text runs with approximate positions,
/// embedded images extracted to a cache dir). Native-only — no LibreOffice.
Future<ParsedPptx> parseSlides(String pptxPath, {String? cacheDir}) {
return _parser.parse(pptxPath, cacheDirPath: cacheDir);
}
/// Convert PPTX slides to a list of image file paths (legacy PenSlideScreen).
///
/// Prefer [parseSlides] for native text+image rendering. This method only
/// invokes LibreOffice when native parse fails AND `soffice` exists;
/// otherwise it emits placeholder PNGs from the native slide count.
Future<List<String>> convertToImages(String pptxPath) async {
try {
final parsed = await _parser.parse(pptxPath);
// Native succeeded — do NOT call LibreOffice; placeholders for callers
// that still expect image paths. OfficeDocumentScreen uses [parseSlides].
if (parsed.slides.isEmpty) return [];
return _generatePlaceholderImages(pptxPath);
} catch (_) {
// Native failed — LibreOffice fallback ONLY if soffice exists.
final soffice = await resolveSoffice();
if (soffice != null) {
final loImages = await _convertViaLibreOffice(pptxPath);
if (loImages.isNotEmpty) return loImages;
}
return _generatePlaceholderImages(pptxPath);
}
}
/// Open a file picker dialog and return the selected PPTX path, or null.
Future<String?> openPptxFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pptx', 'ppt'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first.path;
}
// ---------------------------------------------------------------------------
// Legacy / LibreOffice helpers
// ---------------------------------------------------------------------------
Future<String> _extractTextLegacy(String pptxPath) async {
final tmpDir = await _makeTmpDir('pptx_text');
try {
// Unzip the PPTX
final unzipResult = await Process.run('unzip', [
'-o',
'-q',
@@ -35,7 +94,6 @@ class PptxService {
return '';
}
// Find all slide XML files
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
if (!await slidesDir.exists()) return '';
@@ -44,7 +102,6 @@ class PptxService {
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
.toList();
// Sort by slide number
slideFiles.sort((a, b) {
final aNum = _extractSlideNumber(a.path);
final bNum = _extractSlideNumber(b.path);
@@ -67,47 +124,14 @@ class PptxService {
} catch (_) {
return '';
} finally {
// Cleanup
try {
await tmpDir.delete(recursive: true);
} catch (_) {}
}
}
/// Convert PPTX slides to a list of image file paths.
///
/// Attempts LibreOffice headless conversion first. Falls back to
/// generating placeholder slide images (colored rectangles with slide numbers).
Future<List<String>> convertToImages(String pptxPath) async {
// Try LibreOffice first
final loImages = await _convertViaLibreOffice(pptxPath);
if (loImages.isNotEmpty) return loImages;
// Fallback: generate placeholder images
return _generatePlaceholderImages(pptxPath);
}
/// Open a file picker dialog and return the selected PPTX path, or null.
///
/// Uses the cross-platform file_picker package.
Future<String?> openPptxFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pptx', 'ppt'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first.path;
}
// ---------------------------------------------------------------------------
// Implementation helpers
// ---------------------------------------------------------------------------
/// Extract text from PPTX slide XML by finding `<a:t>` content.
String _extractTextFromXml(String xml) {
final lines = <String>[];
// Match <a:t>...</a:t> — handles both <a:t>text</a:t> and <a:t xml:space="preserve">text</a:t>
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
for (final match in regex.allMatches(xml)) {
final text = match.group(1) ?? '';
@@ -124,16 +148,16 @@ class PptxService {
return 0;
}
/// Try converting via LibreOffice headless.
/// LibreOffice fallback — only when native fails or caller wants PNGs and
/// soffice is installed.
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
try {
// Check if LibreOffice is available
final which = await Process.run('which', ['libreoffice']);
if (which.exitCode != 0) return [];
final soffice = await resolveSoffice();
if (soffice == null) return [];
final outDir = await _makeTmpDir('pptx_images');
final result = await Process.run('libreoffice', [
final result = await Process.run(soffice, [
'--headless',
'--convert-to',
'png',
@@ -144,7 +168,6 @@ class PptxService {
if (result.exitCode != 0) return [];
// Collect generated PNGs, sorted by name
final pngs = await outDir
.list()
.where((f) => f.path.endsWith('.png'))
@@ -153,7 +176,6 @@ class PptxService {
pngs.sort();
// Move to a persistent temp location so outDir can be cleaned up
final persistDir = await _makeTmpDir('pptx_slides');
final persistentPaths = <String>[];
for (var i = 0; i < pngs.length; i++) {
@@ -163,7 +185,6 @@ class PptxService {
persistentPaths.add(dst);
}
// Clean up the LibreOffice output dir
try {
await outDir.delete(recursive: true);
} catch (_) {}
@@ -174,20 +195,68 @@ class PptxService {
}
}
/// Generate placeholder slide images when LibreOffice is not available.
///
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
/// If ImageMagick is not available, writes minimal 1x1 white PNGs as
/// last-resort placeholders.
/// Resolve the LibreOffice CLI binary, or null when unavailable.
static Future<String?> resolveSoffice() async {
if (Platform.isWindows) {
const candidates = [
r'C:\Program Files\LibreOffice\program\soffice.exe',
r'C:\Program Files (x86)\LibreOffice\program\soffice.exe',
];
for (final c in candidates) {
if (await File(c).exists()) return c;
}
if (await _whichOk('soffice')) return 'soffice';
return null;
}
if (await _whichOk('libreoffice')) return 'libreoffice';
if (await _whichOk('soffice')) return 'soffice';
return null;
}
static Future<bool> _whichOk(String cmd) async {
try {
final r = await Process.run('which', [cmd]);
return r.exitCode == 0;
} catch (_) {
return false;
}
}
/// Convert an arbitrary office document (e.g. DOCX) to PDF via LibreOffice.
/// Optional — native [DocxParser] is preferred for opening in BadNote.
Future<String?> convertToPdf(String sourcePath) async {
final soffice = await resolveSoffice();
if (soffice == null) return null;
final outDir = p.dirname(sourcePath);
try {
final result = await Process.run(soffice, [
'--headless',
'--convert-to',
'pdf',
'--outdir',
outDir,
sourcePath,
]);
if (result.exitCode != 0) return null;
final pdfPath = p.join(
outDir,
'${p.basenameWithoutExtension(sourcePath)}.pdf',
);
if (await File(pdfPath).exists()) return pdfPath;
return null;
} catch (_) {
return null;
}
}
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
// Count slides by unzipping and counting slide XML files
final slideCount = await _countSlides(pptxPath);
final slideCount = await _countSlidesNative(pptxPath);
if (slideCount == 0) return [];
final outDir = await _makeTmpDir('pptx_placeholders');
final paths = <String>[];
// Try ImageMagick
final hasConvert = await _hasCommand('convert');
for (var i = 1; i <= slideCount; i++) {
@@ -203,7 +272,16 @@ class PptxService {
return paths;
}
Future<int> _countSlides(String pptxPath) async {
Future<int> _countSlidesNative(String pptxPath) async {
try {
final parsed = await _parser.parse(pptxPath);
return parsed.slides.length;
} catch (_) {
return _countSlidesUnzip(pptxPath);
}
}
Future<int> _countSlidesUnzip(String pptxPath) async {
final tmpDir = await _makeTmpDir('pptx_count');
try {
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
@@ -237,8 +315,7 @@ class PptxService {
int slideNum,
int total,
) async {
// Light pastel background with slide number
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
final hue = ((slideNum - 1) * 137) % 360;
await Process.run('convert', [
'-size',
'1920x1080',
@@ -256,30 +333,24 @@ class PptxService {
]);
}
/// Write a minimal valid 1x1 white PNG as an absolute last resort.
/// This is a hand-crafted PNG (IHDR + single white pixel IDAT + IEND).
Future<void> _writeMinimalPng(String path) async {
// Minimal valid 1x1 white PNG
const pngBytes = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
// IHDR chunk
0x00, 0x00, 0x00, 0x0D, // length = 13
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, // width = 1
0x00, 0x00, 0x00, 0x01, // height = 1
0x08, 0x02, // bit depth = 8, color type = 2 (RGB)
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xDE, // CRC
// IDAT chunk
0x00, 0x00, 0x00, 0x0C, // length = 12
0x49, 0x44, 0x41, 0x54, // "IDAT"
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01,
0x08, 0x02,
0x00, 0x00, 0x00,
0x90, 0x77, 0x53, 0xDE,
0x00, 0x00, 0x00, 0x0C,
0x49, 0x44, 0x41, 0x54,
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
0x01, 0x01, 0x01, 0x00, // compressed data
0x18, 0xDD, 0x8D, 0xB4, // CRC
// IEND chunk
0x00, 0x00, 0x00, 0x00, // length = 0
0x49, 0x45, 0x4E, 0x44, // "IEND"
0xAE, 0x42, 0x60, 0x82, // CRC
0x01, 0x01, 0x01, 0x00,
0x18, 0xDD, 0x8D, 0xB4,
0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4E, 0x44,
0xAE, 0x42, 0x60, 0x82,
];
await File(path).writeAsBytes(pngBytes);
}

View File

@@ -0,0 +1,194 @@
// lib/services/vault_search_index.dart
//
// Phase 6 of the file-based storage plan (docs/plans/2026-06-24-file-based-
// storage.md §B/§F): the search index, rebuilt by SCANNING the vault sidecars
// (the source of truth) rather than the demoted SQLite cache.
//
// What it indexes, per notebook/note folder, from its `*.badnote.json` sidecar:
// * the title (standalone notebooks) / source filename (file-backed docs),
// * every typed text box — EditorStroke(tool: text).textContent across all
// pages,
// * the handwriting OCR text persisted in the sidecar's `ocrText` field.
//
// It ALSO indexes a file-backed PDF's document body text, captured once at
// import into the sidecar's `pageText` field by [PdfTextIndexer]: the embedded
// (printed) text layer for a normal PDF, or background OCR of the rendered pages
// for a RASTERIZED / scanned PDF that has no text layer. Matching uses the
// existing pure search primitives (normalize / rank / snippet), so CJK substring
// search works.
import 'dart:io';
import '../editor/search/search_ranking.dart';
import '../editor/search/search_snippet.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/sidecar_store.dart';
import 'vault_service.dart';
/// One indexed notebook: where it lives and the text harvested from its sidecar.
class VaultSearchEntry {
const VaultSearchEntry({
required this.id,
required this.title,
required this.openPath,
required this.docType,
required this.text,
required this.isNote,
});
/// Stable id (the notebook folder path).
final String id;
/// Display title (note title / source filename).
final String title;
/// Path to pass to the editor: the in-vault source file for docs, or the
/// synthetic `<folder>/notebook` note path for standalone notebooks.
final String openPath;
/// `pdf` / `pptx` / `ppt` / `docx` / `notebook`.
final String docType;
/// All searchable text harvested from the sidecar (title + typed text + OCR),
/// joined for substring matching.
final String text;
/// True for standalone (free-ink) notebooks, false for file-backed documents.
final bool isNote;
}
/// A search hit over the vault: the entry plus a display snippet of the match.
class VaultSearchHit {
const VaultSearchHit({required this.entry, required this.snippet});
final VaultSearchEntry entry;
final Snippet snippet;
}
/// Builds and queries a scan-based full-text index over the vault sidecars.
///
/// The index is the list of [VaultSearchEntry]s built by [rebuild]; query is a
/// pure substring/rank over their harvested text (CJK-safe). Cheap enough to
/// rebuild lazily on demand for a single-user vault; there is no background
/// thread and no persisted index file (the SQLite cache is no longer the search
/// source of truth).
///
/// HONEST SCOPE (what search covers / does NOT):
/// * COVERS: note/doc titles, typed text boxes, handwriting OCR text persisted
/// into a sidecar's `ocrText` field, AND a PDF's document body text persisted
/// into `pageText` at import — the embedded text layer, or background OCR of
/// a rasterized/scanned PDF (see [PdfTextIndexer]).
/// * CAVEAT: `pageText` is only present once import-time indexing has run and
/// written it back to the sidecar. A PDF imported before this feature (or
/// whose OCR backend was unavailable) has no `pageText`, so only its
/// annotations are searchable until it is re-indexed.
class VaultSearchIndex {
VaultSearchIndex(this._vault);
final VaultService _vault;
List<VaultSearchEntry> _entries = const [];
bool _built = false;
/// The entries from the most recent [rebuild] (for tests / inspection).
List<VaultSearchEntry> get entries => List.unmodifiable(_entries);
/// Scan the vault and (re)build the in-memory index. Safe on an empty/missing
/// vault (yields an empty index). Never throws on a single unreadable sidecar.
Future<void> rebuild() async {
final entries = <VaultSearchEntry>[];
final notebooks = await _vault.scanNotebooks();
for (final nb in notebooks) {
final sidecar = await SidecarStore.read(
File('${nb.sourceFilePath}$kVaultSidecarSuffix'),
);
entries.add(
VaultSearchEntry(
id: nb.folderPath,
title: nb.filename,
openPath: nb.sourceFilePath,
docType: nb.docType,
text: _harvest(title: nb.filename, sidecar: sidecar),
isNote: false,
),
);
}
final notes = await _vault.scanNotes();
for (final note in notes) {
final sidecar = await SidecarStore.read(
File('${note.notePath}$kVaultSidecarSuffix'),
);
entries.add(
VaultSearchEntry(
id: note.folderPath,
title: note.title,
openPath: note.notePath,
docType: 'notebook',
text: _harvest(title: note.title, sidecar: sidecar),
isNote: true,
),
);
}
_entries = entries;
_built = true;
}
/// Search the index for [query], rebuilding it first if it has never been
/// built. Returns the best-matching notebooks/notes, most-relevant first. An
/// empty/whitespace query yields no hits.
Future<List<VaultSearchHit>> search(String query) async {
if (query.trim().isEmpty) return const [];
if (!_built) await rebuild();
final sources = <String, String>{
for (final e in _entries) e.id: e.text,
};
final ranked = rankHits(sources, query);
final byId = {for (final e in _entries) e.id: e};
final hits = <VaultSearchHit>[];
for (final hit in ranked) {
final entry = byId[hit.ref];
if (entry == null) continue;
hits.add(VaultSearchHit(entry: entry, snippet: hit.snippet));
}
return hits;
}
/// Concatenate every searchable string from one sidecar: the [title], every
/// typed text box across all pages, and the persisted handwriting OCR text.
static String _harvest({
required String title,
required BadnoteSidecar? sidecar,
}) {
final parts = <String>[title];
if (sidecar != null) {
// Typed text boxes on the pen-first text tool (SidecarText), plus any
// legacy stroke-embedded textContent.
for (final pageTexts in sidecar.texts.values) {
for (final t in pageTexts) {
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
}
}
for (final pageStrokes in sidecar.strokes.values) {
for (final stroke in pageStrokes) {
final text = stroke.textContent;
if (text != null && text.trim().isNotEmpty) {
parts.add(text.trim());
}
}
}
final ocr = sidecar.ocrText;
if (ocr != null && ocr.trim().isNotEmpty) parts.add(ocr.trim());
// Document body text captured at import: the PDF's embedded text layer,
// or background OCR of a rasterized/scanned PDF. Covers the underlying
// document, not just the user's annotations.
final body = sidecar.pageText;
if (body != null && body.trim().isNotEmpty) parts.add(body.trim());
}
return parts.join('\n');
}
}

View File

@@ -0,0 +1,546 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import '../storage/badnote_sidecar.dart';
import '../storage/notebook_manifest.dart';
import '../storage/sidecar_store.dart';
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
/// with [SidecarRepository.kSidecarSuffix]; duplicated here to avoid a layering
/// dependency from the service onto the editor.
const String kVaultSidecarSuffix = '.badnote.json';
/// A notebook discovered by scanning the vault: one folder holding a source
/// file (and, optionally, its sidecar). This is the file-backed source of truth
/// for the home screen list (the SQLite `documents` table is no longer read).
class VaultNotebook {
const VaultNotebook({
required this.folderPath,
required this.sourceFilePath,
required this.filename,
required this.docType,
required this.modified,
this.hasSidecar = false,
});
/// Absolute path to the notebook folder.
final String folderPath;
/// Absolute path to the annotatable source file inside the folder.
final String sourceFilePath;
/// Source filename including extension, e.g. `Calculus Lecture 3.pdf`.
final String filename;
/// Lowercased extension without the dot: `pdf` / `pptx` / `ppt` / `docx`.
final String docType;
/// Last-modified time of the source file (used for recency sorting).
final DateTime modified;
/// Whether a `<file>.badnote.json` sidecar exists next to the source file.
final bool hasSidecar;
}
/// Basename (without the sidecar suffix) of a standalone notebook's synthetic
/// note "source". Opening `SidecarRepository.open('<folder>/notebook', …)`
/// therefore writes `<folder>/notebook.badnote.json`.
const String kNotebookBaseName = 'notebook';
/// Full sidecar filename for a standalone (free-ink) notebook folder.
const String kNotebookSidecarName = '$kNotebookBaseName$kVaultSidecarSuffix';
/// A standalone (non-file-backed) free-ink notebook discovered by scanning the
/// vault: a folder holding a `notebook.badnote.json` and NO importable source
/// file. This is the file-based replacement for the old SQLite `notes` table.
class VaultNote {
const VaultNote({
required this.folderPath,
required this.notePath,
required this.title,
required this.modified,
});
/// Absolute path to the notebook folder.
final String folderPath;
/// Synthetic note "source" path `<folder>/notebook`. Pass this to
/// `SidecarRepository.open(notePath, docType: 'notebook')`; it keys the
/// `<folder>/notebook.badnote.json` sidecar. Doubles as the note's stable id.
final String notePath;
/// Display title (from the sidecar's `title`, falling back to the folder name).
final String title;
/// Last-modified time of the sidecar (used for recency sorting).
final DateTime modified;
}
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
class VaultContainer {
const VaultContainer({
required this.folderPath,
required this.title,
required this.modified,
required this.memberCount,
});
final String folderPath;
final String title;
final DateTime modified;
final int memberCount;
}
/// Records the user-picked vault root folder (an Obsidian-style vault) and
/// gates app startup behind a valid choice.
///
/// The vault root is the single folder under which all notebooks will live.
/// Phase 0 only persists the path and validates it exists; no data is moved
/// into the vault yet (later phases do that).
///
/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is
/// easily mockable: inject a [SharedPreferences] (e.g. from
/// `SharedPreferences.setMockInitialValues`) via the constructor for tests.
class VaultService {
/// SharedPreferences key under which the vault root path is stored.
static const String vaultRootKey = 'vaultRoot';
/// SharedPreferences key gating the one-time SQLite→sidecar migration
/// (Phase 5). Set true once the migration completes so it never re-runs.
static const String vaultMigrationDoneKey = 'vaultMigrationDone';
final SharedPreferences _prefs;
VaultService._(this._prefs);
static VaultService? _instance;
/// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads
/// the shared [SharedPreferences] instance.
static Future<VaultService> getInstance() async {
if (_instance != null) return _instance!;
final prefs = await SharedPreferences.getInstance();
final service = VaultService._(prefs);
_instance = service;
return service;
}
/// Test-only constructor: inject a (typically mock) [SharedPreferences] so
/// the vault root can be exercised without platform channels.
@visibleForTesting
VaultService.forTest(SharedPreferences prefs) : _prefs = prefs;
/// Test-only: drop the cached singleton so the next [getInstance] rebuilds.
@visibleForTesting
static void resetForTest() {
_instance = null;
}
/// The currently stored vault root path, or null if none has been chosen.
String? get vaultRoot => _prefs.getString(vaultRootKey);
/// Persist [path] as the vault root.
Future<void> setVaultRoot(String path) async {
await _prefs.setString(vaultRootKey, path);
}
/// Forget the stored vault root (e.g. to re-prompt the user).
Future<void> clearVaultRoot() async {
await _prefs.remove(vaultRootKey);
}
/// True once the one-time SQLite→sidecar migration (Phase 5) has completed.
/// When false, startup runs the migrator before opening the home screen.
bool get vaultMigrationDone => _prefs.getBool(vaultMigrationDoneKey) ?? false;
/// Mark the one-time SQLite→sidecar migration as done so it never re-runs.
Future<void> setVaultMigrationDone() async {
await _prefs.setBool(vaultMigrationDoneKey, true);
}
/// True iff a vault root is set AND that directory currently exists.
///
/// Returns false when no path is stored or when the stored path no longer
/// resolves to a directory (external drive unplugged, folder deleted) — the
/// caller then re-prompts rather than silently scattering data elsewhere.
Future<bool> vaultRootValid() async {
final path = vaultRoot;
if (path == null || path.isEmpty) return false;
return Directory(path).exists();
}
/// Source-file extensions BadNote can import as notebooks.
static const Set<String> importableExtensions = {'pdf', 'docx', 'pptx', 'ppt'};
/// Create a notebook FOLDER under the vault root, COPY the source file at
/// [sourceFilePath] into it, and return the path of the in-vault copy.
///
/// The folder name is the sanitized source basename (without extension),
/// de-duplicated with a numeric suffix on collision (`Lecture`, `Lecture 2`,
/// …). The sidecar (`<file>.badnote.json`) will live next to the copy — the
/// [SidecarRepository] keys off the returned path, so nothing else is needed.
///
/// Throws [StateError] if no valid vault root is set.
Future<String> createNotebook(String sourceFilePath) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final source = File(sourceFilePath);
final filename = p.basename(sourceFilePath);
final baseName = _sanitizeFolderName(p.basenameWithoutExtension(filename));
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final destPath = p.join(folder.path, filename);
await source.copy(destPath);
return destPath;
}
/// Scan the vault root for notebook folders. A notebook is a direct
/// subfolder (excluding the hidden `.badnote` metadata folder) that contains
/// at least one importable source file. Returns the notebooks sorted by
/// source-file mtime, most-recent first. An empty / missing vault yields an
/// empty list (never throws).
/// Scan vault folders that hold a [kNotebookManifestName] container.
Future<List<VaultContainer>> scanContainers() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final out = <VaultContainer>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
final c = await _readContainerFolder(entity);
if (c != null) out.add(c);
}
out.sort((a, b) => b.modified.compareTo(a.modified));
return out;
}
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
final manifest = await NotebookManifest.read(folder.path);
if (manifest == null) return null;
final file = NotebookManifest.fileIn(folder.path);
final stat = await file.stat();
final title = manifest.title.trim().isNotEmpty
? manifest.title.trim()
: p.basename(folder.path);
return VaultContainer(
folderPath: folder.path,
title: title,
modified: stat.modified,
memberCount: manifest.members.length,
);
}
/// Create an OneNote-style notebook container with one blank ink page.
Future<VaultContainer> createNotebookContainer(String title) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final trimmed = title.trim();
final baseName = _sanitizeFolderName(trimmed);
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = p.join('pages', pageId, kNotebookBaseName);
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folder.path, pageRel);
final now = DateTime.now().toUtc();
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: pageTitle,
pageCount: 1,
createdAt: now,
updatedAt: now,
),
);
final manifest = NotebookManifest(
title: pageTitle,
members: [
NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel.replaceAll('\\', '/'),
title: pageTitle,
),
],
);
await NotebookManifest.write(folder.path, manifest);
return VaultContainer(
folderPath: folder.path,
title: pageTitle,
modified: now,
memberCount: 1,
);
}
/// Append a blank ink page to an existing container. Returns the new member.
Future<NotebookMember> addBlankPageToContainer(
String folderPath, {
String title = 'Untitled page',
}) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
final pageRel = 'pages/$pageId/$kNotebookBaseName';
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
await pageDir.create(recursive: true);
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
final now = DateTime.now().toUtc();
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
BadnoteSidecar(
docType: 'notebook',
title: title,
pageCount: 1,
createdAt: now,
updatedAt: now,
),
);
final member = NotebookMember(
id: pageId,
kind: NotebookMemberKind.note,
relativePath: pageRel,
title: title,
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Copy [sourceAbsolutePath] into the container and register it as a member.
Future<NotebookMember> importFileIntoContainer(
String folderPath,
String sourceAbsolutePath,
) async {
final manifest = await NotebookManifest.read(folderPath);
if (manifest == null) {
throw StateError('Not a notebook container: $folderPath');
}
final basename = p.basename(sourceAbsolutePath);
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
final kind = notebookMemberKindFromExt(ext);
if (kind == null || kind == NotebookMemberKind.note) {
throw StateError('Unsupported import type: $ext');
}
final destRel = basename;
var destPath = p.join(folderPath, destRel);
var n = 2;
while (await File(destPath).exists()) {
final stem = p.basenameWithoutExtension(basename);
destPath = p.join(folderPath, '$stem $n.$ext');
n++;
}
await File(sourceAbsolutePath).copy(destPath);
final member = NotebookMember(
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
kind: kind,
relativePath: p.basename(destPath),
title: p.basenameWithoutExtension(destPath),
);
await NotebookManifest.write(
folderPath,
manifest.copyWith(members: [...manifest.members, member]),
);
return member;
}
/// Absolute path for a member inside [folderPath].
String memberAbsolutePath(String folderPath, NotebookMember member) =>
p.normalize(p.join(folderPath, member.relativePath));
Future<List<VaultNotebook>> scanNotebooks() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final notebooks = <VaultNotebook>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
// Container folders are listed by [scanContainers], not here.
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
final notebook = await _readNotebookFolder(entity);
if (notebook != null) notebooks.add(notebook);
}
notebooks.sort((a, b) => b.modified.compareTo(a.modified));
return notebooks;
}
/// Create an empty (free-ink) standalone notebook FOLDER under the vault root
/// named from [title], write an initial `notebook.badnote.json` carrying that
/// title (so the scan sees it immediately), and return the synthetic note
/// path `<folder>/notebook`.
///
/// Pass the returned path to `SidecarRepository.open(path, docType:
/// 'notebook')`, which keys the folder's `notebook.badnote.json` — there is
/// NO fake source file. Throws [StateError] if no valid vault root is set.
Future<String> createEmptyNotebook(String title) async {
final root = vaultRoot;
if (root == null || root.isEmpty) {
throw StateError('No vault root is set; cannot create a notebook.');
}
final trimmed = title.trim();
final baseName = _sanitizeFolderName(trimmed);
final folder = await _uniqueNotebookFolder(root, baseName);
await folder.create(recursive: true);
final notePath = p.join(folder.path, kNotebookBaseName);
final now = DateTime.now().toUtc();
final sidecar = BadnoteSidecar(
docType: 'notebook',
title: trimmed.isEmpty ? null : trimmed,
pageCount: 1,
createdAt: now,
updatedAt: now,
);
await SidecarStore.writeAtomic(
File('$notePath$kVaultSidecarSuffix'),
sidecar,
);
return notePath;
}
/// Scan the vault root for standalone (free-ink) notebook folders: direct
/// subfolders (excluding hidden `.` folders) that contain a
/// `notebook.badnote.json` and NO importable source file. Returns them sorted
/// by sidecar mtime, most-recent first. Missing / empty vault → empty list.
///
/// File-backed document folders (which DO hold an importable source file) are
/// surfaced by [scanNotebooks] instead, so the two scans never overlap.
Future<List<VaultNote>> scanNotes() async {
final root = vaultRoot;
if (root == null || root.isEmpty) return const [];
final dir = Directory(root);
if (!await dir.exists()) return const [];
final notes = <VaultNote>[];
await for (final entity in dir.list(followLinks: false)) {
if (entity is! Directory) continue;
final folderName = p.basename(entity.path);
if (folderName.startsWith('.')) continue;
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
final note = await _readNoteFolder(entity);
if (note != null) notes.add(note);
}
notes.sort((a, b) => b.modified.compareTo(a.modified));
return notes;
}
/// Inspect a folder, returning a [VaultNote] iff it holds a
/// `notebook.badnote.json` and NO importable source file, else null.
Future<VaultNote?> _readNoteFolder(Directory folder) async {
File? noteSidecar;
var hasSource = false;
await for (final entity in folder.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
if (name == kNotebookSidecarName) {
noteSidecar = entity;
continue;
}
if (name.endsWith(kVaultSidecarSuffix)) continue;
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
if (importableExtensions.contains(ext)) hasSource = true;
}
if (noteSidecar == null || hasSource) return null;
final notePath = p.join(folder.path, kNotebookBaseName);
final loaded = await SidecarStore.read(noteSidecar);
final stat = await noteSidecar.stat();
final title = (loaded?.title?.trim().isNotEmpty ?? false)
? loaded!.title!.trim()
: p.basename(folder.path);
return VaultNote(
folderPath: folder.path,
notePath: notePath,
title: title,
modified: stat.modified,
);
}
/// Inspect a single notebook folder, returning a [VaultNotebook] when it
/// holds an importable source file, else null. Picks the first importable
/// file (prefers a `.pdf` so a DOCX→PDF-converted notebook opens as its PDF).
Future<VaultNotebook?> _readNotebookFolder(Directory folder) async {
File? chosen;
String? chosenExt;
await for (final entity in folder.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
if (name.endsWith(kVaultSidecarSuffix)) continue;
final ext = p.extension(name).replaceFirst('.', '').toLowerCase();
if (!importableExtensions.contains(ext)) continue;
// Prefer a PDF artifact when present (DOCX-converted notebooks keep both).
if (chosen == null || (ext == 'pdf' && chosenExt != 'pdf')) {
chosen = entity;
chosenExt = ext;
}
}
if (chosen == null || chosenExt == null) return null;
final stat = await chosen.stat();
final sidecar = File('${chosen.path}$kVaultSidecarSuffix');
return VaultNotebook(
folderPath: folder.path,
sourceFilePath: chosen.path,
filename: p.basename(chosen.path),
docType: chosenExt,
modified: stat.modified,
hasSidecar: await sidecar.exists(),
);
}
/// Find an unused notebook folder under [root] for [baseName], appending a
/// ` 2`, ` 3`, … suffix on collision.
Future<Directory> _uniqueNotebookFolder(String root, String baseName) async {
final safeBase = baseName.isEmpty ? 'Untitled' : baseName;
var candidate = Directory(p.join(root, safeBase));
var n = 2;
while (await candidate.exists()) {
candidate = Directory(p.join(root, '$safeBase $n'));
n++;
}
return candidate;
}
/// Sanitize a basename into a safe folder name: strip characters illegal on
/// Windows/POSIX (`\ / : * ? " < > |`) and control chars, collapse
/// whitespace, and trim trailing dots/spaces (illegal on Windows).
static String _sanitizeFolderName(String name) {
final cleaned = name
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.replaceAll(RegExp(r'[. ]+$'), '');
return cleaned;
}
}

View File

@@ -0,0 +1,348 @@
// lib/services/webdav_client.dart
//
// A minimal WebDAV client abstraction for vault sync. The [WebDavClient]
// interface is intentionally tiny (the four verbs the sync algorithm needs:
// list / download / upload / mkcol) so that:
// * the sync ALGORITHM in WebDavSyncService can be unit-tested against a
// FAKE in-memory implementation (no real server), and
// * the real network adapter ([HttpWebDavClient]) stays a thin shim over
// `package:http` + `package:xml` (PROPFIND/GET/PUT/MKCOL).
//
// Paths handled here are REMOTE paths relative to the configured remote root,
// using forward slashes (e.g. `Lecture/Lecture.pdf`). Mapping vault file paths
// to/from these remote paths lives in WebDavSyncService.
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart';
/// One remote resource returned by a directory listing (PROPFIND).
class RemoteEntry {
const RemoteEntry({
required this.path,
required this.isDirectory,
this.modified,
this.size,
this.etag,
});
/// Remote path RELATIVE to the configured remote root, forward-slashed and
/// WITHOUT a leading slash, e.g. `Lecture/Lecture.pdf`. Directories carry no
/// trailing slash here (normalized by the client).
final String path;
/// Whether this entry is a collection (directory) rather than a file.
final bool isDirectory;
/// Server last-modified time (UTC) if the server reported one.
final DateTime? modified;
/// Content length in bytes if reported (files only).
final int? size;
/// Weak/strong ETag if reported (quotes stripped).
final String? etag;
}
/// Thrown by [WebDavClient] implementations for any transport/protocol error.
/// Carries a human-readable [message] suitable for surfacing in the UI.
class WebDavException implements Exception {
WebDavException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => 'WebDavException($message'
'${statusCode != null ? ', status: $statusCode' : ''})';
}
/// The four WebDAV operations the sync algorithm depends on. Inject a fake in
/// tests; inject [HttpWebDavClient] in production.
abstract class WebDavClient {
/// List the immediate-and-nested files under [remoteDir] (relative to the
/// remote root, `''` meaning the root itself). Returns every FILE found in
/// the subtree (directories are created on demand via [makeCollection], so
/// callers care about files). Implementations PROPFIND with Depth: infinity
/// and flatten the result. A missing remote dir yields an empty list.
Future<List<RemoteEntry>> list(String remoteDir);
/// Download the bytes of the remote file at [remotePath].
Future<Uint8List> download(String remotePath);
/// Upload [bytes] to [remotePath], creating/overwriting the remote file.
/// Parent collections must already exist (use [makeCollection]).
Future<void> upload(String remotePath, Uint8List bytes);
/// Create the collection (directory) at [remotePath]. Idempotent: an
/// already-existing collection is not an error.
Future<void> makeCollection(String remotePath);
/// Probe connectivity + credentials cheaply (PROPFIND Depth:0 on the root).
/// Throws [WebDavException] on failure; returns normally on success.
Future<void> testConnection();
}
/// Real WebDAV adapter over `package:http`. Thin by design — all the sync
/// decision logic lives in WebDavSyncService, NOT here.
///
/// DEVICE/SERVER-VALIDATED ONLY: this class performs real network round-trips
/// and is not exercised in CI (no WebDAV server). The XML/path plumbing below
/// is best-effort against common servers (Nextcloud, Apache mod_dav). The sync
/// algorithm that consumes it is what the unit tests cover, via a fake client.
class HttpWebDavClient implements WebDavClient {
HttpWebDavClient({
required String baseUrl,
required String username,
required String password,
String remoteRoot = '',
http.Client? httpClient,
this.timeout = const Duration(seconds: 30),
}) : _client = httpClient ?? http.Client(),
_ownsClient = httpClient == null,
_baseUri = _normalizeBase(baseUrl, remoteRoot),
_authHeader =
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
final http.Client _client;
final bool _ownsClient;
/// Absolute base URI INCLUDING the remote root path, always ending in `/`.
final Uri _baseUri;
final String _authHeader;
final Duration timeout;
/// Combine the server [baseUrl] with the [remoteRoot] folder into a single
/// absolute base URI ending in a slash. Tolerates trailing/leading slashes.
static Uri _normalizeBase(String baseUrl, String remoteRoot) {
var base = baseUrl.trim();
if (!base.endsWith('/')) base = '$base/';
var uri = Uri.parse(base);
final root = remoteRoot.trim().replaceAll(RegExp(r'^/+|/+$'), '');
if (root.isNotEmpty) {
uri = uri.resolve('${Uri.encodeFull(root)}/');
}
return uri;
}
/// Resolve a remote-root-relative [remotePath] to an absolute URI.
Uri _resolve(String remotePath) {
final clean = remotePath.replaceAll(RegExp(r'^/+'), '');
if (clean.isEmpty) return _baseUri;
// Encode each segment but keep the slashes.
final encoded = clean.split('/').map(Uri.encodeComponent).join('/');
return _baseUri.resolve(encoded);
}
Map<String, String> get _headers => {'Authorization': _authHeader};
@override
Future<void> testConnection() async {
final res = await _send('PROPFIND', _baseUri, headers: {'Depth': '0'});
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Server responded ${res.statusCode}',
statusCode: res.statusCode,
);
}
}
@override
Future<List<RemoteEntry>> list(String remoteDir) async {
final uri = _resolve(remoteDir.endsWith('/') ? remoteDir : '$remoteDir/');
final http.Response res;
try {
res = await _send('PROPFIND', uri, headers: {'Depth': 'infinity'});
} on WebDavException {
rethrow;
}
if (res.statusCode == 404) return const [];
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'PROPFIND failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
return _parseMultiStatus(res.body);
}
@override
Future<Uint8List> download(String remotePath) async {
final res = await _get(_resolve(remotePath));
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Download failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
return res.bodyBytes;
}
@override
Future<void> upload(String remotePath, Uint8List bytes) async {
final res = await _put(_resolve(remotePath), bytes);
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'Upload failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
}
@override
Future<void> makeCollection(String remotePath) async {
final uri = _resolve(remotePath.endsWith('/') ? remotePath : '$remotePath/');
final res = await _send('MKCOL', uri);
// 201 created; 405 method-not-allowed means it already exists (fine).
if (res.statusCode == 201 || res.statusCode == 405) return;
if (res.statusCode < 200 || res.statusCode >= 300) {
throw WebDavException(
'MKCOL failed (${res.statusCode})',
statusCode: res.statusCode,
);
}
}
/// Parse a WebDAV multistatus (PROPFIND) body into FILE entries, dropping
/// collections. Paths are made relative to [_baseUri]'s path and stripped of
/// a leading slash.
List<RemoteEntry> _parseMultiStatus(String body) {
final doc = XmlDocument.parse(body);
final basePath = _baseUri.path; // ends with '/'
final entries = <RemoteEntry>[];
for (final response in doc.findAllElements('response', namespace: '*')) {
final href = response
.findElements('href', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (href.isEmpty) continue;
// href may be absolute (http://host/dav/Lecture/x.pdf) or root-relative
// (/dav/Lecture/x.pdf). Reduce to the server path, then strip basePath.
var hrefPath = Uri.parse(href).path;
hrefPath = Uri.decodeFull(hrefPath);
final decodedBase = Uri.decodeFull(basePath);
if (!hrefPath.startsWith(decodedBase)) {
// Some servers omit the app prefix; try a looser suffix match.
final idx = hrefPath.indexOf(decodedBase);
if (idx < 0) continue;
hrefPath = hrefPath.substring(idx);
}
var rel = hrefPath.substring(decodedBase.length);
final isDir = rel.endsWith('/');
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
if (rel.isEmpty) continue; // the root collection itself
final propstat = response.findElements('propstat', namespace: '*');
DateTime? modified;
int? size;
String? etag;
var collection = isDir;
for (final ps in propstat) {
for (final prop in ps.findElements('prop', namespace: '*')) {
final lm = prop
.findElements('getlastmodified', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (lm.isNotEmpty) modified = _parseHttpDate(lm);
final cl = prop
.findElements('getcontentlength', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (cl.isNotEmpty) size = int.tryParse(cl);
final et = prop
.findElements('getetag', namespace: '*')
.map((e) => e.innerText.trim())
.firstWhere((_) => true, orElse: () => '');
if (et.isNotEmpty) etag = et.replaceAll('"', '');
if (prop.findAllElements('collection', namespace: '*').isNotEmpty) {
collection = true;
}
}
}
if (collection) continue; // sync only cares about files
entries.add(RemoteEntry(
path: rel,
isDirectory: false,
modified: modified?.toUtc(),
size: size,
etag: etag,
));
}
return entries;
}
static DateTime? _parseHttpDate(String s) {
try {
return parseHttpDate(s);
} catch (_) {
return null;
}
}
Future<http.Response> _send(
String method,
Uri uri, {
Map<String, String>? headers,
}) async {
final req = http.Request(method, uri)..headers.addAll(_headers);
if (headers != null) req.headers.addAll(headers);
try {
final streamed = await _client.send(req).timeout(timeout);
return http.Response.fromStream(streamed);
} on WebDavException {
rethrow;
} catch (e) {
throw WebDavException('Network error: $e');
}
}
Future<http.Response> _get(Uri uri) async {
try {
return await _client.get(uri, headers: _headers).timeout(timeout);
} catch (e) {
throw WebDavException('Network error: $e');
}
}
Future<http.Response> _put(Uri uri, Uint8List bytes) async {
try {
return await _client
.put(uri, headers: _headers, body: bytes)
.timeout(timeout);
} catch (e) {
throw WebDavException('Network error: $e');
}
}
/// Release the underlying [http.Client] if this instance created it.
void close() {
if (_ownsClient) _client.close();
}
}
/// Parse an RFC 1123 / RFC 850 / asctime HTTP-date into UTC. Kept local (rather
/// than pulling `http_parser`) since only `getlastmodified` needs it.
DateTime? parseHttpDate(String input) {
final s = input.trim();
// RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT"
final months = {
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12,
};
final m = RegExp(
r'(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})',
).firstMatch(s);
if (m == null) return null;
final day = int.parse(m.group(1)!);
final month = months[m.group(2)!];
if (month == null) return null;
final year = int.parse(m.group(3)!);
final hour = int.parse(m.group(4)!);
final min = int.parse(m.group(5)!);
final sec = int.parse(m.group(6)!);
return DateTime.utc(year, month, day, hour, min, sec);
}

View File

@@ -0,0 +1,573 @@
// lib/services/webdav_sync_service.dart
//
// Two-way sync of the BadNote vault folder <-> a user-configured WebDAV remote.
// The vault is a flat folder of notebook subfolders, each holding a source file
// plus its `<file>.badnote.json` sidecar; sync operates on the FILES only and
// never touches editors or storage formats.
//
// DESIGN: all sync DECISION logic (per-file winner, conflict handling, path
// mapping, last-synced bookkeeping) lives here and is injected with a
// [WebDavClient]. Tests drive it with an in-memory FAKE client; production wires
// an [HttpWebDavClient]. The real network round-trip is device/server-validated
// only (no WebDAV server in CI).
//
// ALGORITHM (per file, keyed by its vault-root-relative path):
// Let L = local state (exists? mtime), R = remote state (exists? mtime),
// and B = the per-file LAST-SYNCED baseline we stored after the previous sync
// (the mtime we last reconciled to, or absent for never-synced files).
//
// * local-only (L, !R) -> upload L (create remote dirs)
// * remote-only (!L, R):
// - known-before (B present) -> remote was DELETED by peer? We
// do NOT delete locally (conservative);
// we re-UPLOAD to restore. [see note]
// - never-seen (B absent) -> download R
// * both exist (L, R):
// - localChanged = L.mtime != B.mtime (or B absent)
// - remoteChanged = R.mtime != B.mtime (or B absent)
// - neither changed -> skip
// - only local changed -> upload L
// - only remote changed -> download R
// - BOTH changed (true conflict) -> last-write-wins by mtime:
// keep the WINNER as the canonical file, write the LOSER's bytes to
// `<file>.conflict-<winnerMtimeMillis>` on BOTH sides so NO data is
// lost, then converge canonical (upload or download as needed).
// After acting, record the converged mtime as the new baseline B.
//
// Deletes are handled CONSERVATIVELY: we never delete a file on either side as a
// result of sync. A file vanishing on one side is treated as "restore from the
// other side" rather than "propagate the delete", because we cannot distinguish
// an intentional delete from a half-finished transfer without a tombstone log
// (a deliberate TODO — see report).
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'webdav_client.dart';
/// Persisted WebDAV connection + sync configuration.
class WebDavConfig {
const WebDavConfig({
required this.baseUrl,
required this.username,
required this.password,
this.remoteRoot = '',
this.autoSync = false,
});
final String baseUrl;
final String username;
final String password;
/// Folder under the server's WebDAV root to sync into, e.g. `BadNote`.
final String remoteRoot;
/// When true, sync runs on launch/resume (non-blocking, failure-tolerant).
final bool autoSync;
/// True once enough is set to attempt a sync (URL present).
bool get isConfigured => baseUrl.trim().isNotEmpty;
WebDavConfig copyWith({
String? baseUrl,
String? username,
String? password,
String? remoteRoot,
bool? autoSync,
}) {
return WebDavConfig(
baseUrl: baseUrl ?? this.baseUrl,
username: username ?? this.username,
password: password ?? this.password,
remoteRoot: remoteRoot ?? this.remoteRoot,
autoSync: autoSync ?? this.autoSync,
);
}
}
/// Outcome of a [WebDavSyncService.syncNow] run, surfaced to the UI.
class SyncResult {
const SyncResult({
this.uploaded = 0,
this.downloaded = 0,
this.conflicts = 0,
this.skipped = 0,
this.finishedAt,
this.error,
});
final int uploaded;
final int downloaded;
final int conflicts;
final int skipped;
final DateTime? finishedAt;
/// Friendly error message when the run failed wholesale; null on success.
final String? error;
bool get ok => error == null;
}
/// What to do with one file after comparing local/remote/baseline.
@visibleForTesting
enum SyncAction { skip, upload, download, conflict }
/// A single planned per-file decision (exposed for testing the algorithm).
@visibleForTesting
class SyncDecision {
const SyncDecision(this.relPath, this.action, {this.conflictWinnerIsLocal});
final String relPath;
final SyncAction action;
/// For [SyncAction.conflict]: true if the LOCAL copy won (newer) and the
/// remote copy is the loser kept as `.conflict-*`; false if remote won.
final bool? conflictWinnerIsLocal;
}
/// Per-file last-synced baseline. After each sync we record BOTH sides'
/// observed mtimes, because an upload makes the server stamp its OWN mtime (≠
/// the local one) — a single shared timestamp would then look "changed" on the
/// next run. Comparing each side to its own baseline avoids that false conflict.
@visibleForTesting
class SyncBaseline {
const SyncBaseline({this.localMtime, this.remoteMtime});
final DateTime? localMtime;
final DateTime? remoteMtime;
}
/// Compact local/remote snapshot of a file for the decision function.
@visibleForTesting
class FileFacts {
const FileFacts({
required this.relPath,
required this.localMtime,
required this.remoteMtime,
this.baseline,
});
final String relPath;
/// Local file mtime (UTC, whole-second), or null if the file is absent.
final DateTime? localMtime;
/// Remote file mtime (UTC, whole-second), or null if absent.
final DateTime? remoteMtime;
/// Per-side mtimes recorded after the last successful sync, or null if never.
final SyncBaseline? baseline;
}
class WebDavSyncService {
WebDavSyncService(this._prefs);
final SharedPreferences _prefs;
static const String _kBaseUrl = 'webdav.baseUrl';
static const String _kUsername = 'webdav.username';
static const String _kPassword = 'webdav.password';
static const String _kRemoteRoot = 'webdav.remoteRoot';
static const String _kAutoSync = 'webdav.autoSync';
static const String _kLastSyncMillis = 'webdav.lastSyncMillis';
/// JSON map { relPath: {l: localMillis, r: remoteMillis} } persisted across
/// runs — the per-file, per-side baseline that lets us detect which side
/// changed since the last successful sync.
static const String _kBaselineJson = 'webdav.baseline';
/// Suffix marking a kept conflict loser. The trailing timestamp keeps repeated
/// conflicts from clobbering each other.
static const String conflictMarker = '.conflict-';
// ---- Configuration (SharedPreferences-backed) -------------------------
WebDavConfig get config => WebDavConfig(
baseUrl: _prefs.getString(_kBaseUrl) ?? '',
username: _prefs.getString(_kUsername) ?? '',
password: _prefs.getString(_kPassword) ?? '',
remoteRoot: _prefs.getString(_kRemoteRoot) ?? '',
autoSync: _prefs.getBool(_kAutoSync) ?? false,
);
Future<void> saveConfig(WebDavConfig c) async {
await _prefs.setString(_kBaseUrl, c.baseUrl.trim());
await _prefs.setString(_kUsername, c.username);
await _prefs.setString(_kPassword, c.password);
await _prefs.setString(_kRemoteRoot, c.remoteRoot.trim());
await _prefs.setBool(_kAutoSync, c.autoSync);
}
DateTime? get lastSyncTime {
final millis = _prefs.getInt(_kLastSyncMillis);
return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
}
// ---- Baseline map -----------------------------------------------------
Map<String, SyncBaseline> _readBaseline() {
final raw = _prefs.getString(_kBaselineJson);
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return decoded.map((k, v) {
final m = v as Map<String, dynamic>;
final lm = m['l'] as int?;
final rm = m['r'] as int?;
return MapEntry(
k,
SyncBaseline(
localMtime:
lm == null ? null : DateTime.fromMillisecondsSinceEpoch(lm),
remoteMtime:
rm == null ? null : DateTime.fromMillisecondsSinceEpoch(rm),
),
);
});
} catch (_) {
return {};
}
}
Future<void> _writeBaseline(Map<String, SyncBaseline> baseline) async {
final encoded = jsonEncode(baseline.map((k, v) => MapEntry(k, {
'l': v.localMtime?.millisecondsSinceEpoch,
'r': v.remoteMtime?.millisecondsSinceEpoch,
})));
await _prefs.setString(_kBaselineJson, encoded);
}
// ---- The pure decision function (unit-tested) -------------------------
/// Decide what to do for one file from its [FileFacts]. Pure: no I/O. This is
/// the heart of the algorithm and is exercised directly by the tests.
///
/// Mtimes are compared at whole-second granularity (WebDAV `getlastmodified`
/// has 1-second resolution); callers must truncate before passing facts in.
@visibleForTesting
static SyncDecision decide(FileFacts f) {
final l = f.localMtime;
final r = f.remoteMtime;
final bl = f.baseline?.localMtime;
final br = f.baseline?.remoteMtime;
if (l != null && r == null) {
// Local-only: either brand new locally, or remote vanished. Either way we
// (re)upload — never delete the local file.
return SyncDecision(f.relPath, SyncAction.upload);
}
if (l == null && r != null) {
// Never synced OR previously known but now gone locally — in both cases we
// conservatively pull from remote rather than propagating a delete.
return SyncDecision(f.relPath, SyncAction.download);
}
if (l == null && r == null) {
return SyncDecision(f.relPath, SyncAction.skip);
}
// Both sides have the file. Compare each side to ITS OWN baseline so an
// upload-stamped remote mtime isn't mistaken for a remote edit.
final localChanged = bl == null || !_sameSecond(l!, bl);
final remoteChanged = br == null || !_sameSecond(r!, br);
if (!localChanged && !remoteChanged) {
return SyncDecision(f.relPath, SyncAction.skip);
}
if (localChanged && !remoteChanged) {
return SyncDecision(f.relPath, SyncAction.upload);
}
if (!localChanged && remoteChanged) {
return SyncDecision(f.relPath, SyncAction.download);
}
// Both changed since baseline -> true conflict. Newer mtime wins.
final localWins = !l!.isBefore(r!); // ties resolve to local (keep working copy)
return SyncDecision(
f.relPath,
SyncAction.conflict,
conflictWinnerIsLocal: localWins,
);
}
static bool _sameSecond(DateTime a, DateTime b) =>
a.toUtc().millisecondsSinceEpoch ~/ 1000 ==
b.toUtc().millisecondsSinceEpoch ~/ 1000;
// ---- Path mapping (vault <-> remote), unit-tested ---------------------
/// Map a vault-root-relative path (OS separators) to a forward-slashed remote
/// path. e.g. on Windows `Lecture\Lecture.pdf` -> `Lecture/Lecture.pdf`.
@visibleForTesting
static String toRemotePath(String relPath) =>
p.split(relPath).where((s) => s.isNotEmpty).join('/');
/// Map a forward-slashed remote path back to a vault-root-relative path using
/// OS separators.
@visibleForTesting
static String toLocalRelPath(String remotePath) =>
p.joinAll(remotePath.split('/').where((s) => s.isNotEmpty));
/// True for files sync must IGNORE: sidecar temp/backup artifacts and our own
/// conflict copies (conflict copies stay local; they are not re-synced as if
/// canonical, but ARE uploaded as plain new files if the user keeps them).
@visibleForTesting
static bool isSyncable(String relPath) {
final name = p.basename(relPath);
if (name.startsWith('.')) return false; // hidden / .badnote metadata
if (name.endsWith('.tmp') || name.endsWith('.bak')) return false;
return true;
}
// ---- The orchestrator (I/O; device/server-validated) ------------------
/// Run a full two-way sync of [vaultRoot] against [client]. Pure decisions
/// from [decide] drive uploads/downloads; conflicts keep the loser as a
/// `.conflict-*` copy on both sides. Returns counts for the UI; on a wholesale
/// failure returns a [SyncResult] with [SyncResult.error] set (never throws).
Future<SyncResult> syncNow({
required String vaultRoot,
required WebDavClient client,
}) async {
try {
final root = Directory(vaultRoot);
if (!await root.exists()) {
return const SyncResult(error: 'Vault folder not found');
}
// 1. Snapshot both sides keyed by vault-relative path.
final localFiles = await _scanLocal(root);
final remoteList = await client.list('');
final remoteFiles = <String, RemoteEntry>{};
for (final e in remoteList) {
final rel = toLocalRelPath(e.path);
if (isSyncable(rel)) remoteFiles[rel] = e;
}
final baseline = _readBaseline();
var uploaded = 0;
var downloaded = 0;
var conflicts = 0;
var skipped = 0;
// Rel paths that ended up converged (and so deserve a fresh baseline).
final converged = <String>{};
final allPaths = <String>{...localFiles.keys, ...remoteFiles.keys};
for (final rel in allPaths) {
final localMtime = localFiles[rel];
final remoteEntry = remoteFiles[rel];
final facts = FileFacts(
relPath: rel,
localMtime: localMtime == null ? null : _truncate(localMtime),
remoteMtime: remoteEntry?.modified == null
? null
: _truncate(remoteEntry!.modified!),
baseline: baseline[rel],
);
final decision = decide(facts);
switch (decision.action) {
case SyncAction.skip:
skipped++;
converged.add(rel);
break;
case SyncAction.upload:
await _doUpload(root, client, rel);
uploaded++;
converged.add(rel);
break;
case SyncAction.download:
await _doDownload(root, client, rel, remoteEntry!.modified);
downloaded++;
converged.add(rel);
break;
case SyncAction.conflict:
await _doConflict(
root,
client,
rel,
localWins: decision.conflictWinnerIsLocal == true,
localMtime: localMtime!,
remoteMtime: remoteEntry!.modified,
);
conflicts++;
converged.add(rel);
break;
}
}
// Re-snapshot both sides so the new baseline records each side's ACTUAL
// post-sync mtime (uploads make the server stamp its own mtime). Comparing
// each side to its own baseline next time avoids a false "remote changed".
final finalLocal = await _scanLocal(root);
final finalRemote = <String, DateTime?>{};
for (final e in await client.list('')) {
final r = toLocalRelPath(e.path);
if (isSyncable(r)) finalRemote[r] = e.modified;
}
final newBaseline = <String, SyncBaseline>{};
for (final rel in converged) {
final lm = finalLocal[rel];
final rm = finalRemote[rel];
// Only keep a baseline once a file exists on BOTH sides; a one-sided
// file (mid-restore) stays "new" so the next run finishes converging it.
if (lm != null && rm != null) {
newBaseline[rel] = SyncBaseline(
localMtime: _truncate(lm),
remoteMtime: _truncate(rm),
);
}
}
await _writeBaseline(newBaseline);
final finishedAt = DateTime.now();
await _prefs.setInt(_kLastSyncMillis, finishedAt.millisecondsSinceEpoch);
return SyncResult(
uploaded: uploaded,
downloaded: downloaded,
conflicts: conflicts,
skipped: skipped,
finishedAt: finishedAt,
);
} on WebDavException catch (e) {
return SyncResult(error: e.message);
} catch (e) {
return SyncResult(error: e.toString());
}
}
/// Recursively collect every syncable file under [root], keyed by its
/// vault-root-relative path, mapped to its mtime.
Future<Map<String, DateTime>> _scanLocal(Directory root) async {
final out = <String, DateTime>{};
await for (final entity in root.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
final rel = p.relative(entity.path, from: root.path);
// Skip anything inside a hidden folder (e.g. .badnote) or hidden file.
if (p.split(rel).any((seg) => seg.startsWith('.'))) continue;
if (!isSyncable(rel)) continue;
final stat = await entity.stat();
out[rel] = stat.modified;
}
return out;
}
/// Upload the local file at [rel], creating remote parent collections.
Future<void> _doUpload(Directory root, WebDavClient client, String rel) async {
final file = File(p.join(root.path, rel));
final bytes = await file.readAsBytes();
await _ensureRemoteDirs(client, rel);
await client.upload(toRemotePath(rel), bytes);
}
/// Download the remote file at [rel] into the vault, creating local parent
/// dirs. Sets the local mtime to the remote's so the next run sees no drift.
Future<void> _doDownload(
Directory root,
WebDavClient client,
String rel,
DateTime? remoteMtime,
) async {
final bytes = await client.download(toRemotePath(rel));
final file = File(p.join(root.path, rel));
await file.parent.create(recursive: true);
await file.writeAsBytes(bytes, flush: true);
if (remoteMtime != null) {
try {
await file.setLastModified(remoteMtime);
} catch (_) {
// Some filesystems reject setLastModified; the post-sync re-snapshot
// captures whatever mtime landed, so convergence still holds.
}
}
}
/// Resolve a true conflict: keep the loser as `<file>.conflict-<winnerMillis>`
/// on BOTH sides (no data lost), then converge the canonical to the winner.
Future<void> _doConflict(
Directory root,
WebDavClient client,
String rel, {
required bool localWins,
required DateTime localMtime,
required DateTime? remoteMtime,
}) async {
final winnerMtime = localWins ? localMtime : (remoteMtime ?? localMtime);
final stamp = _truncate(winnerMtime).millisecondsSinceEpoch;
final conflictRel = '$rel$conflictMarker$stamp';
final localFile = File(p.join(root.path, rel));
final remoteBytes = await client.download(toRemotePath(rel));
if (localWins) {
// Local is canonical. Save the REMOTE bytes as the local conflict copy,
// upload that conflict copy remotely too, then push local up as canonical.
final conflictFile = File(p.join(root.path, conflictRel));
await conflictFile.parent.create(recursive: true);
await conflictFile.writeAsBytes(remoteBytes, flush: true);
await _ensureRemoteDirs(client, conflictRel);
await client.upload(toRemotePath(conflictRel), remoteBytes);
final localBytes = await localFile.readAsBytes();
await _ensureRemoteDirs(client, rel);
await client.upload(toRemotePath(rel), localBytes);
} else {
// Remote is canonical. Save the LOCAL bytes as the local conflict copy
// and push it remotely, then overwrite local with the remote (winner).
final localBytes = await localFile.readAsBytes();
final conflictFile = File(p.join(root.path, conflictRel));
await conflictFile.parent.create(recursive: true);
await conflictFile.writeAsBytes(localBytes, flush: true);
await _ensureRemoteDirs(client, conflictRel);
await client.upload(toRemotePath(conflictRel), localBytes);
await localFile.writeAsBytes(remoteBytes, flush: true);
if (remoteMtime != null) {
try {
await localFile.setLastModified(remoteMtime);
} catch (_) {}
}
}
}
/// Create each remote parent collection of [rel] from the root down (MKCOL is
/// idempotent), so an upload never 409s on a missing directory.
Future<void> _ensureRemoteDirs(WebDavClient client, String rel) async {
final segments = p.split(rel).where((s) => s.isNotEmpty).toList();
if (segments.length <= 1) return; // file at root, no dirs needed
var acc = '';
for (var i = 0; i < segments.length - 1; i++) {
acc = acc.isEmpty ? segments[i] : '$acc/${segments[i]}';
await client.makeCollection(acc);
}
}
static DateTime _truncate(DateTime t) => DateTime.fromMillisecondsSinceEpoch(
(t.toUtc().millisecondsSinceEpoch ~/ 1000) * 1000,
isUtc: true,
);
// ---- Convenience: build a real client from the saved config -----------
/// Construct an [HttpWebDavClient] from the persisted [config], or null when
/// unconfigured (caller disables the sync button). Caller owns close().
HttpWebDavClient? buildClient() {
final c = config;
if (!c.isConfigured) return null;
return HttpWebDavClient(
baseUrl: c.baseUrl,
username: c.username,
password: c.password,
remoteRoot: c.remoteRoot,
);
}
}

View File

@@ -0,0 +1,529 @@
// lib/storage/badnote_sidecar.dart
//
// The on-disk sidecar model: all annotations for one source file, serialized as
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
// pure model with NO runtime wiring yet.
//
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
// stroke format. Specifically:
// * per-page ink → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
// byte-for-byte the `ink.stroke_json` column today)
// * scratchpad ink → List<InkStroke> (lib/models/ink_stroke.dart; the exact
// format scratchpads already persist, absolute world px)
// * scratch anchors → ScratchLink (lib/models/scratch_link.dart)
// * bookmarks → Bookmark (lib/models/bookmark.dart)
//
// Only the *containers* and the (previously in-memory-only) highlight rect are
// new here. Unknown JSON fields are ignored on read so the schema is
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).
import 'dart:ui' show Rect;
import '../editor/engine/stroke_model.dart';
import '../models/bookmark.dart';
import '../models/ink_stroke.dart';
import '../models/scratch_link.dart';
/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
/// forward-compat; readers tolerate unknown extra fields.
const int kBadnoteSidecarVersion = 1;
/// A single highlighted text rectangle on a page, normalized to the page rect
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
/// `TODO(persist-highlights)`), so this small value class is the representation.
class SidecarHighlight {
const SidecarHighlight({
required this.l,
required this.t,
required this.r,
required this.b,
this.color = 0xFFFFFF00,
});
/// Normalized left edge in [0,1].
final double l;
/// Normalized top edge in [0,1].
final double t;
/// Normalized right edge in [0,1].
final double r;
/// Normalized bottom edge in [0,1].
final double b;
/// ARGB color of the highlight.
final int color;
/// Builds a highlight from a normalized [Rect] (as stored in
/// `_highlightsByPage`) and an ARGB color.
factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
SidecarHighlight(
l: rect.left,
t: rect.top,
r: rect.right,
b: rect.bottom,
color: color,
);
/// The normalized rect (page-relative) for rendering.
Rect toRect() => Rect.fromLTRB(l, t, r, b);
Map<String, dynamic> toJson() => {
'l': l,
't': t,
'r': r,
'b': b,
'color': color,
};
factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
SidecarHighlight(
l: (json['l'] as num).toDouble(),
t: (json['t'] as num).toDouble(),
r: (json['r'] as num).toDouble(),
b: (json['b'] as num).toDouble(),
color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarHighlight &&
runtimeType == other.runtimeType &&
l == other.l &&
t == other.t &&
r == other.r &&
b == other.b &&
color == other.color;
@override
int get hashCode => Object.hash(l, t, r, b, color);
@override
String toString() =>
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
}
/// A single typed-text annotation on a page (PDF editor for now). Position is
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
/// with the page; the editor multiplies it by the on-screen page width.
class SidecarText {
const SidecarText({
required this.id,
required this.nx,
required this.ny,
required this.text,
this.fontSize = 0.03,
this.color = 0xFF000000,
this.fontWeight = 400,
this.fontFamily,
});
/// Stable id (uuid) so edits/deletes address a specific box.
final String id;
/// Normalized x of the box's top-left in [0,1].
final double nx;
/// Normalized y of the box's top-left in [0,1].
final double ny;
/// The typed text.
final String text;
/// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
final double fontSize;
/// ARGB text color.
final int color;
/// CSS-like numeric weight (100900). Default 400 (regular).
final int fontWeight;
/// Optional family name. Null → editor default (IBM Plex Sans).
final String? fontFamily;
SidecarText copyWith({
String? id,
double? nx,
double? ny,
String? text,
double? fontSize,
int? color,
int? fontWeight,
String? fontFamily,
bool clearFontFamily = false,
}) =>
SidecarText(
id: id ?? this.id,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
text: text ?? this.text,
fontSize: fontSize ?? this.fontSize,
color: color ?? this.color,
fontWeight: fontWeight ?? this.fontWeight,
fontFamily:
clearFontFamily ? null : (fontFamily ?? this.fontFamily),
);
Map<String, dynamic> toJson() => {
'id': id,
'nx': nx,
'ny': ny,
'text': text,
'fontSize': fontSize,
'color': color,
'fontWeight': fontWeight,
if (fontFamily != null) 'fontFamily': fontFamily,
};
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
id: json['id'] as String,
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
text: (json['text'] as String?) ?? '',
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400,
fontFamily: json['fontFamily'] as String?,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarText &&
runtimeType == other.runtimeType &&
id == other.id &&
nx == other.nx &&
ny == other.ny &&
text == other.text &&
fontSize == other.fontSize &&
color == other.color &&
fontWeight == other.fontWeight &&
fontFamily == other.fontFamily;
@override
int get hashCode => Object.hash(
id,
nx,
ny,
text,
fontSize,
color,
fontWeight,
fontFamily,
);
@override
String toString() =>
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)';
}
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
/// so it restores (today the canvas always resets to 4000×4000).
class SidecarScratchpad {
const SidecarScratchpad({
this.canvasWidth = 4000.0,
this.canvasHeight = 4000.0,
this.strokes = const [],
});
final double canvasWidth;
final double canvasHeight;
/// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
final List<InkStroke> strokes;
Map<String, dynamic> toJson() => {
'canvasWidth': canvasWidth,
'canvasHeight': canvasHeight,
'strokes': strokes.map((s) => s.toJson()).toList(),
};
factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
SidecarScratchpad(
canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
.toList(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarScratchpad &&
runtimeType == other.runtimeType &&
canvasWidth == other.canvasWidth &&
canvasHeight == other.canvasHeight &&
_listEq(strokes, other.strokes);
@override
int get hashCode =>
Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));
@override
String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
}
/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
class SidecarScratchLink {
const SidecarScratchLink({
required this.link,
this.scratchpad = const SidecarScratchpad(),
});
/// Anchor geometry (reuses [ScratchLink] verbatim).
final ScratchLink link;
/// The anchor's private scratchpad.
final SidecarScratchpad scratchpad;
Map<String, dynamic> toJson() => {
...link.toJson(),
'scratchpad': scratchpad.toJson(),
};
factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
SidecarScratchLink(
link: ScratchLink.fromJson(json),
scratchpad: json['scratchpad'] == null
? const SidecarScratchpad()
: SidecarScratchpad.fromJson(
json['scratchpad'] as Map<String, dynamic>),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarScratchLink &&
runtimeType == other.runtimeType &&
link == other.link &&
scratchpad == other.scratchpad;
@override
int get hashCode => Object.hash(link, scratchpad);
@override
String toString() =>
'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
}
/// The whole sidecar: all annotations for one source file.
///
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
/// the scratchpad).
class BadnoteSidecar {
BadnoteSidecar({
this.version = kBadnoteSidecarVersion,
this.sourceFile,
this.docType,
this.title,
this.pageCount,
this.rotation = 0,
this.createdAt,
this.updatedAt,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
Map<int, String>? legacyAnnotations,
this.ocrText,
this.pageText,
this.legacyId,
this.background,
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
highlights = highlights ?? <int, List<SidecarHighlight>>{},
texts = texts ?? <int, List<SidecarText>>{},
bookmarks = bookmarks ?? <Bookmark>[],
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
legacyAnnotations = legacyAnnotations ?? <int, String>{};
/// Schema version (`badnoteSidecarVersion`).
final int version;
/// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
final String? sourceFile;
/// `pdf` / `pptx` / `notebook` etc.
final String? docType;
/// Display title for a standalone (non-file-backed) notebook (`docType ==
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
final String? title;
final int? pageCount;
final int rotation;
final DateTime? createdAt;
final DateTime? updatedAt;
/// Page index → committed [EditorStroke]s (normalized page coords).
final Map<int, List<EditorStroke>> strokes;
/// Page index → highlighted text rects (normalized).
final Map<int, List<SidecarHighlight>> highlights;
/// Page index → typed-text annotations (normalized position, page-relative
/// font size). PDF editor only for now (note text is a later increment).
final Map<int, List<SidecarText>> texts;
final List<Bookmark> bookmarks;
final List<SidecarScratchLink> scratchLinks;
/// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
/// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
/// only by the one-time SQLite→sidecar migration so no legacy data is silently
/// dropped; the live editor ignores it. Empty for all freshly authored
/// sidecars.
final Map<int, String> legacyAnnotations;
/// Searchable text recovered from this notebook's handwriting via local OCR
/// (Phase 6 search index). Persisted in the sidecar — the source of truth —
/// so the vault-scan search index can find handwritten notes WITHOUT the
/// (rebuildable, per-device) SQLite cache. Typed text already lives in the
/// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
/// the notebook has no handwriting or OCR hasn't run.
final String? ocrText;
/// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook
/// (a PDF), captured ONCE at import time so the vault-scan search index covers
/// the document — not just the user's annotations. It is either the PDF's
/// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no
/// text layer — the result of a background OCR pass over the rendered pages.
/// Pages are joined with `\f` (form feed) but the index treats it as a flat
/// blob. Null when the document has not been indexed yet (back-compat: an old
/// sidecar simply omits the field) or has no extractable/recognized text. This
/// is distinct from [ocrText], which holds ONLY handwriting OCR.
final String? pageText;
/// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
/// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
/// idempotent (a re-run recognizes an already-migrated item by this id even if
/// its folder name collided). Null for all freshly authored sidecars.
final String? legacyId;
/// The page-background template for a standalone notebook, stored as the
/// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
/// here so the storage model stays UI-decoupled; the editor decodes it via
/// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
final String? background;
Map<String, dynamic> toJson() => {
'badnoteSidecarVersion': version,
if (sourceFile != null) 'sourceFile': sourceFile,
if (docType != null) 'docType': docType,
if (title != null) 'title': title,
if (pageCount != null) 'pageCount': pageCount,
'rotation': rotation,
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
'strokes': {
for (final entry in strokes.entries)
entry.key.toString():
entry.value.map((s) => s.toJson()).toList(),
},
'highlights': {
for (final entry in highlights.entries)
entry.key.toString():
entry.value.map((h) => h.toJson()).toList(),
},
if (texts.isNotEmpty)
'texts': {
for (final entry in texts.entries)
entry.key.toString():
entry.value.map((t) => t.toJson()).toList(),
},
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
if (legacyAnnotations.isNotEmpty)
'legacyAnnotations': {
for (final entry in legacyAnnotations.entries)
entry.key.toString(): entry.value,
},
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
if (legacyId != null) 'legacyId': legacyId,
if (background != null) 'background': background,
};
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
Map<int, List<T>> decodePageMap<T>(
Object? raw,
T Function(Map<String, dynamic>) item,
) {
final out = <int, List<T>>{};
if (raw is Map) {
raw.forEach((key, value) {
final pageIndex = int.tryParse(key.toString());
if (pageIndex == null || value is! List) return;
out[pageIndex] = value
.map((e) => item(e as Map<String, dynamic>))
.toList();
});
}
return out;
}
return BadnoteSidecar(
version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
kBadnoteSidecarVersion,
sourceFile: json['sourceFile'] as String?,
docType: json['docType'] as String?,
title: json['title'] as String?,
pageCount: (json['pageCount'] as num?)?.toInt(),
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
createdAt: json['createdAt'] == null
? null
: DateTime.tryParse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.tryParse(json['updatedAt'] as String),
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
texts: decodePageMap(json['texts'], SidecarText.fromJson),
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
.toList(),
scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
.map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
.toList(),
legacyAnnotations: () {
final raw = json['legacyAnnotations'];
final out = <int, String>{};
if (raw is Map) {
raw.forEach((key, value) {
final page = int.tryParse(key.toString());
if (page != null && value is String) out[page] = value;
});
}
return out;
}(),
ocrText: json['ocrText'] as String?,
pageText: json['pageText'] as String?,
legacyId: json['legacyId'] as String?,
background: json['background'] as String?,
);
}
}
bool _listEq<T>(List<T> a, List<T> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,160 @@
// lib/storage/notebook_manifest.dart
//
// OneNote-style notebook container: a vault folder with `notebook.json` that
// lists members (blank notes + imported PDF/PPTX/DOCX). Each member still uses
// its own sidecar; this file is only the table of contents.
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
/// Filename of the notebook container manifest inside a vault folder.
const String kNotebookManifestName = 'notebook.json';
/// Default annotation font family (matches app UI theme).
const String kAnnotationFontFamily = 'IBM Plex Sans';
/// Kind of a notebook member.
enum NotebookMemberKind {
note,
pdf,
pptx,
ppt,
docx,
}
NotebookMemberKind? notebookMemberKindFromExt(String ext) {
switch (ext.toLowerCase()) {
case 'pdf':
return NotebookMemberKind.pdf;
case 'pptx':
return NotebookMemberKind.pptx;
case 'ppt':
return NotebookMemberKind.ppt;
case 'docx':
return NotebookMemberKind.docx;
case 'note':
case 'notebook':
return NotebookMemberKind.note;
default:
return null;
}
}
String notebookMemberKindToExt(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => 'note',
NotebookMemberKind.pdf => 'pdf',
NotebookMemberKind.pptx => 'pptx',
NotebookMemberKind.ppt => 'ppt',
NotebookMemberKind.docx => 'docx',
};
/// One page/section inside a notebook container.
class NotebookMember {
const NotebookMember({
required this.id,
required this.kind,
required this.relativePath,
required this.title,
});
final String id;
final NotebookMemberKind kind;
/// Path relative to the notebook folder (POSIX separators preferred).
final String relativePath;
final String title;
Map<String, dynamic> toJson() => {
'id': id,
'kind': kind.name,
'path': relativePath,
'title': title,
};
factory NotebookMember.fromJson(Map<String, dynamic> json) {
final kindName = (json['kind'] as String?) ?? 'note';
final kind = NotebookMemberKind.values.firstWhere(
(k) => k.name == kindName,
orElse: () => NotebookMemberKind.note,
);
return NotebookMember(
id: (json['id'] as String?) ?? '',
kind: kind,
relativePath: (json['path'] as String?) ?? '',
title: (json['title'] as String?) ?? '',
);
}
NotebookMember copyWith({String? title, String? relativePath}) =>
NotebookMember(
id: id,
kind: kind,
relativePath: relativePath ?? this.relativePath,
title: title ?? this.title,
);
}
/// Table of contents for a multi-document notebook folder.
class NotebookManifest {
const NotebookManifest({
required this.title,
required this.members,
this.version = 1,
});
final int version;
final String title;
final List<NotebookMember> members;
Map<String, dynamic> toJson() => {
'version': version,
'title': title,
'members': members.map((m) => m.toJson()).toList(),
};
factory NotebookManifest.fromJson(Map<String, dynamic> json) {
final raw = (json['members'] as List<dynamic>?) ?? const [];
return NotebookManifest(
version: (json['version'] as num?)?.toInt() ?? 1,
title: (json['title'] as String?) ?? '',
members: [
for (final e in raw)
NotebookMember.fromJson(e as Map<String, dynamic>),
],
);
}
NotebookManifest copyWith({
String? title,
List<NotebookMember>? members,
}) =>
NotebookManifest(
version: version,
title: title ?? this.title,
members: members ?? this.members,
);
static File fileIn(String folderPath) =>
File(p.join(folderPath, kNotebookManifestName));
static Future<NotebookManifest?> read(String folderPath) async {
final file = fileIn(folderPath);
if (!await file.exists()) return null;
try {
final map = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
return NotebookManifest.fromJson(map);
} catch (_) {
return null;
}
}
static Future<void> write(String folderPath, NotebookManifest manifest) async {
final file = fileIn(folderPath);
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(manifest.toJson()),
);
}
}

View File

@@ -0,0 +1,84 @@
// lib/storage/sidecar_store.dart
//
// Atomic read/write for `<file>.badnote.json` sidecars (Phase 1 / §F.1 of
// docs/plans/2026-06-24-file-based-storage.md). Pure dart:io, NO UI.
//
// Write protocol (§F.1):
// 1. Serialize to pretty JSON, write to `<target>.tmp` with flush:true.
// 2. Before clobbering, copy the current good `<target>` to `<target>.bak`
// (one-deep backup — cheap insurance against a corrupt write).
// 3. `rename` tmp → target. rename is atomic on the same filesystem (NTFS /
// POSIX), so a reader never observes a half-written sidecar.
//
// Read protocol: parse `<target>`; if it is missing OR fails to parse, fall back
// to `<target>.bak`. If neither yields valid JSON, return null.
import 'dart:convert';
import 'dart:io';
import 'badnote_sidecar.dart';
/// Stateless helper namespace for sidecar persistence.
class SidecarStore {
const SidecarStore._();
static const JsonEncoder _encoder = JsonEncoder.withIndent(' ');
/// Suffix for the in-progress temp file.
static const String tmpSuffix = '.tmp';
/// Suffix for the one-deep backup of the last good sidecar.
static const String bakSuffix = '.bak';
/// Atomically writes [sidecar] to [target] (temp + rename), keeping a `.bak`
/// of the previous good file. Never leaves a partial sidecar at [target]:
/// either the previous content (on failure before rename) or the new content.
static Future<void> writeAtomic(File target, BadnoteSidecar sidecar) async {
final json = _encoder.convert(sidecar.toJson());
await writeAtomicJson(target, json);
}
/// Lower-level variant for callers that already hold the JSON string.
static Future<void> writeAtomicJson(File target, String json) async {
await target.parent.create(recursive: true);
final tmp = File('${target.path}$tmpSuffix');
await tmp.writeAsString(json, flush: true);
// Back up the previous good file before clobbering it.
if (await target.exists()) {
final bak = File('${target.path}$bakSuffix');
try {
await target.copy(bak.path);
} catch (_) {
// A failed backup must not block the write; the atomic rename below
// still guarantees the new content lands intact.
}
}
// Atomic on the same filesystem.
await tmp.rename(target.path);
}
/// Reads and parses the sidecar at [target], falling back to `<target>.bak`
/// if the primary is missing or corrupt. Returns null if neither is readable.
static Future<BadnoteSidecar?> read(File target) async {
final primary = await _tryRead(target);
if (primary != null) return primary;
final bak = File('${target.path}$bakSuffix');
return _tryRead(bak);
}
static Future<BadnoteSidecar?> _tryRead(File file) async {
try {
if (!await file.exists()) return null;
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return BadnoteSidecar.fromJson(decoded);
} catch (_) {
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More