- PROGRESS.md: remove stale known issues (all fixed), add post-Phase 10 feature polish section covering toolbar redesign, 4 pen sizes, line/box/move tools, edge swipe nav, page reorder, notebook rename, filter/sort, JPG export, clipboard ops, startup state restoration, and DB migrations - PROJECT_PLAN.md: add Phase 11 (Server Sync Integration) and Phase 12 (Notebook Backup/Export) with step breakdowns - DESIGN.md: add Tools table, sync architecture section, backup/export design, JPG export, stroke styles, startup state restoration, edge swipe nav; update rendering strategy (3-layer compositing), source tree, schema, and pen sizes - CLAUDE.md: update build commands, architecture, source tree, and key conventions to match current codebase Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
445 lines
20 KiB
Markdown
445 lines
20 KiB
Markdown
# DESIGN.md — eng-pad Technical Design
|
|
|
|
## Overview
|
|
|
|
eng-pad is an Android note-taking app for EMR pen devices. It provides a
|
|
notebook-based writing surface with a guide grid, four fixed pen sizes, line
|
|
and box drawing tools, and PDF/JPG export. Target devices are the Supernote
|
|
Manta (Android 11) and the Daylight DC-1 (Android 13).
|
|
|
|
## Architecture
|
|
|
|
**Kotlin + Jetpack Compose + Custom Canvas View.**
|
|
|
|
The app uses a single-Activity architecture with Jetpack Compose for UI
|
|
chrome (notebook list, page list, toolbar, dialogs) and Compose Navigation
|
|
for screen transitions. The drawing surface is a custom `View` subclass
|
|
(`PadCanvasView`) hosted inside Compose via `AndroidView` — Compose's
|
|
`Canvas` composable does not provide direct access to `MotionEvent` dispatch
|
|
or hardware-accelerated `Path` rendering, both of which are critical for
|
|
low-latency stylus input.
|
|
|
|
### Screen Flow
|
|
|
|
```
|
|
NotebookListScreen -> PageListScreen -> EditorScreen
|
|
| | |
|
|
Create/delete Page grid, PadCanvasView +
|
|
rename, filter, add/delete/ Toolbar (Compose)
|
|
sort notebooks reorder pages
|
|
```
|
|
|
|
Three Compose navigation destinations:
|
|
|
|
| Route | Screen | Purpose |
|
|
|------------------------|--------------------|--------------------------------|
|
|
| `notebooks` | NotebookListScreen | List, create, delete, rename notebooks; filter and sort |
|
|
| `pages/{notebookId}` | PageListScreen | Page thumbnails, add/delete pages, drag-to-reorder |
|
|
| `editor/{notebookId}?pageId={pageId}` | EditorScreen | Drawing canvas + toolbar |
|
|
|
|
### Dependency Stack
|
|
|
|
| Layer | Technology | Purpose |
|
|
|--------------|-----------------------------|---------------------------------|
|
|
| UI | Jetpack Compose + Material3 | Screens, toolbar, dialogs |
|
|
| Drawing | Custom View + Canvas API | Stroke rendering, input |
|
|
| State | ViewModel + StateFlow | Reactive UI state |
|
|
| Persistence | Room (SQLite) | Notebooks, pages, strokes |
|
|
| Preferences | SharedPreferences | Last notebook, UI state restore |
|
|
| Async | Kotlin Coroutines | Background DB operations |
|
|
| Navigation | Compose Navigation | Screen routing |
|
|
| Export | Android PdfDocument API | PDF generation |
|
|
| Export | Bitmap + JPEG compress | JPG generation |
|
|
| Share | FileProvider + Intents | PDF/JPG sharing (Dropbox, etc.) |
|
|
|
|
No external drawing libraries. The Android `Canvas` + `Path` + `Paint` API
|
|
is sufficient and avoids dependencies that may behave unpredictably on e-ink
|
|
displays.
|
|
|
|
## Coordinate System
|
|
|
|
All stroke data is stored in **canonical coordinates at 300 points per inch**
|
|
for high precision with fine pen strokes. Coordinates are scaled to 72 DPI
|
|
during PDF export (multiply by 72/300 = 0.24).
|
|
|
|
| Page Size | Inches | Canonical Points |
|
|
|-----------|----------|-------------------|
|
|
| Regular | 8.5 x 11 | 2550 x 3300 |
|
|
| Large | 11 x 17 | 3300 x 5100 |
|
|
|
|
### Grid
|
|
|
|
The guide grid uses ~5 squares per inch = 300/5 = **60 points** per grid
|
|
square. The grid is drawn as thin gray lines on screen but excluded from PDF
|
|
export, JPG export, and any other output.
|
|
|
|
### Pen Sizes
|
|
|
|
Four fixed stroke widths:
|
|
|
|
| Pen | Millimeters | Canonical Points |
|
|
|-------|-------------|------------------|
|
|
| 0.38 | 0.38 mm | 4.49 pt |
|
|
| 0.50 | 0.50 mm | 5.91 pt |
|
|
| 0.60 | 0.60 mm | 7.09 pt |
|
|
| 0.70 | 0.70 mm | 8.27 pt |
|
|
|
|
No pressure sensitivity — stroke width is uniform for a given pen size.
|
|
The PEN tool remembers the last selected size. Long-press the pen button
|
|
to open the size picker.
|
|
|
|
### Screen Transform
|
|
|
|
A `Matrix` maps canonical coordinates to screen pixels. The matrix encodes
|
|
the current zoom level and pan offset. All stylus input coordinates are
|
|
transformed through the inverse matrix before being stored, ensuring strokes
|
|
are always in canonical space regardless of zoom.
|
|
|
|
```
|
|
canonical -> [viewMatrix] -> screen pixels
|
|
screen pixels -> [inverseMatrix] -> canonical
|
|
```
|
|
|
|
Zoom range: dynamic minimum (page fills viewport) to 4x. Pan is clamped so
|
|
the page cannot scroll entirely off-screen.
|
|
|
|
## Tools
|
|
|
|
| Tool | Behavior | Sub-options |
|
|
|--------|----------|-------------|
|
|
| PEN | Freehand drawing with active pen size | 4 sizes via long-press menu |
|
|
| LINE | Straight line from pen-down to pen-up | Plain, arrow, double-arrow, dashed via long-press menu |
|
|
| BOX | Rectangle from corner to corner | Uses current pen size |
|
|
| ERASER | Touch a stroke to delete it (stroke-level) | — |
|
|
| SELECT | Rectangle selection, then cut/copy/delete/paste | — |
|
|
| MOVE | Tap and drag individual strokes | — |
|
|
|
|
## Data Model
|
|
|
|
### SQLite Schema (Room)
|
|
|
|
```sql
|
|
CREATE TABLE notebooks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
page_size TEXT NOT NULL CHECK(page_size IN ('regular', 'large')),
|
|
created_at INTEGER NOT NULL, -- epoch millis
|
|
updated_at INTEGER NOT NULL, -- epoch millis
|
|
last_page_id INTEGER NOT NULL DEFAULT 0 -- resume position
|
|
);
|
|
|
|
CREATE TABLE pages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
|
page_number INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
UNIQUE(notebook_id, page_number)
|
|
);
|
|
|
|
CREATE TABLE strokes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
pen_size REAL NOT NULL, -- width in canonical points
|
|
color INTEGER NOT NULL, -- ARGB packed int
|
|
point_data BLOB NOT NULL, -- packed floats: [x0,y0,x1,y1,...]
|
|
stroke_order INTEGER NOT NULL, -- z-order within the page
|
|
created_at INTEGER NOT NULL,
|
|
style TEXT NOT NULL DEFAULT 'plain' -- plain, dashed, arrow, double_arrow
|
|
);
|
|
|
|
CREATE INDEX idx_strokes_page ON strokes(page_id);
|
|
CREATE INDEX idx_pages_notebook ON pages(notebook_id);
|
|
```
|
|
|
|
Database version: 3. Migrations add `style` column to strokes and
|
|
`last_page_id` column to notebooks.
|
|
|
|
### Stroke Point Encoding
|
|
|
|
Points are stored as a `BLOB` of packed little-endian floats:
|
|
`[x0, y0, x1, y1, x2, y2, ...]`. A stroke with N points uses N x 2 x 4
|
|
bytes. A typical stroke of 200 points = 1600 bytes. This is ~10x more
|
|
compact than JSON and eliminates parsing overhead.
|
|
|
|
```kotlin
|
|
// Encode
|
|
fun FloatArray.toBlob(): ByteArray {
|
|
val buf = ByteBuffer.allocate(size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
|
for (f in this) buf.putFloat(f)
|
|
return buf.array()
|
|
}
|
|
|
|
// Decode
|
|
fun ByteArray.toFloatArray(): FloatArray {
|
|
val buf = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN)
|
|
return FloatArray(size / 4) { buf.getFloat() }
|
|
}
|
|
```
|
|
|
|
### Stroke Styles
|
|
|
|
The `style` column stores a string constant identifying the stroke's visual
|
|
style. This applies to LINE tool strokes and is stored in the DB so rendering
|
|
is consistent across sessions.
|
|
|
|
| Style | Rendering |
|
|
|----------------|-----------|
|
|
| `plain` | Solid line (default for freehand, box, and plain lines) |
|
|
| `dashed` | Dashed line pattern |
|
|
| `arrow` | Solid line with arrowhead at endpoint |
|
|
| `double_arrow` | Solid line with arrowheads at both ends |
|
|
|
|
## Input Handling
|
|
|
|
### Tool Type Dispatch
|
|
|
|
The EMR digitizer reports stylus events as `TOOL_TYPE_STYLUS` and finger
|
|
touches as `TOOL_TYPE_FINGER`. This provides a clean input split without
|
|
software palm rejection:
|
|
|
|
| Tool Type | Action |
|
|
|------------------|-------------------------------------|
|
|
| `TOOL_TYPE_STYLUS` | Draw, erase, select, or move (per tool) |
|
|
| `TOOL_TYPE_FINGER` | Pinch-to-zoom, pan, edge swipe navigation |
|
|
|
|
### Historical Points
|
|
|
|
The Wacom EMR digitizer batches events. To capture all intermediate points
|
|
for smooth strokes, `PadCanvasView` processes `MotionEvent.getHistoricalX/Y`
|
|
on every `ACTION_MOVE`, not just the current event coordinates.
|
|
|
|
### Edge Swipe Navigation
|
|
|
|
Finger swipes originating from the left or right 8% of the screen trigger
|
|
page navigation (previous/next page). Requires a minimum horizontal distance
|
|
of 100px. Multi-finger gestures cancel edge swipe detection. Swiping forward
|
|
past the last page auto-creates a new page if the current page has strokes.
|
|
|
|
### Line Snap
|
|
|
|
When drawing in pen mode, holding the stylus still for 1.5 seconds
|
|
activates line snap mode. The stroke is replaced with a straight line
|
|
from the original pen-down point to the current position. Moving the
|
|
pen continues the straight line. The snap timer is canceled if the pen
|
|
moves more than ~5mm (60pt at 300 DPI) from the origin before the
|
|
timer fires. This allows drawing straight lines for diagrams without
|
|
a separate ruler tool.
|
|
|
|
## Rendering Strategy
|
|
|
|
### On-Screen
|
|
|
|
Three-layer compositing with cached bitmaps:
|
|
|
|
1. **Grid layer** — cached bitmap at screen resolution, redrawn only when
|
|
zoom, pan, or view size changes. Grid is drawn in screen pixel space
|
|
(not canonical space) with pixel-snapped line positions for perfectly
|
|
uniform squares regardless of zoom level. Uses 1px screen-space lines.
|
|
|
|
2. **Stroke layer** — screen-resolution backing bitmap. Completed strokes
|
|
are drawn as `Path` objects via the view `Matrix`. New strokes are added
|
|
incrementally (drawn onto the existing bitmap without clearing) to avoid
|
|
full-redraw flicker. The bitmap is fully rebuilt only on zoom/pan changes
|
|
or stroke deletion.
|
|
|
|
3. **Dynamic layer** — drawn directly on each `onDraw` call in canonical
|
|
space via the view `Matrix`. Includes: in-progress stroke (pen is down),
|
|
line/box previews, selection rectangle and highlights.
|
|
|
|
Anti-aliasing is disabled on all paint objects for crisp e-ink rendering.
|
|
|
|
### Viewport
|
|
|
|
The page always fills the entire visible canvas area:
|
|
- Minimum zoom is computed dynamically so the page never appears smaller
|
|
than the viewport in either dimension.
|
|
- Pan is clamped so page edges stay at or beyond viewport edges.
|
|
- Background is white to eliminate any visible non-page area.
|
|
|
|
### PDF Export
|
|
|
|
Uses Android's `PdfDocument` API. `PdfDocument` pages use 1/72-inch units,
|
|
so stroke coordinates are scaled by 72/300 = 0.24 during export. A `Matrix`
|
|
pre-concatenated with the export canvas handles this. The grid drawing pass
|
|
is skipped.
|
|
|
|
Export flow:
|
|
1. Create `PdfDocument`.
|
|
2. For each page: add a `PdfDocument.Page` with canonical dimensions,
|
|
draw all strokes using the same `Path`/`Paint` rendering code.
|
|
3. Write to a temp file in the app's cache directory.
|
|
4. Share via `Intent.ACTION_SEND` with a `FileProvider` URI.
|
|
|
|
### JPG Export
|
|
|
|
Single-page export at 300 DPI (full canonical resolution). Creates a
|
|
`Bitmap`, renders all strokes (no grid), compresses to JPEG at 95% quality.
|
|
Shared via the same `FileProvider` + intent mechanism as PDF.
|
|
|
|
## Undo/Redo
|
|
|
|
Command pattern with a depth limit of 50:
|
|
|
|
| Action | Execute | Undo |
|
|
|----------------------|-------------------|--------------------|
|
|
| `AddStrokeAction` | Insert stroke | Delete stroke |
|
|
| `DeleteStrokeAction` | Delete stroke | Re-insert stroke |
|
|
| `MoveStrokesAction` | Offset points | Restore original points |
|
|
| `DeleteMultipleAction`| Delete strokes | Re-insert strokes |
|
|
| `CopyStrokesAction` | Duplicate strokes | Delete copies |
|
|
|
|
The `UndoManager` maintains two stacks. Performing a new action clears the
|
|
redo stack. Each action also persists or deletes from Room as part of
|
|
execute/undo.
|
|
|
|
## Selection
|
|
|
|
Rectangle selection (initial implementation; lasso selection as a future
|
|
upgrade):
|
|
|
|
1. In select mode, stylus drag draws a selection rectangle.
|
|
2. Strokes whose bounding boxes intersect the rectangle are selected.
|
|
3. Selected strokes get a visual highlight (translucent overlay + bounding box).
|
|
4. Available operations: cut, copy, delete, paste (toolbar buttons appear
|
|
contextually when strokes are selected or clipboard is non-empty).
|
|
5. All selection operations are undoable.
|
|
|
|
## Eraser
|
|
|
|
Stroke-level eraser (not pixel-level):
|
|
|
|
1. In erase mode, stylus touch/drag hit-tests against all strokes on the page.
|
|
2. Hit test: check stroke bounding box first (fast reject), then check
|
|
point-by-point distance (threshold: ~42 canonical points / ~3.5mm).
|
|
3. Hit strokes are deleted immediately (with undo support).
|
|
|
|
## Startup State Restoration
|
|
|
|
The app persists the last-opened notebook ID and a flag for whether the page
|
|
list was showing in `SharedPreferences` (via `EngPadApp`). On launch:
|
|
- If a notebook was previously open, auto-navigate to it.
|
|
- If the page list was showing, navigate to the page list on top.
|
|
- The editor resumes at the notebook's `lastPageId` (tracked per-notebook in
|
|
the DB).
|
|
|
|
## E-ink Considerations
|
|
|
|
Both target devices have e-ink or e-ink-like displays with high refresh latency:
|
|
|
|
- Minimize animations and transitions.
|
|
- Use high-contrast colors (black on white).
|
|
- Minimize invalidation regions (invalidate only the bounding box of new
|
|
stroke segments, not the full canvas).
|
|
- Consider a manual "refresh" button to force a full-screen redraw to clear
|
|
e-ink ghosting.
|
|
|
|
## Sync Architecture (Planned)
|
|
|
|
eng-pad will sync notebooks to an eng-pad-server instance over gRPC:
|
|
|
|
- **Protocol**: gRPC with protobuf-lite, over TLS.
|
|
- **Authentication**: password-based auth to eng-pad-server (not MCIAS — this
|
|
is a personal-use app, not a Metacircular service).
|
|
- **Sync model**: manual sync (user-initiated, not automatic). Per-notebook
|
|
sync button and sync-all from the library.
|
|
- **Conflict resolution**: last-write-wins based on `updated_at` timestamps,
|
|
with potential for manual resolution in future.
|
|
- **Data flow**: push local changes, pull remote changes. Full notebook
|
|
content (metadata + pages + strokes) serialized for transfer.
|
|
- **Status tracking**: per-notebook sync state (synced, pending, error)
|
|
displayed as badges in the notebook list.
|
|
|
|
## Backup/Export Design (Planned)
|
|
|
|
Notebook backup as a portable zip file:
|
|
|
|
- **Format**: `notebook_title.engpad.zip` containing:
|
|
- `notebook.json` — notebook metadata (title, page_size, page count)
|
|
- `pages/001.json`, `pages/002.json`, ... — one file per page with
|
|
metadata and strokes array
|
|
- Each stroke: `{penSize, color, style, points: [x0,y0,x1,y1,...]}`
|
|
- Points as JSON float arrays (portable, human-readable)
|
|
- **Import**: parse zip, create notebook + pages + strokes in Room DB.
|
|
- **Export-all**: single zip containing all notebooks for full backup.
|
|
- **Sharing**: via Android share intents (same mechanism as PDF/JPG).
|
|
|
|
## Source Tree
|
|
|
|
```
|
|
eng-pad/
|
|
├── app/
|
|
│ ├── build.gradle.kts -- Module build config
|
|
│ └── src/
|
|
│ ├── main/
|
|
│ │ ├── AndroidManifest.xml
|
|
│ │ ├── kotlin/net/metacircular/engpad/
|
|
│ │ │ ├── EngPadApp.kt -- Application class, SharedPreferences
|
|
│ │ │ ├── MainActivity.kt -- Single activity, Compose NavHost
|
|
│ │ │ ├── data/
|
|
│ │ │ │ ├── db/
|
|
│ │ │ │ │ ├── EngPadDatabase.kt -- Room database (version 3)
|
|
│ │ │ │ │ ├── NotebookDao.kt -- Notebook CRUD
|
|
│ │ │ │ │ ├── PageDao.kt -- Page CRUD + reorder
|
|
│ │ │ │ │ ├── StrokeDao.kt -- Stroke CRUD
|
|
│ │ │ │ │ └── Converters.kt -- Room type converters
|
|
│ │ │ │ ├── model/
|
|
│ │ │ │ │ ├── Notebook.kt -- Room entity (+ lastPageId)
|
|
│ │ │ │ │ ├── Page.kt -- Room entity
|
|
│ │ │ │ │ ├── Stroke.kt -- Room entity (+ style column)
|
|
│ │ │ │ │ └── PageSize.kt -- Enum: REGULAR, LARGE
|
|
│ │ │ │ └── repository/
|
|
│ │ │ │ ├── NotebookRepository.kt -- Notebook operations + rename
|
|
│ │ │ │ └── PageRepository.kt -- Page + stroke ops + reorder
|
|
│ │ │ ├── ui/
|
|
│ │ │ │ ├── navigation/
|
|
│ │ │ │ │ └── NavGraph.kt -- Routes, auto-restore last notebook
|
|
│ │ │ │ ├── notebooks/
|
|
│ │ │ │ │ ├── NotebookListScreen.kt -- Library: list, filter, sort, rename
|
|
│ │ │ │ │ └── NotebookListViewModel.kt -- Notebook list state + rename
|
|
│ │ │ │ ├── pages/
|
|
│ │ │ │ │ ├── PageListScreen.kt -- Page grid, drag-to-reorder
|
|
│ │ │ │ │ └── PageListViewModel.kt -- Page list state + reorder
|
|
│ │ │ │ ├── editor/
|
|
│ │ │ │ │ ├── PadCanvasView.kt -- Custom View: rendering + input + edge swipe
|
|
│ │ │ │ │ ├── EditorScreen.kt -- Compose wrapper
|
|
│ │ │ │ │ ├── EditorViewModel.kt -- Editor state, page nav, clipboard
|
|
│ │ │ │ │ ├── CanvasState.kt -- Tool, PenSize, LineStyle, zoom/pan
|
|
│ │ │ │ │ └── Toolbar.kt -- Icon-button toolbar with Canvas icons
|
|
│ │ │ │ ├── export/
|
|
│ │ │ │ │ └── PdfExporter.kt -- PDF + JPG generation + sharing
|
|
│ │ │ │ └── theme/
|
|
│ │ │ │ └── Theme.kt -- Material3 high-contrast e-ink theme
|
|
│ │ │ └── undo/
|
|
│ │ │ ├── UndoManager.kt -- Undo/redo stack
|
|
│ │ │ ├── UndoableAction.kt -- Action interface
|
|
│ │ │ ├── StrokeActions.kt -- Add/delete stroke actions
|
|
│ │ │ └── SelectionActions.kt -- Multi-stroke delete/move/copy
|
|
│ │ └── res/
|
|
│ │ ├── values/
|
|
│ │ │ ├── strings.xml
|
|
│ │ │ └── themes.xml
|
|
│ │ ├── drawable/ -- Launcher icons
|
|
│ │ ├── mipmap-anydpi/ -- Adaptive icon
|
|
│ │ └── xml/
|
|
│ │ └── file_provider_paths.xml -- FileProvider config
|
|
│ └── test/
|
|
│ └── kotlin/net/metacircular/engpad/
|
|
│ ├── data/
|
|
│ │ ├── StrokeBlobTest.kt -- Float array <-> blob roundtrip
|
|
│ │ └── PageSizeTest.kt -- Page size enum tests
|
|
│ └── undo/
|
|
│ └── UndoManagerTest.kt -- Undo/redo logic
|
|
├── build.gradle.kts -- Root build config
|
|
├── settings.gradle.kts -- Project settings (foojay JDK resolver)
|
|
├── gradle.properties -- Gradle properties
|
|
├── gradle/
|
|
│ └── libs.versions.toml -- Version catalog
|
|
├── Makefile -- Build targets (build, test, lint, run, devrun)
|
|
├── .gitignore
|
|
├── CLAUDE.md
|
|
├── README.md
|
|
├── DESIGN.md -- This file
|
|
├── PROJECT_PLAN.md -- Implementation steps
|
|
└── PROGRESS.md -- Completion tracking
|
|
```
|