From e81dd60f303a26660220b48ffd8c0027f71f6ec1 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 15:30:51 -0700 Subject: [PATCH] Overhaul navigation, add line tool and JPG export Navigation: - Notebooks remember last page (last_page_id column, migration v1->v2) - Opening a notebook goes directly to last page's editor - Edge swipe (finger from screen edge) navigates prev/next page, auto-adds pages at end, no-op on first page - X button closes notebook back to list - Binder dropdown: "View all pages" and "Go to page" dialog - Page list shows stroke previews and page numbers below cards Line tool: - LINE tool: drag start-to-end for straight lines - Long-press Line chip for style variants: plain, arrow, double arrow, dashed - Arrow heads rendered as path segments, stored in stroke data - Dashed lines use DashPathEffect Export: - JPG export at full 300 DPI resolution - Export dropdown: PDF or JPG options - Refactored PdfExporter with shared drawStrokes helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../engpad/data/db/EngPadDatabase.kt | 14 +- .../engpad/data/db/NotebookDao.kt | 3 + .../metacircular/engpad/data/db/PageDao.kt | 3 + .../engpad/data/model/Notebook.kt | 1 + .../data/repository/NotebookRepository.kt | 4 + .../engpad/data/repository/PageRepository.kt | 3 + .../engpad/ui/editor/CanvasState.kt | 12 +- .../engpad/ui/editor/EditorScreen.kt | 49 +++- .../engpad/ui/editor/EditorViewModel.kt | 89 +++++++- .../engpad/ui/editor/PadCanvasView.kt | 211 +++++++++++++++++- .../metacircular/engpad/ui/editor/Toolbar.kt | 186 ++++++++++++++- .../engpad/ui/export/PdfExporter.kt | 61 +++-- .../engpad/ui/navigation/NavGraph.kt | 87 ++++++-- .../engpad/ui/pages/PageListScreen.kt | 81 +++++-- 14 files changed, 727 insertions(+), 77 deletions(-) diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt b/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt index f6eb13e..e4e65eb 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/db/EngPadDatabase.kt @@ -4,13 +4,15 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import net.metacircular.engpad.data.model.Notebook import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.Stroke @Database( entities = [Notebook::class, Page::class, Stroke::class], - version = 1, + version = 2, exportSchema = false, ) abstract class EngPadDatabase : RoomDatabase() { @@ -22,13 +24,21 @@ abstract class EngPadDatabase : RoomDatabase() { @Volatile private var instance: EngPadDatabase? = null + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE notebooks ADD COLUMN last_page_id INTEGER NOT NULL DEFAULT 0" + ) + } + } + fun getInstance(context: Context): EngPadDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, EngPadDatabase::class.java, "engpad.db", - ).build().also { instance = it } + ).addMigrations(MIGRATION_1_2).build().also { instance = it } } } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/db/NotebookDao.kt b/app/src/main/kotlin/net/metacircular/engpad/data/db/NotebookDao.kt index 855d1b5..e7eb1b8 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/db/NotebookDao.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/db/NotebookDao.kt @@ -23,4 +23,7 @@ interface NotebookDao { @Query("DELETE FROM notebooks WHERE id = :id") suspend fun deleteById(id: Long) + + @Query("UPDATE notebooks SET last_page_id = :pageId, updated_at = :updatedAt WHERE id = :id") + suspend fun updateLastPage(id: Long, pageId: Long, updatedAt: Long) } diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt b/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt index 7db43df..3933b4e 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt @@ -22,4 +22,7 @@ interface PageDao { @Query("DELETE FROM pages WHERE id = :id") suspend fun deleteById(id: Long) + + @Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC") + suspend fun getByNotebookIdList(notebookId: Long): List } diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/model/Notebook.kt b/app/src/main/kotlin/net/metacircular/engpad/data/model/Notebook.kt index 7a0ff87..3e378b3 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/model/Notebook.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/model/Notebook.kt @@ -11,4 +11,5 @@ data class Notebook( @ColumnInfo(name = "page_size") val pageSize: String, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, + @ColumnInfo(name = "last_page_id", defaultValue = "0") val lastPageId: Long = 0, ) diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/repository/NotebookRepository.kt b/app/src/main/kotlin/net/metacircular/engpad/data/repository/NotebookRepository.kt index 527d5e3..bac2319 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/repository/NotebookRepository.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/repository/NotebookRepository.kt @@ -39,6 +39,10 @@ class NotebookRepository( suspend fun delete(id: Long) = notebookDao.deleteById(id) + suspend fun updateLastPage(notebookId: Long, pageId: Long) { + notebookDao.updateLastPage(notebookId, pageId, System.currentTimeMillis()) + } + suspend fun updateTitle(id: Long, title: String) { val notebook = notebookDao.getById(id) ?: return notebookDao.update( diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt b/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt index 0d542cd..8f065f9 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt @@ -13,6 +13,9 @@ class PageRepository( fun getPages(notebookId: Long): Flow> = pageDao.getByNotebookId(notebookId) + suspend fun getPagesList(notebookId: Long): List = + pageDao.getByNotebookIdList(notebookId) + suspend fun getById(id: Long): Page? = pageDao.getById(id) /** 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 index a04dfb7..861eede 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/CanvasState.kt @@ -8,11 +8,20 @@ enum class Tool { ERASER, SELECT, BOX, // Draw rectangles + LINE, // Draw straight lines (with style variants) +} + +enum class LineStyle { + PLAIN, + ARROW, // Single arrow at end + DOUBLE_ARROW, // Arrows at both ends + DASHED, } data class CanvasState( val pageSize: PageSize = PageSize.REGULAR, val tool: Tool = Tool.PEN_FINE, + val lineStyle: LineStyle = LineStyle.PLAIN, val zoom: Float = 1f, val panX: Float = 0f, val panY: Float = 0f, @@ -21,7 +30,8 @@ data class CanvasState( get() = when (tool) { Tool.PEN_FINE -> 4.49f Tool.PEN_MEDIUM -> 5.91f - Tool.BOX -> 4.49f // Box uses fine pen width + Tool.BOX -> 4.49f + Tool.LINE -> 4.49f else -> 0f } 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 index 60145e8..916c62b 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorScreen.kt @@ -17,26 +17,37 @@ import net.metacircular.engpad.data.db.EngPadDatabase import net.metacircular.engpad.data.db.toFloatArray import net.metacircular.engpad.data.model.Page 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.export.PdfExporter @Composable fun EditorScreen( - pageId: Long, + notebookId: Long, pageSize: PageSize, + initialPageId: Long, database: EngPadDatabase, + onClose: () -> Unit, + onViewAllPages: () -> Unit, ) { - val repository = remember { + val pageRepository = remember { PageRepository(database.pageDao(), database.strokeDao()) } + val notebookRepository = remember { + NotebookRepository(database.notebookDao(), database.pageDao()) + } val viewModel: EditorViewModel = viewModel( - factory = EditorViewModel.Factory(pageId, pageSize, repository), + factory = EditorViewModel.Factory( + notebookId, pageSize, pageRepository, notebookRepository, initialPageId, + ), ) val canvasState by viewModel.canvasState.collectAsState() val strokes by viewModel.strokes.collectAsState() val canUndo by viewModel.undoManager.canUndo.collectAsState() val canRedo by viewModel.undoManager.canRedo.collectAsState() val hasSelection by viewModel.hasSelection.collectAsState() + val currentPageIndex by viewModel.currentPageIndex.collectAsState() + val pages by viewModel.pages.collectAsState() val context = LocalContext.current val canvasView = remember { PadCanvasView(context) } @@ -68,7 +79,12 @@ fun EditorScreen( canvasView.onSelectionMoved = { ids, dx, dy -> viewModel.moveSelection(dx, dy) } - // Wire undo/redo visual callbacks + canvasView.onEdgeSwipe = { direction -> + when (direction) { + PadCanvasView.SwipeDirection.LEFT -> viewModel.navigateNext() + PadCanvasView.SwipeDirection.RIGHT -> viewModel.navigatePrev() + } + } viewModel.onStrokeAdded = { stroke -> canvasView.addCompletedStroke( stroke.id, stroke.penSize, stroke.color, @@ -100,16 +116,35 @@ fun EditorScreen( viewModel.copySelection() canvasView.clearSelection() }, - onExport = { - val page = Page(id = pageId, notebookId = 0, pageNumber = 1, createdAt = 0) + onExportPdf = { + val currentPage = pages.getOrNull(currentPageIndex) ?: return@EditorToolbar val file = PdfExporter.exportPages( context = context, notebookTitle = "eng-pad-export", pageSize = pageSize, - pages = listOf(page to strokes), + pages = listOf(currentPage to strokes), ) PdfExporter.shareFile(context, file) }, + onExportJpg = { + val file = PdfExporter.exportPageAsJpg( + context = context, + notebookTitle = "eng-pad-export", + pageSize = pageSize, + pageNumber = currentPageIndex + 1, + strokes = strokes, + ) + PdfExporter.shareFile(context, file, "image/jpeg") + }, + onClose = onClose, + currentPageNumber = currentPageIndex + 1, + totalPages = pages.size, + onViewAllPages = onViewAllPages, + onGoToPage = { pageNum -> + viewModel.navigateToPage(pageNum - 1) + }, + lineStyle = canvasState.lineStyle, + onLineStyleChanged = { viewModel.setLineStyle(it) }, modifier = Modifier.fillMaxWidth(), ) AndroidView( 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 index 50719ec..4951db7 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/EditorViewModel.kt @@ -8,8 +8,10 @@ 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.Page import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.Stroke +import net.metacircular.engpad.data.repository.NotebookRepository import net.metacircular.engpad.data.repository.PageRepository import net.metacircular.engpad.undo.AddStrokeAction import net.metacircular.engpad.undo.CopyStrokesAction @@ -19,9 +21,11 @@ import net.metacircular.engpad.undo.MoveStrokesAction import net.metacircular.engpad.undo.UndoManager class EditorViewModel( - private val pageId: Long, + private val notebookId: Long, private val pageSize: PageSize, private val pageRepository: PageRepository, + private val notebookRepository: NotebookRepository, + initialPageId: Long, ) : ViewModel() { private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize)) @@ -36,25 +40,90 @@ class EditorViewModel( val hasSelection: StateFlow = _hasSelection private var selectedIds = emptySet() - // Callbacks for the canvas view to add/remove strokes visually + // Page navigation + private val _pages = MutableStateFlow>(emptyList()) + val pages: StateFlow> = _pages + + private val _currentPageIndex = MutableStateFlow(0) + val currentPageIndex: StateFlow = _currentPageIndex + + private val _currentPageId = MutableStateFlow(initialPageId) + val currentPageId: StateFlow = _currentPageId + + val pageCount: Int get() = _pages.value.size + + // Callbacks for the canvas view var onStrokeAdded: ((Stroke) -> Unit)? = null var onStrokeRemoved: ((Long) -> Unit)? = null var onStrokesChanged: (() -> Unit)? = null init { - loadStrokes() + viewModelScope.launch { + loadPages() + // Find initial page index + val idx = _pages.value.indexOfFirst { it.id == initialPageId } + if (idx >= 0) { + _currentPageIndex.value = idx + _currentPageId.value = _pages.value[idx].id + } + loadStrokes() + } } - private fun loadStrokes() { + private suspend fun loadPages() { + _pages.value = pageRepository.getPagesList(notebookId) + } + + private suspend fun loadStrokes() { + _strokes.value = pageRepository.getStrokes(_currentPageId.value) + } + + fun navigateToPage(index: Int) { + val pages = _pages.value + if (index < 0 || index >= pages.size) return viewModelScope.launch { - _strokes.value = pageRepository.getStrokes(pageId) + // Save current page state + undoManager.clear() + _currentPageIndex.value = index + _currentPageId.value = pages[index].id + notebookRepository.updateLastPage(notebookId, pages[index].id) + _canvasState.value = _canvasState.value.copy(zoom = 1f, panX = 0f, panY = 0f) + clearSelection() + loadStrokes() + onStrokesChanged?.invoke() } } + fun navigateNext() { + val nextIndex = _currentPageIndex.value + 1 + if (nextIndex < _pages.value.size) { + navigateToPage(nextIndex) + } else { + // Add a new page and navigate to it + viewModelScope.launch { + pageRepository.addPage(notebookId) + loadPages() + navigateToPage(_pages.value.size - 1) + } + } + } + + fun navigatePrev() { + val prevIndex = _currentPageIndex.value - 1 + if (prevIndex >= 0) { + navigateToPage(prevIndex) + } + // No-op on first page + } + fun setTool(tool: Tool) { _canvasState.value = _canvasState.value.copy(tool = tool) } + fun setLineStyle(style: LineStyle) { + _canvasState.value = _canvasState.value.copy(lineStyle = style) + } + fun onStrokeErased(strokeId: Long) { val stroke = _strokes.value.find { it.id == strokeId } ?: return viewModelScope.launch { @@ -81,6 +150,7 @@ class EditorViewModel( fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) { viewModelScope.launch { + val pageId = _currentPageId.value val order = pageRepository.getNextStrokeOrder(pageId) val stroke = Stroke( pageId = pageId, @@ -138,6 +208,7 @@ class EditorViewModel( val toCopy = _strokes.value.filter { it.id in selectedIds } if (toCopy.isEmpty()) return viewModelScope.launch { + val pageId = _currentPageId.value undoManager.perform( CopyStrokesAction( strokes = toCopy, @@ -199,13 +270,17 @@ class EditorViewModel( } class Factory( - private val pageId: Long, + private val notebookId: Long, private val pageSize: PageSize, private val pageRepository: PageRepository, + private val notebookRepository: NotebookRepository, + private val initialPageId: Long, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - return EditorViewModel(pageId, pageSize, pageRepository) as T + return EditorViewModel( + notebookId, pageSize, pageRepository, notebookRepository, initialPageId, + ) 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 index fc10016..8e16589 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/PadCanvasView.kt @@ -58,6 +58,13 @@ class PadCanvasView(context: Context) : View(context) { private var boxEndY = 0f private var isDrawingBox = false + // --- Line drawing --- + private var lineStartX = 0f + private var lineStartY = 0f + private var lineEndX = 0f + private var lineEndY = 0f + private var isDrawingLine = false + // --- Selection --- private var selectionStartX = 0f private var selectionStartY = 0f @@ -79,6 +86,9 @@ class PadCanvasView(context: Context) : View(context) { var onStrokeErased: ((strokeId: Long) -> Unit)? = null var onSelectionComplete: ((selectedIds: Set) -> Unit)? = null var onSelectionMoved: ((selectedIds: Set, deltaX: Float, deltaY: Float) -> Unit)? = null + var onEdgeSwipe: ((SwipeDirection) -> Unit)? = null + + enum class SwipeDirection { LEFT, RIGHT } // --- Zoom/pan state --- private var zoom = 1f @@ -88,6 +98,11 @@ class PadCanvasView(context: Context) : View(context) { private var lastTouchY = 0f private var activePointerId = MotionEvent.INVALID_POINTER_ID + // --- Edge swipe detection --- + private var fingerDownX = 0f + private var fingerDownY = 0f + private var isEdgeSwipe = false + private val scaleGestureDetector = ScaleGestureDetector( context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { @@ -148,6 +163,20 @@ class PadCanvasView(context: Context) : View(context) { isAntiAlias = false } + // --- Line preview paint --- + private val linePreviewPaint = Paint().apply { + color = Color.BLACK + style = Paint.Style.STROKE + isAntiAlias = false + } + + private val dashedLinePaint = Paint().apply { + color = Color.BLACK + style = Paint.Style.STROKE + isAntiAlias = false + pathEffect = android.graphics.DashPathEffect(floatArrayOf(30f, 20f), 0f) + } + init { setBackgroundColor(Color.WHITE) } @@ -224,6 +253,20 @@ class PadCanvasView(context: Context) : View(context) { drawRect(left, top, right, bottom, boxPreviewPaint) } + // Line preview + if (isDrawingLine) { + val paint = if (canvasState.lineStyle == LineStyle.DASHED) { + dashedLinePaint.also { it.strokeWidth = canvasState.penWidthPt } + } else { + linePreviewPaint.also { it.strokeWidth = canvasState.penWidthPt } + } + drawLine(lineStartX, lineStartY, lineEndX, lineEndY, paint) + drawArrowHeads( + this, lineStartX, lineStartY, lineEndX, lineEndY, + canvasState.lineStyle, canvasState.penWidthPt, + ) + } + // Selection highlights if (selectedStrokeIds.isNotEmpty()) { for (sr in completedStrokes) { @@ -320,6 +363,9 @@ class PadCanvasView(context: Context) : View(context) { if (canvasState.tool == Tool.BOX) { return handleBoxInput(event) } + if (canvasState.tool == Tool.LINE) { + return handleLineInput(event) + } when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { @@ -455,6 +501,125 @@ class PadCanvasView(context: Context) : View(context) { return true } + // --- Line drawing --- + + private fun handleLineInput(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val pt = screenToCanonical(event.x, event.y) + lineStartX = pt[0] + lineStartY = pt[1] + lineEndX = pt[0] + lineEndY = pt[1] + isDrawingLine = true + invalidate() + return true + } + MotionEvent.ACTION_MOVE -> { + val pt = screenToCanonical(event.x, event.y) + lineEndX = pt[0] + lineEndY = pt[1] + invalidate() + return true + } + MotionEvent.ACTION_UP -> { + isDrawingLine = false + // Build the line stroke as points + val points = buildLinePoints( + lineStartX, lineStartY, lineEndX, lineEndY, + canvasState.lineStyle, + ) + onStrokeCompleted?.invoke(canvasState.penWidthPt, Color.BLACK, points) + invalidate() + return true + } + } + return true + } + + /** + * Build points for a line stroke. For plain/dashed, just two endpoints. + * Arrow heads are encoded as extra path segments. + */ + private fun buildLinePoints( + x1: Float, y1: Float, x2: Float, y2: Float, style: LineStyle, + ): FloatArray { + val points = mutableListOf() + points.add(x1); points.add(y1) + points.add(x2); points.add(y2) + + val arrowLen = 40f // Arrow head length in canonical points + val arrowAngle = Math.toRadians(25.0) + val dx = (x2 - x1).toDouble() + val dy = (y2 - y1).toDouble() + val angle = Math.atan2(dy, dx) + + if (style == LineStyle.ARROW || style == LineStyle.DOUBLE_ARROW) { + // Arrow at end (x2, y2) + val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat() + val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat() + val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat() + val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat() + // Draw as: tip -> left wing, then move back to tip -> right wing + points.add(ax1); points.add(ay1) + points.add(x2); points.add(y2) + points.add(ax2); points.add(ay2) + } + if (style == LineStyle.DOUBLE_ARROW) { + // Arrow at start (x1, y1) + val revAngle = angle + Math.PI + val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat() + val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat() + val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat() + val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat() + points.add(x1); points.add(y1) + points.add(bx1); points.add(by1) + points.add(x1); points.add(y1) + points.add(bx2); points.add(by2) + } + + return points.toFloatArray() + } + + private fun drawArrowHeads( + canvas: Canvas, x1: Float, y1: Float, x2: Float, y2: Float, + style: LineStyle, strokeWidth: Float, + ) { + if (style != LineStyle.ARROW && style != LineStyle.DOUBLE_ARROW) return + + val paint = Paint().apply { + color = Color.BLACK + this.strokeWidth = strokeWidth + this.style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + isAntiAlias = false + } + + val arrowLen = 40f + val arrowAngle = Math.toRadians(25.0) + val dx = (x2 - x1).toDouble() + val dy = (y2 - y1).toDouble() + val angle = Math.atan2(dy, dx) + + // End arrow + val ax1 = x2 - (arrowLen * Math.cos(angle - arrowAngle)).toFloat() + val ay1 = y2 - (arrowLen * Math.sin(angle - arrowAngle)).toFloat() + val ax2 = x2 - (arrowLen * Math.cos(angle + arrowAngle)).toFloat() + val ay2 = y2 - (arrowLen * Math.sin(angle + arrowAngle)).toFloat() + canvas.drawLine(x2, y2, ax1, ay1, paint) + canvas.drawLine(x2, y2, ax2, ay2, paint) + + if (style == LineStyle.DOUBLE_ARROW) { + val revAngle = angle + Math.PI + val bx1 = x1 - (arrowLen * Math.cos(revAngle - arrowAngle)).toFloat() + val by1 = y1 - (arrowLen * Math.sin(revAngle - arrowAngle)).toFloat() + val bx2 = x1 - (arrowLen * Math.cos(revAngle + arrowAngle)).toFloat() + val by2 = y1 - (arrowLen * Math.sin(revAngle + arrowAngle)).toFloat() + canvas.drawLine(x1, y1, bx1, by1, paint) + canvas.drawLine(x1, y1, bx2, by2, paint) + } + } + private fun handleFingerInput(event: MotionEvent): Boolean { scaleGestureDetector.onTouchEvent(event) @@ -463,27 +628,55 @@ class PadCanvasView(context: Context) : View(context) { activePointerId = event.getPointerId(0) lastTouchX = event.x lastTouchY = event.y + fingerDownX = event.x + fingerDownY = event.y + // Detect if finger started at screen edge + val edgeZone = width * EDGE_ZONE_FRACTION + isEdgeSwipe = event.x < edgeZone || event.x > width - edgeZone } MotionEvent.ACTION_MOVE -> { - if (!scaleGestureDetector.isInProgress) { + if (!scaleGestureDetector.isInProgress && event.pointerCount == 1) { val pointerIndex = event.findPointerIndex(activePointerId) if (pointerIndex >= 0) { val x = event.getX(pointerIndex) val y = event.getY(pointerIndex) - panX += x - lastTouchX - panY += y - lastTouchY + if (!isEdgeSwipe) { + panX += x - lastTouchX + panY += y - lastTouchY + rebuildViewMatrix() + invalidate() + } lastTouchX = x lastTouchY = y - rebuildViewMatrix() - invalidate() } } } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + MotionEvent.ACTION_UP -> { + if (isEdgeSwipe && event.pointerCount == 1) { + val dx = event.x - fingerDownX + val dy = event.y - fingerDownY + val absDx = Math.abs(dx) + val absDy = Math.abs(dy) + if (absDx > EDGE_SWIPE_MIN_PX && absDx > absDy * 2) { + // Horizontal swipe detected + if (dx < 0) { + onEdgeSwipe?.invoke(SwipeDirection.LEFT) + } else { + onEdgeSwipe?.invoke(SwipeDirection.RIGHT) + } + } + } + isEdgeSwipe = false + activePointerId = MotionEvent.INVALID_POINTER_ID + onZoomPanChanged?.invoke(zoom, panX, panY) + } + MotionEvent.ACTION_CANCEL -> { + isEdgeSwipe = false activePointerId = MotionEvent.INVALID_POINTER_ID onZoomPanChanged?.invoke(zoom, panX, panY) } MotionEvent.ACTION_POINTER_DOWN -> { + isEdgeSwipe = false // Multi-finger = not an edge swipe val newIndex = event.actionIndex lastTouchX = event.getX(newIndex) lastTouchY = event.getY(newIndex) @@ -732,5 +925,11 @@ class PadCanvasView(context: Context) : View(context) { /** Max distance from origin (canonical pts) before snap is canceled (~5mm). */ private const val LINE_SNAP_MOVE_THRESHOLD = 60f + + /** Fraction of screen width that counts as the edge zone for swipes. */ + private const val EDGE_ZONE_FRACTION = 0.08f + + /** Minimum horizontal pixels for an edge swipe to register. */ + private const val EDGE_SWIPE_MIN_PX = 100f } } 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 index 45711d7..d35ff26 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/editor/Toolbar.kt @@ -4,12 +4,24 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @Composable @@ -23,13 +35,54 @@ fun EditorToolbar( hasSelection: Boolean, onDeleteSelection: () -> Unit, onCopySelection: () -> Unit, - onExport: () -> Unit, + onExportPdf: () -> Unit, + onExportJpg: () -> Unit, + onClose: () -> Unit, + currentPageNumber: Int, + totalPages: Int, + onViewAllPages: () -> Unit, + onGoToPage: (Int) -> Unit, + lineStyle: LineStyle, + onLineStyleChanged: (LineStyle) -> Unit, modifier: Modifier = Modifier, ) { + var showBinderMenu by remember { mutableStateOf(false) } + var showGoToPageDialog by remember { mutableStateOf(false) } + Row( modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Close button + TextButton(onClick = onClose) { Text("X") } + + // Binder dropdown + TextButton(onClick = { showBinderMenu = true }) { + Text("p$currentPageNumber/$totalPages") + } + DropdownMenu( + expanded = showBinderMenu, + onDismissRequest = { showBinderMenu = false }, + ) { + DropdownMenuItem( + text = { Text("View all pages") }, + onClick = { + showBinderMenu = false + onViewAllPages() + }, + ) + DropdownMenuItem( + text = { Text("Go to page...") }, + onClick = { + showBinderMenu = false + showGoToPageDialog = true + }, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Tool chips FilterChip( selected = currentTool == Tool.PEN_FINE, onClick = { onToolSelected(Tool.PEN_FINE) }, @@ -60,12 +113,38 @@ fun EditorToolbar( label = { Text("Box") }, modifier = Modifier.padding(end = 4.dp), ) + LineToolChip( + selected = currentTool == Tool.LINE, + lineStyle = lineStyle, + onSelect = { onToolSelected(Tool.LINE) }, + onStyleChanged = onLineStyleChanged, + ) if (hasSelection) { TextButton(onClick = onDeleteSelection) { Text("Del") } TextButton(onClick = onCopySelection) { Text("Copy") } } Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onExport) { Text("PDF") } + var showExportMenu by remember { mutableStateOf(false) } + TextButton(onClick = { showExportMenu = true }) { Text("Export") } + DropdownMenu( + expanded = showExportMenu, + onDismissRequest = { showExportMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Export as PDF") }, + onClick = { + showExportMenu = false + onExportPdf() + }, + ) + DropdownMenuItem( + text = { Text("Export as JPG") }, + onClick = { + showExportMenu = false + onExportJpg() + }, + ) + } TextButton(onClick = onUndo, enabled = canUndo) { Text("Undo") } @@ -74,4 +153,107 @@ fun EditorToolbar( Text("Redo") } } + + if (showGoToPageDialog) { + GoToPageDialog( + totalPages = totalPages, + onDismiss = { showGoToPageDialog = false }, + onGoTo = { pageNum -> + showGoToPageDialog = false + onGoToPage(pageNum) + }, + ) + } +} + +@Composable +private fun GoToPageDialog( + totalPages: Int, + onDismiss: () -> Unit, + onGoTo: (Int) -> Unit, +) { + var text by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Go to page") }, + text = { + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = null + }, + label = { Text("Page (1\u2013$totalPages)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + ) + }, + confirmButton = { + TextButton(onClick = { + val num = text.toIntOrNull() + if (num == null || num < 1 || num > totalPages) { + error = "Enter a number between 1 and $totalPages" + } else { + onGoTo(num) + } + }) { Text("Go") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LineToolChip( + selected: Boolean, + lineStyle: LineStyle, + onSelect: () -> Unit, + onStyleChanged: (LineStyle) -> Unit, +) { + var showStyleMenu by remember { mutableStateOf(false) } + val label = when (lineStyle) { + LineStyle.PLAIN -> "Line" + LineStyle.ARROW -> "Arrow" + LineStyle.DOUBLE_ARROW -> "\u2194" // ↔ + LineStyle.DASHED -> "Dash" + } + + FilterChip( + selected = selected, + onClick = { onSelect() }, + label = { Text(label) }, + modifier = Modifier + .padding(end = 4.dp) + .combinedClickable( + onClick = { onSelect() }, + onLongClick = { showStyleMenu = true }, + ), + ) + DropdownMenu( + expanded = showStyleMenu, + onDismissRequest = { showStyleMenu = false }, + ) { + LineStyle.entries.forEach { style -> + val styleName = when (style) { + LineStyle.PLAIN -> "Plain line" + LineStyle.ARROW -> "Single arrow \u2192" + LineStyle.DOUBLE_ARROW -> "Double arrow \u2194" + LineStyle.DASHED -> "Dashed line" + } + DropdownMenuItem( + text = { Text(styleName) }, + onClick = { + showStyleMenu = false + onStyleChanged(style) + onSelect() + }, + ) + } + } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt index 74e1036..b5f1274 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/export/PdfExporter.kt @@ -2,8 +2,10 @@ package net.metacircular.engpad.ui.export import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color +import androidx.core.graphics.createBitmap import android.graphics.Paint import android.graphics.Path import android.graphics.pdf.PdfDocument @@ -14,10 +16,6 @@ import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.Stroke import java.io.File -/** - * Exports pages to PDF. Coordinates are in 300 DPI canonical space and - * scaled to 72 DPI (PDF points) during export via a scale factor of 0.24. - */ object PdfExporter { private const val CANONICAL_TO_PDF = 72f / 300f // 0.24 @@ -39,23 +37,15 @@ object PdfExporter { val pdfPage = pdfDoc.startPage(pageInfo) val canvas = pdfPage.canvas - // Scale from canonical (300 DPI) to PDF (72 DPI) canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF) - - // Draw strokes (no grid) - for (stroke in strokes) { - val points = stroke.pointData.toFloatArray() - val path = buildPath(points) - val paint = buildPaint(stroke.penSize, stroke.color) - canvas.drawPath(path, paint) - } + drawStrokes(canvas, strokes) pdfDoc.finishPage(pdfPage) } val exportDir = File(context.cacheDir, "exports") exportDir.mkdirs() - val sanitizedTitle = notebookTitle.replace(Regex("[^a-zA-Z0-9._-]"), "_") + val sanitizedTitle = sanitize(notebookTitle) val file = File(exportDir, "$sanitizedTitle.pdf") file.outputStream().use { pdfDoc.writeTo(it) } pdfDoc.close() @@ -63,18 +53,52 @@ object PdfExporter { return file } - fun shareFile(context: Context, file: File) { + fun exportPageAsJpg( + context: Context, + notebookTitle: String, + pageSize: PageSize, + pageNumber: Int, + strokes: List, + ): File { + // Render at 300 DPI (full canonical resolution) + val bitmap = createBitmap(pageSize.widthPt, pageSize.heightPt) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.WHITE) + drawStrokes(canvas, strokes) + + val exportDir = File(context.cacheDir, "exports") + exportDir.mkdirs() + val sanitizedTitle = sanitize(notebookTitle) + val file = File(exportDir, "${sanitizedTitle}_p${pageNumber}.jpg") + file.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) + } + bitmap.recycle() + + return file + } + + fun shareFile(context: Context, file: File, mimeType: String = "application/pdf") { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file, ) val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/pdf" + type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - context.startActivity(Intent.createChooser(intent, "Export PDF")) + context.startActivity(Intent.createChooser(intent, "Export")) + } + + private fun drawStrokes(canvas: Canvas, strokes: List) { + for (stroke in strokes) { + val points = stroke.pointData.toFloatArray() + val path = buildPath(points) + val paint = buildPaint(stroke.penSize, stroke.color) + canvas.drawPath(path, paint) + } } private fun buildPath(points: FloatArray): Path { @@ -99,4 +123,7 @@ object PdfExporter { isAntiAlias = true } } + + private fun sanitize(name: String): String = + name.replace(Regex("[^a-zA-Z0-9._-]"), "_") } 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 2bed526..e0a418a 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 @@ -2,6 +2,7 @@ package net.metacircular.engpad.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -14,17 +15,18 @@ 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 import net.metacircular.engpad.ui.pages.PageListScreen object Routes { const val NOTEBOOKS = "notebooks" + const val EDITOR = "editor/{notebookId}" const val PAGES = "pages/{notebookId}" - const val EDITOR = "editor/{pageId}/{pageSize}" + fun editor(notebookId: Long) = "editor/$notebookId" fun pages(notebookId: Long) = "pages/$notebookId" - fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}" } @Composable @@ -37,10 +39,65 @@ fun EngPadNavGraph( NotebookListScreen( database = database, onNotebookClick = { notebookId -> - navController.navigate(Routes.pages(notebookId)) + navController.navigate(Routes.editor(notebookId)) }, ) } + composable( + route = Routes.EDITOR, + arguments = listOf(navArgument("notebookId") { type = NavType.LongType }), + ) { backStackEntry -> + val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable + val notebookRepo = remember { + NotebookRepository(database.notebookDao(), database.pageDao()) + } + val pageRepo = remember { + PageRepository(database.pageDao(), database.strokeDao()) + } + + var pageSize by remember { mutableStateOf(null) } + var initialPageId by remember { mutableStateOf(null) } + + LaunchedEffect(notebookId) { + val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect + pageSize = PageSize.fromString(notebook.pageSize) + // Use last page or fall back to first page + val pages = pageRepo.getPagesList(notebookId) + initialPageId = if (notebook.lastPageId > 0 && pages.any { it.id == notebook.lastPageId }) { + notebook.lastPageId + } else { + pages.firstOrNull()?.id ?: return@LaunchedEffect + } + } + + // Observe page selection from the page list screen + val savedState = backStackEntry.savedStateHandle + val selectedPageId by savedState.getStateFlow("selectedPageId", 0L) + .collectAsState() + LaunchedEffect(selectedPageId) { + if (selectedPageId > 0) { + initialPageId = selectedPageId + savedState["selectedPageId"] = 0L + } + } + + val ps = pageSize + val pid = initialPageId + if (ps != null && pid != null) { + EditorScreen( + notebookId = notebookId, + pageSize = ps, + initialPageId = pid, + database = database, + onClose = { + navController.popBackStack(Routes.NOTEBOOKS, false) + }, + onViewAllPages = { + navController.navigate(Routes.pages(notebookId)) + }, + ) + } + } composable( route = Routes.PAGES, arguments = listOf(navArgument("notebookId") { type = NavType.LongType }), @@ -68,27 +125,15 @@ fun EngPadNavGraph( notebookTitle = notebookTitle, pageSize = pageSize, database = database, - onPageClick = { pageId, ps -> - navController.navigate(Routes.editor(pageId, ps)) + onPageClick = { pageId, _ -> + // Pass selected page ID back to the editor + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("selectedPageId", pageId) + navController.popBackStack() }, ) } } - composable( - route = Routes.EDITOR, - arguments = listOf( - navArgument("pageId") { type = NavType.LongType }, - navArgument("pageSize") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable - val pageSizeStr = backStackEntry.arguments?.getString("pageSize") ?: return@composable - val pageSize = PageSize.fromString(pageSizeStr) - EditorScreen( - pageId = pageId, - pageSize = pageSize, - database = database, - ) - } } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt index 276f060..071c023 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt @@ -1,8 +1,11 @@ package net.metacircular.engpad.ui.pages +import android.graphics.Color as AndroidColor +import android.graphics.Paint as AndroidPaint +import android.graphics.Path as AndroidPath +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -19,16 +22,24 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.core.graphics.withScale import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import net.metacircular.engpad.data.db.EngPadDatabase +import net.metacircular.engpad.data.db.toFloatArray import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.PageSize +import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.repository.PageRepository @OptIn(ExperimentalMaterial3Api::class) @@ -82,7 +93,9 @@ fun PageListScreen( items(pages, key = { it.id }) { page -> PageThumbnail( page = page, + pageSize = pageSize, aspectRatio = aspectRatio, + repository = repository, onClick = { onPageClick(page.id, pageSize) }, ) } @@ -94,24 +107,64 @@ fun PageListScreen( @Composable private fun PageThumbnail( page: Page, + pageSize: PageSize, aspectRatio: Float, + repository: PageRepository, onClick: () -> Unit, ) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), + var strokes by remember(page.id) { mutableStateOf>(emptyList()) } + + LaunchedEffect(page.id) { + strokes = repository.getStrokes(page.id) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable(onClick = onClick), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(aspectRatio), - contentAlignment = Alignment.Center, + Card( + modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "Page ${page.pageNumber}", - style = MaterialTheme.typography.bodyMedium, - ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio), + ) { + val scaleX = size.width / pageSize.widthPt.toFloat() + val scaleY = size.height / pageSize.heightPt.toFloat() + val scale = minOf(scaleX, scaleY) + + drawIntoCanvas { canvas -> + val nativeCanvas = canvas.nativeCanvas + nativeCanvas.withScale(scale, scale) { + for (stroke in strokes) { + val points = stroke.pointData.toFloatArray() + if (points.size < 2) continue + val path = AndroidPath() + path.moveTo(points[0], points[1]) + var i = 2 + while (i < points.size - 1) { + path.lineTo(points[i], points[i + 1]) + i += 2 + } + val paint = AndroidPaint().apply { + color = stroke.color + strokeWidth = stroke.penSize + style = AndroidPaint.Style.STROKE + strokeCap = AndroidPaint.Cap.ROUND + strokeJoin = AndroidPaint.Join.ROUND + isAntiAlias = false + } + drawPath(path, paint) + } + } + } + } } + Text( + text = "Page ${page.pageNumber}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) } }