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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 15:30:51 -07:00
parent 85a210c001
commit e81dd60f30
14 changed files with 727 additions and 77 deletions

View File

@@ -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 }
}
}
}

View File

@@ -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)
}

View File

@@ -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<Page>
}

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -13,6 +13,9 @@ class PageRepository(
fun getPages(notebookId: Long): Flow<List<Page>> =
pageDao.getByNotebookId(notebookId)
suspend fun getPagesList(notebookId: Long): List<Page> =
pageDao.getByNotebookIdList(notebookId)
suspend fun getById(id: Long): Page? = pageDao.getById(id)
/**

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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<Boolean> = _hasSelection
private var selectedIds = emptySet<Long>()
// Callbacks for the canvas view to add/remove strokes visually
// Page navigation
private val _pages = MutableStateFlow<List<Page>>(emptyList())
val pages: StateFlow<List<Page>> = _pages
private val _currentPageIndex = MutableStateFlow(0)
val currentPageIndex: StateFlow<Int> = _currentPageIndex
private val _currentPageId = MutableStateFlow(initialPageId)
val currentPageId: StateFlow<Long> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return EditorViewModel(pageId, pageSize, pageRepository) as T
return EditorViewModel(
notebookId, pageSize, pageRepository, notebookRepository, initialPageId,
) as T
}
}
}

View File

@@ -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<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, 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<Float>()
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
}
}

View File

@@ -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<String?>(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()
},
)
}
}
}

View File

@@ -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<Stroke>,
): 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<Stroke>) {
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._-]"), "_")
}

View File

@@ -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<PageSize?>(null) }
var initialPageId by remember { mutableStateOf<Long?>(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,
)
}
}
}

View File

@@ -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<List<Stroke>>(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),
)
}
}