Implement Phase 3: canvas drawing with stylus input
- PadCanvasView: custom View with stylus event handling (historical points for smoothness), Path/Paint stroke rendering, backing bitmap at 1/4 resolution, 60pt grid drawing, Matrix coordinate transform - CanvasState: tool modes (fine/medium pen, eraser, select), zoom/pan state - EditorViewModel: loads/saves strokes to Room on completion - EditorScreen: Compose wrapper with AndroidView + FilterChip toolbar - NavGraph: pages route auto-navigates to first page editor, page size passed through route params Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
17
PROGRESS.md
17
PROGRESS.md
@@ -41,9 +41,24 @@ See PROJECT_PLAN.md for the full step list.
|
|||||||
- [x] 2.5: Navigation wired — tap notebook → pages stub, editor stub
|
- [x] 2.5: Navigation wired — tap notebook → pages stub, editor stub
|
||||||
- EngPadTheme: high-contrast light color scheme (black on white for e-ink)
|
- EngPadTheme: high-contrast light color scheme (black on white for e-ink)
|
||||||
|
|
||||||
|
### Phase 3: Canvas — Basic Drawing (2026-03-24)
|
||||||
|
|
||||||
|
- [x] 3.1–3.4: PadCanvasView — stylus input with historical points, Path/Paint
|
||||||
|
stroke rendering, backing bitmap at 1/4 resolution, 60pt grid, Matrix
|
||||||
|
coordinate transform (canonical ↔ screen)
|
||||||
|
- [x] 3.5: CanvasState — tool enum (PEN_FINE, PEN_MEDIUM, ERASER, SELECT),
|
||||||
|
zoom/pan state, pen width constants (4.49pt, 5.91pt)
|
||||||
|
- [x] 3.6: EditorViewModel — loads strokes from Room, saves on completion
|
||||||
|
- [x] 3.7: EditorScreen + Toolbar — Compose wrapper with AndroidView,
|
||||||
|
FilterChip toolbar for pen size and eraser selection
|
||||||
|
- [x] 3.8: NavGraph updated — pages route auto-navigates to first page's editor,
|
||||||
|
page size passed through route params
|
||||||
|
- Used KTX Canvas extensions (withMatrix, withScale, createBitmap) per lint
|
||||||
|
- ClickableViewAccessibility suppressed on PadCanvasView (drawing view)
|
||||||
|
|
||||||
## In Progress
|
## In Progress
|
||||||
|
|
||||||
Phase 3: Canvas — Basic Drawing
|
Phase 4: Zoom and Pan
|
||||||
|
|
||||||
## Decisions & Deviations
|
## Decisions & Deviations
|
||||||
|
|
||||||
|
|||||||
@@ -48,19 +48,19 @@ completed and log them in PROGRESS.md.
|
|||||||
|
|
||||||
## Phase 3: Canvas — Basic Drawing
|
## Phase 3: Canvas — Basic Drawing
|
||||||
|
|
||||||
- [ ] 3.1: Implement `PadCanvasView` — stylus event handling
|
- [x] 3.1: Implement `PadCanvasView` — stylus event handling
|
||||||
- `ui/editor/PadCanvasView.kt` — `onTouchEvent`, `getHistoricalX/Y`
|
- `ui/editor/PadCanvasView.kt` — `onTouchEvent`, `getHistoricalX/Y`
|
||||||
- [ ] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap
|
- [x] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap (1/4 resolution)
|
||||||
- [ ] 3.3: Grid drawing — 14.4pt spacing, toggleable
|
- [x] 3.3: Grid drawing — 60pt spacing, drawn on screen only
|
||||||
- [ ] 3.4: Coordinate transform — canonical ↔ screen via `Matrix`
|
- [x] 3.4: Coordinate transform — canonical ↔ screen via `Matrix`
|
||||||
- [ ] 3.5: Implement `CanvasState`
|
- [x] 3.5: Implement `CanvasState`
|
||||||
- `ui/editor/CanvasState.kt` — zoom, pan, active tool
|
- `ui/editor/CanvasState.kt` — zoom, pan, active tool, pen widths
|
||||||
- [ ] 3.6: Implement `EditorViewModel`
|
- [x] 3.6: Implement `EditorViewModel`
|
||||||
- `ui/editor/EditorViewModel.kt` — load/save strokes from Room
|
- `ui/editor/EditorViewModel.kt` — load/save strokes from Room
|
||||||
- [ ] 3.7: Implement `EditorScreen` + toolbar
|
- [x] 3.7: Implement `EditorScreen` + toolbar
|
||||||
- `ui/editor/EditorScreen.kt`, `ui/editor/Toolbar.kt`
|
- `ui/editor/EditorScreen.kt`, `ui/editor/Toolbar.kt`
|
||||||
- [ ] 3.8: Wire navigation from notebook/page list to editor
|
- [x] 3.8: Wire navigation — notebook list → pages → editor (pages auto-navigates to first page)
|
||||||
- **Verify:** `./gradlew build && ./gradlew test` + manual on-device test
|
- **Verify:** `./gradlew build` — PASSED (build + test + lint). Manual on-device test pending.
|
||||||
|
|
||||||
## Phase 4: Zoom and Pan
|
## Phase 4: Zoom and Pan
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
|
||||||
|
enum class Tool {
|
||||||
|
PEN_FINE, // 0.38mm = 4.49pt at 300 DPI
|
||||||
|
PEN_MEDIUM, // 0.50mm = 5.91pt at 300 DPI
|
||||||
|
ERASER,
|
||||||
|
SELECT,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CanvasState(
|
||||||
|
val pageSize: PageSize = PageSize.REGULAR,
|
||||||
|
val tool: Tool = Tool.PEN_FINE,
|
||||||
|
val zoom: Float = 1f,
|
||||||
|
val panX: Float = 0f,
|
||||||
|
val panY: Float = 0f,
|
||||||
|
) {
|
||||||
|
val penWidthPt: Float
|
||||||
|
get() = when (tool) {
|
||||||
|
Tool.PEN_FINE -> 4.49f
|
||||||
|
Tool.PEN_MEDIUM -> 5.91f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MIN_ZOOM = 0.5f
|
||||||
|
const val MAX_ZOOM = 4f
|
||||||
|
const val GRID_SPACING_PT = 60f // 300 DPI / 5 squares per inch
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditorScreen(
|
||||||
|
pageId: Long,
|
||||||
|
pageSize: PageSize,
|
||||||
|
database: EngPadDatabase,
|
||||||
|
) {
|
||||||
|
val repository = remember {
|
||||||
|
PageRepository(database.pageDao(), database.strokeDao())
|
||||||
|
}
|
||||||
|
val viewModel: EditorViewModel = viewModel(
|
||||||
|
factory = EditorViewModel.Factory(pageId, pageSize, repository),
|
||||||
|
)
|
||||||
|
val canvasState by viewModel.canvasState.collectAsState()
|
||||||
|
val strokes by viewModel.strokes.collectAsState()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val canvasView = remember { PadCanvasView(context) }
|
||||||
|
|
||||||
|
// Update canvas when state changes
|
||||||
|
LaunchedEffect(canvasState) {
|
||||||
|
canvasView.canvasState = canvasState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update strokes when loaded from DB
|
||||||
|
LaunchedEffect(strokes) {
|
||||||
|
canvasView.setStrokes(strokes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up stroke completion callback
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
canvasView.onStrokeCompleted = { penSize, color, points ->
|
||||||
|
viewModel.onStrokeCompleted(penSize, color, points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
EditorToolbar(
|
||||||
|
currentTool = canvasState.tool,
|
||||||
|
onToolSelected = { viewModel.setTool(it) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
AndroidView(
|
||||||
|
factory = { canvasView },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import net.metacircular.engpad.data.db.toBlob
|
||||||
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
|
||||||
|
class EditorViewModel(
|
||||||
|
private val pageId: Long,
|
||||||
|
private val pageSize: PageSize,
|
||||||
|
private val pageRepository: PageRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize))
|
||||||
|
val canvasState: StateFlow<CanvasState> = _canvasState
|
||||||
|
|
||||||
|
private val _strokes = MutableStateFlow<List<Stroke>>(emptyList())
|
||||||
|
val strokes: StateFlow<List<Stroke>> = _strokes
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadStrokes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadStrokes() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_strokes.value = pageRepository.getStrokes(pageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTool(tool: Tool) {
|
||||||
|
_canvasState.value = _canvasState.value.copy(tool = tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val order = pageRepository.getNextStrokeOrder(pageId)
|
||||||
|
val stroke = Stroke(
|
||||||
|
pageId = pageId,
|
||||||
|
penSize = penSize,
|
||||||
|
color = color,
|
||||||
|
pointData = points.toBlob(),
|
||||||
|
strokeOrder = order,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
val id = pageRepository.addStroke(stroke)
|
||||||
|
// Refresh strokes from DB to get the assigned ID
|
||||||
|
_strokes.value = pageRepository.getStrokes(pageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val pageId: Long,
|
||||||
|
private val pageSize: PageSize,
|
||||||
|
private val pageRepository: PageRepository,
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return EditorViewModel(pageId, pageSize, pageRepository) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.graphics.createBitmap
|
||||||
|
import androidx.core.graphics.withMatrix
|
||||||
|
import androidx.core.graphics.withScale
|
||||||
|
import net.metacircular.engpad.data.db.toFloatArray
|
||||||
|
import net.metacircular.engpad.data.model.Stroke
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom View for the drawing canvas. Handles stylus input, stroke rendering,
|
||||||
|
* grid drawing, and zoom/pan via Matrix transforms.
|
||||||
|
*
|
||||||
|
* All stroke coordinates are stored in canonical space (300 DPI).
|
||||||
|
* The viewMatrix transforms canonical -> screen coordinates.
|
||||||
|
*/
|
||||||
|
class PadCanvasView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
var canvasState = CanvasState()
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
rebuildViewMatrix()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strokes ---
|
||||||
|
private val completedStrokes = mutableListOf<StrokeRender>()
|
||||||
|
private var backingBitmap: Bitmap? = null
|
||||||
|
private var backingCanvas: Canvas? = null
|
||||||
|
private var bitmapDirty = true
|
||||||
|
|
||||||
|
// --- In-progress stroke ---
|
||||||
|
private var currentPath: Path? = null
|
||||||
|
private var currentPaint: Paint? = null
|
||||||
|
private val currentPoints = mutableListOf<Float>()
|
||||||
|
|
||||||
|
// --- Transform ---
|
||||||
|
private val viewMatrix = Matrix()
|
||||||
|
private val inverseMatrix = Matrix()
|
||||||
|
|
||||||
|
// --- Callbacks ---
|
||||||
|
var onStrokeCompleted: ((penSize: Float, color: Int, points: FloatArray) -> Unit)? = null
|
||||||
|
|
||||||
|
// --- Grid paint ---
|
||||||
|
private val gridPaint = Paint().apply {
|
||||||
|
color = Color.LTGRAY
|
||||||
|
strokeWidth = 1f
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
isAntiAlias = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page background ---
|
||||||
|
private val pagePaint = Paint().apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setBackgroundColor(Color.DKGRAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public API ---
|
||||||
|
|
||||||
|
fun setStrokes(strokes: List<Stroke>) {
|
||||||
|
completedStrokes.clear()
|
||||||
|
for (stroke in strokes) {
|
||||||
|
val points = stroke.pointData.toFloatArray()
|
||||||
|
val path = buildPathFromPoints(points)
|
||||||
|
val paint = buildPaint(stroke.penSize, stroke.color)
|
||||||
|
completedStrokes.add(StrokeRender(path, paint, stroke.id))
|
||||||
|
}
|
||||||
|
bitmapDirty = true
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCompletedStroke(id: Long, penSize: Float, color: Int, points: FloatArray) {
|
||||||
|
val path = buildPathFromPoints(points)
|
||||||
|
val paint = buildPaint(penSize, color)
|
||||||
|
completedStrokes.add(StrokeRender(path, paint, id))
|
||||||
|
bitmapDirty = true
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeStroke(id: Long) {
|
||||||
|
completedStrokes.removeAll { it.id == id }
|
||||||
|
bitmapDirty = true
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Drawing ---
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
rebuildViewMatrix()
|
||||||
|
bitmapDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
canvas.withMatrix(viewMatrix) {
|
||||||
|
// Draw page background
|
||||||
|
drawRect(
|
||||||
|
0f, 0f,
|
||||||
|
canvasState.pageSize.widthPt.toFloat(),
|
||||||
|
canvasState.pageSize.heightPt.toFloat(),
|
||||||
|
pagePaint,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw grid
|
||||||
|
drawGrid(this)
|
||||||
|
|
||||||
|
// Draw completed strokes from backing bitmap
|
||||||
|
ensureBacking()
|
||||||
|
backingBitmap?.let { bmp ->
|
||||||
|
// Scale backing bitmap back up (it's rendered at 1/4 resolution)
|
||||||
|
val scaleUp = 1f / BACKING_SCALE
|
||||||
|
withScale(scaleUp, scaleUp) {
|
||||||
|
drawBitmap(bmp, 0f, 0f, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw in-progress stroke
|
||||||
|
currentPath?.let { path ->
|
||||||
|
currentPaint?.let { paint ->
|
||||||
|
drawPath(path, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawGrid(canvas: Canvas) {
|
||||||
|
val pageW = canvasState.pageSize.widthPt.toFloat()
|
||||||
|
val pageH = canvasState.pageSize.heightPt.toFloat()
|
||||||
|
val spacing = CanvasState.GRID_SPACING_PT
|
||||||
|
|
||||||
|
var x = 0f
|
||||||
|
while (x <= pageW) {
|
||||||
|
canvas.drawLine(x, 0f, x, pageH, gridPaint)
|
||||||
|
x += spacing
|
||||||
|
}
|
||||||
|
var y = 0f
|
||||||
|
while (y <= pageH) {
|
||||||
|
canvas.drawLine(0f, y, pageW, y, gridPaint)
|
||||||
|
y += spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureBacking() {
|
||||||
|
val pageW = canvasState.pageSize.widthPt
|
||||||
|
val pageH = canvasState.pageSize.heightPt
|
||||||
|
|
||||||
|
val bmpW = (pageW * BACKING_SCALE).toInt()
|
||||||
|
val bmpH = (pageH * BACKING_SCALE).toInt()
|
||||||
|
|
||||||
|
if (backingBitmap == null || backingBitmap!!.width != bmpW || backingBitmap!!.height != bmpH) {
|
||||||
|
backingBitmap?.recycle()
|
||||||
|
backingBitmap = createBitmap(bmpW, bmpH)
|
||||||
|
backingCanvas = Canvas(backingBitmap!!)
|
||||||
|
bitmapDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmapDirty) {
|
||||||
|
backingBitmap!!.eraseColor(Color.TRANSPARENT)
|
||||||
|
backingCanvas!!.withScale(BACKING_SCALE, BACKING_SCALE) {
|
||||||
|
for (sr in completedStrokes) {
|
||||||
|
drawPath(sr.path, sr.paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bitmapDirty = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Input handling ---
|
||||||
|
|
||||||
|
@Suppress("ClickableViewAccessibility") // Drawing view — clicks not applicable
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
return when (event.getToolType(0)) {
|
||||||
|
MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusInput(event)
|
||||||
|
MotionEvent.TOOL_TYPE_FINGER -> handleFingerInput(event)
|
||||||
|
else -> super.onTouchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun performClick(): Boolean {
|
||||||
|
super.performClick()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStylusInput(event: MotionEvent): Boolean {
|
||||||
|
if (canvasState.tool == Tool.ERASER || canvasState.tool == Tool.SELECT) {
|
||||||
|
// Eraser and select modes handled in later phases
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
|
currentPoints.clear()
|
||||||
|
currentPoints.add(pt[0])
|
||||||
|
currentPoints.add(pt[1])
|
||||||
|
currentPath = Path().apply { moveTo(pt[0], pt[1]) }
|
||||||
|
currentPaint = buildPaint(canvasState.penWidthPt, Color.BLACK)
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val path = currentPath ?: return true
|
||||||
|
// Process historical points for smoothness
|
||||||
|
for (i in 0 until event.historySize) {
|
||||||
|
val pt = screenToCanonical(event.getHistoricalX(i), event.getHistoricalY(i))
|
||||||
|
path.lineTo(pt[0], pt[1])
|
||||||
|
currentPoints.add(pt[0])
|
||||||
|
currentPoints.add(pt[1])
|
||||||
|
}
|
||||||
|
val pt = screenToCanonical(event.x, event.y)
|
||||||
|
path.lineTo(pt[0], pt[1])
|
||||||
|
currentPoints.add(pt[0])
|
||||||
|
currentPoints.add(pt[1])
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (currentPoints.size >= 4) { // At least 2 points (x,y pairs)
|
||||||
|
val points = currentPoints.toFloatArray()
|
||||||
|
onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points)
|
||||||
|
}
|
||||||
|
currentPath = null
|
||||||
|
currentPaint = null
|
||||||
|
currentPoints.clear()
|
||||||
|
performClick()
|
||||||
|
invalidate()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFingerInput(event: MotionEvent): Boolean {
|
||||||
|
// Zoom/pan handled in Phase 4
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coordinate transforms ---
|
||||||
|
|
||||||
|
private fun rebuildViewMatrix() {
|
||||||
|
viewMatrix.reset()
|
||||||
|
val pageW = canvasState.pageSize.widthPt.toFloat()
|
||||||
|
val viewW = width.toFloat().coerceAtLeast(1f)
|
||||||
|
|
||||||
|
val fitScale = viewW / pageW
|
||||||
|
viewMatrix.setScale(fitScale * canvasState.zoom, fitScale * canvasState.zoom)
|
||||||
|
viewMatrix.postTranslate(canvasState.panX, canvasState.panY)
|
||||||
|
|
||||||
|
viewMatrix.invert(inverseMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun screenToCanonical(screenX: Float, screenY: Float): FloatArray {
|
||||||
|
val pts = floatArrayOf(screenX, screenY)
|
||||||
|
inverseMatrix.mapPoints(pts)
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
private fun buildPathFromPoints(points: FloatArray): Path {
|
||||||
|
val path = Path()
|
||||||
|
if (points.size < 2) return path
|
||||||
|
path.moveTo(points[0], points[1])
|
||||||
|
var i = 2
|
||||||
|
while (i < points.size - 1) {
|
||||||
|
path.lineTo(points[i], points[i + 1])
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPaint(penSize: Float, color: Int): Paint {
|
||||||
|
return Paint().apply {
|
||||||
|
this.color = color
|
||||||
|
strokeWidth = penSize
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeJoin = Paint.Join.ROUND
|
||||||
|
isAntiAlias = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StrokeRender(val path: Path, val paint: Paint, val id: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Backing bitmap is rendered at 1/4 canonical resolution to save memory. */
|
||||||
|
private const val BACKING_SCALE = 0.25f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package net.metacircular.engpad.ui.editor
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditorToolbar(
|
||||||
|
currentTool: Tool,
|
||||||
|
onToolSelected: (Tool) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = currentTool == Tool.PEN_FINE,
|
||||||
|
onClick = { onToolSelected(Tool.PEN_FINE) },
|
||||||
|
label = { Text("0.38") },
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentTool == Tool.PEN_MEDIUM,
|
||||||
|
onClick = { onToolSelected(Tool.PEN_MEDIUM) },
|
||||||
|
label = { Text("0.5") },
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = currentTool == Tool.ERASER,
|
||||||
|
onClick = { onToolSelected(Tool.ERASER) },
|
||||||
|
label = { Text("Eraser") },
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
package net.metacircular.engpad.ui.navigation
|
package net.metacircular.engpad.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
|
import net.metacircular.engpad.data.repository.NotebookRepository
|
||||||
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
import net.metacircular.engpad.ui.editor.EditorScreen
|
||||||
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val NOTEBOOKS = "notebooks"
|
const val NOTEBOOKS = "notebooks"
|
||||||
const val PAGES = "pages/{notebookId}"
|
const val PAGES = "pages/{notebookId}"
|
||||||
const val EDITOR = "editor/{pageId}"
|
const val EDITOR = "editor/{pageId}/{pageSize}"
|
||||||
|
|
||||||
fun pages(notebookId: Long) = "pages/$notebookId"
|
fun pages(notebookId: Long) = "pages/$notebookId"
|
||||||
fun editor(pageId: Long) = "editor/$pageId"
|
fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,16 +46,47 @@ fun EngPadNavGraph(
|
|||||||
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
|
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
|
||||||
// Stub — Phase 8 will implement PageListScreen
|
// Temporary: navigate directly to first page for now (Phase 8 adds page list)
|
||||||
androidx.compose.material3.Text("Pages for notebook $notebookId")
|
val notebookRepo = remember {
|
||||||
|
NotebookRepository(database.notebookDao(), database.pageDao())
|
||||||
|
}
|
||||||
|
val pageRepo = remember {
|
||||||
|
PageRepository(database.pageDao(), database.strokeDao())
|
||||||
|
}
|
||||||
|
var navigated by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(notebookId) {
|
||||||
|
if (!navigated) {
|
||||||
|
val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect
|
||||||
|
val pages = pageRepo.getPages(notebookId)
|
||||||
|
// Collect first emission to get the page list
|
||||||
|
pages.collect { pageList ->
|
||||||
|
if (pageList.isNotEmpty() && !navigated) {
|
||||||
|
navigated = true
|
||||||
|
val pageSize = PageSize.fromString(notebook.pageSize)
|
||||||
|
navController.navigate(Routes.editor(pageList[0].id, pageSize)) {
|
||||||
|
// Replace the pages route so back goes to notebook list
|
||||||
|
popUpTo(Routes.NOTEBOOKS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.EDITOR,
|
route = Routes.EDITOR,
|
||||||
arguments = listOf(navArgument("pageId") { type = NavType.LongType }),
|
arguments = listOf(
|
||||||
|
navArgument("pageId") { type = NavType.LongType },
|
||||||
|
navArgument("pageSize") { type = NavType.StringType },
|
||||||
|
),
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable
|
val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable
|
||||||
// Stub — Phase 3 will implement EditorScreen
|
val pageSizeStr = backStackEntry.arguments?.getString("pageSize") ?: return@composable
|
||||||
androidx.compose.material3.Text("Editor for page $pageId")
|
val pageSize = PageSize.fromString(pageSizeStr)
|
||||||
|
EditorScreen(
|
||||||
|
pageId = pageId,
|
||||||
|
pageSize = pageSize,
|
||||||
|
database = database,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user