From 2fc4224f5a2a98a3f71a7d5a9401743e27af4e01 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 14:21:17 -0700 Subject: [PATCH] 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) --- PROGRESS.md | 17 +- PROJECT_PLAN.md | 20 +- .../engpad/ui/editor/CanvasState.kt | 31 ++ .../engpad/ui/editor/EditorScreen.kt | 67 ++++ .../engpad/ui/editor/EditorViewModel.kt | 68 ++++ .../engpad/ui/editor/PadCanvasView.kt | 303 ++++++++++++++++++ .../metacircular/engpad/ui/editor/Toolbar.kt | 37 +++ .../engpad/ui/navigation/NavGraph.kt | 54 +++- 8 files changed, 579 insertions(+), 18 deletions(-) create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt diff --git a/PROGRESS.md b/PROGRESS.md index 5d280e1..5ade77a 100644 --- a/PROGRESS.md +++ b/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 - 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 -Phase 3: Canvas — Basic Drawing +Phase 4: Zoom and Pan ## Decisions & Deviations diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 8028698..250a97a 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -48,19 +48,19 @@ completed and log them in PROGRESS.md. ## 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` -- [ ] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap -- [ ] 3.3: Grid drawing — 14.4pt spacing, toggleable -- [ ] 3.4: Coordinate transform — canonical ↔ screen via `Matrix` -- [ ] 3.5: Implement `CanvasState` - - `ui/editor/CanvasState.kt` — zoom, pan, active tool -- [ ] 3.6: Implement `EditorViewModel` +- [x] 3.2: Stroke rendering — `Path`/`Paint`, backing bitmap (1/4 resolution) +- [x] 3.3: Grid drawing — 60pt spacing, drawn on screen only +- [x] 3.4: Coordinate transform — canonical ↔ screen via `Matrix` +- [x] 3.5: Implement `CanvasState` + - `ui/editor/CanvasState.kt` — zoom, pan, active tool, pen widths +- [x] 3.6: Implement `EditorViewModel` - `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` -- [ ] 3.8: Wire navigation from notebook/page list to editor -- **Verify:** `./gradlew build && ./gradlew test` + manual on-device test +- [x] 3.8: Wire navigation — notebook list → pages → editor (pages auto-navigates to first page) +- **Verify:** `./gradlew build` — PASSED (build + test + lint). Manual on-device test pending. ## Phase 4: Zoom and Pan diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt new file mode 100644 index 0000000..a4768f2 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt new file mode 100644 index 0000000..3371360 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -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), + ) + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt new file mode 100644 index 0000000..5207239 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt @@ -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 + + private val _strokes = MutableStateFlow>(emptyList()) + val strokes: StateFlow> = _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 create(modelClass: Class): T { + return EditorViewModel(pageId, pageSize, pageRepository) as T + } + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt new file mode 100644 index 0000000..511a219 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt @@ -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() + 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() + + // --- 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) { + 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 + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt new file mode 100644 index 0000000..8eb7a75 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt @@ -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), + ) + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt index 523a43a..36c9da6 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt @@ -1,21 +1,30 @@ package net.metacircular.engpad.ui.navigation 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.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument 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 object Routes { const val NOTEBOOKS = "notebooks" const val PAGES = "pages/{notebookId}" - const val EDITOR = "editor/{pageId}" + const val EDITOR = "editor/{pageId}/{pageSize}" fun pages(notebookId: Long) = "pages/$notebookId" - fun editor(pageId: Long) = "editor/$pageId" + fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}" } @Composable @@ -37,16 +46,47 @@ fun EngPadNavGraph( arguments = listOf(navArgument("notebookId") { type = NavType.LongType }), ) { backStackEntry -> val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable - // Stub — Phase 8 will implement PageListScreen - androidx.compose.material3.Text("Pages for notebook $notebookId") + // Temporary: navigate directly to first page for now (Phase 8 adds page list) + 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( route = Routes.EDITOR, - arguments = listOf(navArgument("pageId") { type = NavType.LongType }), + arguments = listOf( + navArgument("pageId") { type = NavType.LongType }, + navArgument("pageSize") { type = NavType.StringType }, + ), ) { backStackEntry -> val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable - // Stub — Phase 3 will implement EditorScreen - androidx.compose.material3.Text("Editor for page $pageId") + val pageSizeStr = backStackEntry.arguments?.getString("pageSize") ?: return@composable + val pageSize = PageSize.fromString(pageSizeStr) + EditorScreen( + pageId = pageId, + pageSize = pageSize, + database = database, + ) } } }