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.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase 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.Notebook
import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.Page
import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.model.Stroke
@Database( @Database(
entities = [Notebook::class, Page::class, Stroke::class], entities = [Notebook::class, Page::class, Stroke::class],
version = 1, version = 2,
exportSchema = false, exportSchema = false,
) )
abstract class EngPadDatabase : RoomDatabase() { abstract class EngPadDatabase : RoomDatabase() {
@@ -22,13 +24,21 @@ abstract class EngPadDatabase : RoomDatabase() {
@Volatile @Volatile
private var instance: EngPadDatabase? = null 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 = fun getInstance(context: Context): EngPadDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
EngPadDatabase::class.java, EngPadDatabase::class.java,
"engpad.db", "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") @Query("DELETE FROM notebooks WHERE id = :id")
suspend fun deleteById(id: Long) 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") @Query("DELETE FROM pages WHERE id = :id")
suspend fun deleteById(id: Long) 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 = "page_size") val pageSize: String,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: 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 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) { suspend fun updateTitle(id: Long, title: String) {
val notebook = notebookDao.getById(id) ?: return val notebook = notebookDao.getById(id) ?: return
notebookDao.update( notebookDao.update(

View File

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

View File

@@ -8,11 +8,20 @@ enum class Tool {
ERASER, ERASER,
SELECT, SELECT,
BOX, // Draw rectangles 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( data class CanvasState(
val pageSize: PageSize = PageSize.REGULAR, val pageSize: PageSize = PageSize.REGULAR,
val tool: Tool = Tool.PEN_FINE, val tool: Tool = Tool.PEN_FINE,
val lineStyle: LineStyle = LineStyle.PLAIN,
val zoom: Float = 1f, val zoom: Float = 1f,
val panX: Float = 0f, val panX: Float = 0f,
val panY: Float = 0f, val panY: Float = 0f,
@@ -21,7 +30,8 @@ data class CanvasState(
get() = when (tool) { get() = when (tool) {
Tool.PEN_FINE -> 4.49f Tool.PEN_FINE -> 4.49f
Tool.PEN_MEDIUM -> 5.91f Tool.PEN_MEDIUM -> 5.91f
Tool.BOX -> 4.49f // Box uses fine pen width Tool.BOX -> 4.49f
Tool.LINE -> 4.49f
else -> 0f 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.db.toFloatArray
import net.metacircular.engpad.data.model.Page import net.metacircular.engpad.data.model.Page
import net.metacircular.engpad.data.model.PageSize 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.data.repository.PageRepository
import net.metacircular.engpad.ui.export.PdfExporter import net.metacircular.engpad.ui.export.PdfExporter
@Composable @Composable
fun EditorScreen( fun EditorScreen(
pageId: Long, notebookId: Long,
pageSize: PageSize, pageSize: PageSize,
initialPageId: Long,
database: EngPadDatabase, database: EngPadDatabase,
onClose: () -> Unit,
onViewAllPages: () -> Unit,
) { ) {
val repository = remember { val pageRepository = remember {
PageRepository(database.pageDao(), database.strokeDao()) PageRepository(database.pageDao(), database.strokeDao())
} }
val notebookRepository = remember {
NotebookRepository(database.notebookDao(), database.pageDao())
}
val viewModel: EditorViewModel = viewModel( 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 canvasState by viewModel.canvasState.collectAsState()
val strokes by viewModel.strokes.collectAsState() val strokes by viewModel.strokes.collectAsState()
val canUndo by viewModel.undoManager.canUndo.collectAsState() val canUndo by viewModel.undoManager.canUndo.collectAsState()
val canRedo by viewModel.undoManager.canRedo.collectAsState() val canRedo by viewModel.undoManager.canRedo.collectAsState()
val hasSelection by viewModel.hasSelection.collectAsState() val hasSelection by viewModel.hasSelection.collectAsState()
val currentPageIndex by viewModel.currentPageIndex.collectAsState()
val pages by viewModel.pages.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val canvasView = remember { PadCanvasView(context) } val canvasView = remember { PadCanvasView(context) }
@@ -68,7 +79,12 @@ fun EditorScreen(
canvasView.onSelectionMoved = { ids, dx, dy -> canvasView.onSelectionMoved = { ids, dx, dy ->
viewModel.moveSelection(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 -> viewModel.onStrokeAdded = { stroke ->
canvasView.addCompletedStroke( canvasView.addCompletedStroke(
stroke.id, stroke.penSize, stroke.color, stroke.id, stroke.penSize, stroke.color,
@@ -100,16 +116,35 @@ fun EditorScreen(
viewModel.copySelection() viewModel.copySelection()
canvasView.clearSelection() canvasView.clearSelection()
}, },
onExport = { onExportPdf = {
val page = Page(id = pageId, notebookId = 0, pageNumber = 1, createdAt = 0) val currentPage = pages.getOrNull(currentPageIndex) ?: return@EditorToolbar
val file = PdfExporter.exportPages( val file = PdfExporter.exportPages(
context = context, context = context,
notebookTitle = "eng-pad-export", notebookTitle = "eng-pad-export",
pageSize = pageSize, pageSize = pageSize,
pages = listOf(page to strokes), pages = listOf(currentPage to strokes),
) )
PdfExporter.shareFile(context, file) 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(), modifier = Modifier.fillMaxWidth(),
) )
AndroidView( AndroidView(

View File

@@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import net.metacircular.engpad.data.db.toBlob 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.PageSize
import net.metacircular.engpad.data.model.Stroke 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.data.repository.PageRepository
import net.metacircular.engpad.undo.AddStrokeAction import net.metacircular.engpad.undo.AddStrokeAction
import net.metacircular.engpad.undo.CopyStrokesAction import net.metacircular.engpad.undo.CopyStrokesAction
@@ -19,9 +21,11 @@ import net.metacircular.engpad.undo.MoveStrokesAction
import net.metacircular.engpad.undo.UndoManager import net.metacircular.engpad.undo.UndoManager
class EditorViewModel( class EditorViewModel(
private val pageId: Long, private val notebookId: Long,
private val pageSize: PageSize, private val pageSize: PageSize,
private val pageRepository: PageRepository, private val pageRepository: PageRepository,
private val notebookRepository: NotebookRepository,
initialPageId: Long,
) : ViewModel() { ) : ViewModel() {
private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize)) private val _canvasState = MutableStateFlow(CanvasState(pageSize = pageSize))
@@ -36,25 +40,90 @@ class EditorViewModel(
val hasSelection: StateFlow<Boolean> = _hasSelection val hasSelection: StateFlow<Boolean> = _hasSelection
private var selectedIds = emptySet<Long>() 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 onStrokeAdded: ((Stroke) -> Unit)? = null
var onStrokeRemoved: ((Long) -> Unit)? = null var onStrokeRemoved: ((Long) -> Unit)? = null
var onStrokesChanged: (() -> Unit)? = null var onStrokesChanged: (() -> Unit)? = null
init { init {
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() loadStrokes()
} }
private fun loadStrokes() {
viewModelScope.launch {
_strokes.value = pageRepository.getStrokes(pageId)
} }
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 {
// 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) { fun setTool(tool: Tool) {
_canvasState.value = _canvasState.value.copy(tool = tool) _canvasState.value = _canvasState.value.copy(tool = tool)
} }
fun setLineStyle(style: LineStyle) {
_canvasState.value = _canvasState.value.copy(lineStyle = style)
}
fun onStrokeErased(strokeId: Long) { fun onStrokeErased(strokeId: Long) {
val stroke = _strokes.value.find { it.id == strokeId } ?: return val stroke = _strokes.value.find { it.id == strokeId } ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -81,6 +150,7 @@ class EditorViewModel(
fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) { fun onStrokeCompleted(penSize: Float, color: Int, points: FloatArray) {
viewModelScope.launch { viewModelScope.launch {
val pageId = _currentPageId.value
val order = pageRepository.getNextStrokeOrder(pageId) val order = pageRepository.getNextStrokeOrder(pageId)
val stroke = Stroke( val stroke = Stroke(
pageId = pageId, pageId = pageId,
@@ -138,6 +208,7 @@ class EditorViewModel(
val toCopy = _strokes.value.filter { it.id in selectedIds } val toCopy = _strokes.value.filter { it.id in selectedIds }
if (toCopy.isEmpty()) return if (toCopy.isEmpty()) return
viewModelScope.launch { viewModelScope.launch {
val pageId = _currentPageId.value
undoManager.perform( undoManager.perform(
CopyStrokesAction( CopyStrokesAction(
strokes = toCopy, strokes = toCopy,
@@ -199,13 +270,17 @@ class EditorViewModel(
} }
class Factory( class Factory(
private val pageId: Long, private val notebookId: Long,
private val pageSize: PageSize, private val pageSize: PageSize,
private val pageRepository: PageRepository, private val pageRepository: PageRepository,
private val notebookRepository: NotebookRepository,
private val initialPageId: Long,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { 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 boxEndY = 0f
private var isDrawingBox = false 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 --- // --- Selection ---
private var selectionStartX = 0f private var selectionStartX = 0f
private var selectionStartY = 0f private var selectionStartY = 0f
@@ -79,6 +86,9 @@ class PadCanvasView(context: Context) : View(context) {
var onStrokeErased: ((strokeId: Long) -> Unit)? = null var onStrokeErased: ((strokeId: Long) -> Unit)? = null
var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null var onSelectionComplete: ((selectedIds: Set<Long>) -> Unit)? = null
var onSelectionMoved: ((selectedIds: Set<Long>, deltaX: Float, deltaY: Float) -> 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 --- // --- Zoom/pan state ---
private var zoom = 1f private var zoom = 1f
@@ -88,6 +98,11 @@ class PadCanvasView(context: Context) : View(context) {
private var lastTouchY = 0f private var lastTouchY = 0f
private var activePointerId = MotionEvent.INVALID_POINTER_ID 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( private val scaleGestureDetector = ScaleGestureDetector(
context, context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() { object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
@@ -148,6 +163,20 @@ class PadCanvasView(context: Context) : View(context) {
isAntiAlias = false 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 { init {
setBackgroundColor(Color.WHITE) setBackgroundColor(Color.WHITE)
} }
@@ -224,6 +253,20 @@ class PadCanvasView(context: Context) : View(context) {
drawRect(left, top, right, bottom, boxPreviewPaint) 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 // Selection highlights
if (selectedStrokeIds.isNotEmpty()) { if (selectedStrokeIds.isNotEmpty()) {
for (sr in completedStrokes) { for (sr in completedStrokes) {
@@ -320,6 +363,9 @@ class PadCanvasView(context: Context) : View(context) {
if (canvasState.tool == Tool.BOX) { if (canvasState.tool == Tool.BOX) {
return handleBoxInput(event) return handleBoxInput(event)
} }
if (canvasState.tool == Tool.LINE) {
return handleLineInput(event)
}
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
@@ -455,6 +501,125 @@ class PadCanvasView(context: Context) : View(context) {
return true 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 { private fun handleFingerInput(event: MotionEvent): Boolean {
scaleGestureDetector.onTouchEvent(event) scaleGestureDetector.onTouchEvent(event)
@@ -463,27 +628,55 @@ class PadCanvasView(context: Context) : View(context) {
activePointerId = event.getPointerId(0) activePointerId = event.getPointerId(0)
lastTouchX = event.x lastTouchX = event.x
lastTouchY = event.y 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 -> { MotionEvent.ACTION_MOVE -> {
if (!scaleGestureDetector.isInProgress) { if (!scaleGestureDetector.isInProgress && event.pointerCount == 1) {
val pointerIndex = event.findPointerIndex(activePointerId) val pointerIndex = event.findPointerIndex(activePointerId)
if (pointerIndex >= 0) { if (pointerIndex >= 0) {
val x = event.getX(pointerIndex) val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex) val y = event.getY(pointerIndex)
if (!isEdgeSwipe) {
panX += x - lastTouchX panX += x - lastTouchX
panY += y - lastTouchY panY += y - lastTouchY
lastTouchX = x
lastTouchY = y
rebuildViewMatrix() rebuildViewMatrix()
invalidate() invalidate()
} }
lastTouchX = x
lastTouchY = y
} }
} }
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 activePointerId = MotionEvent.INVALID_POINTER_ID
onZoomPanChanged?.invoke(zoom, panX, panY) onZoomPanChanged?.invoke(zoom, panX, panY)
} }
MotionEvent.ACTION_POINTER_DOWN -> { MotionEvent.ACTION_POINTER_DOWN -> {
isEdgeSwipe = false // Multi-finger = not an edge swipe
val newIndex = event.actionIndex val newIndex = event.actionIndex
lastTouchX = event.getX(newIndex) lastTouchX = event.getX(newIndex)
lastTouchY = event.getY(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). */ /** Max distance from origin (canonical pts) before snap is canceled (~5mm). */
private const val LINE_SNAP_MOVE_THRESHOLD = 60f 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.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -23,13 +35,54 @@ fun EditorToolbar(
hasSelection: Boolean, hasSelection: Boolean,
onDeleteSelection: () -> Unit, onDeleteSelection: () -> Unit,
onCopySelection: () -> 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, modifier: Modifier = Modifier,
) { ) {
var showBinderMenu by remember { mutableStateOf(false) }
var showGoToPageDialog by remember { mutableStateOf(false) }
Row( Row(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, 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( FilterChip(
selected = currentTool == Tool.PEN_FINE, selected = currentTool == Tool.PEN_FINE,
onClick = { onToolSelected(Tool.PEN_FINE) }, onClick = { onToolSelected(Tool.PEN_FINE) },
@@ -60,12 +113,38 @@ fun EditorToolbar(
label = { Text("Box") }, label = { Text("Box") },
modifier = Modifier.padding(end = 4.dp), modifier = Modifier.padding(end = 4.dp),
) )
LineToolChip(
selected = currentTool == Tool.LINE,
lineStyle = lineStyle,
onSelect = { onToolSelected(Tool.LINE) },
onStyleChanged = onLineStyleChanged,
)
if (hasSelection) { if (hasSelection) {
TextButton(onClick = onDeleteSelection) { Text("Del") } TextButton(onClick = onDeleteSelection) { Text("Del") }
TextButton(onClick = onCopySelection) { Text("Copy") } TextButton(onClick = onCopySelection) { Text("Copy") }
} }
Spacer(modifier = Modifier.weight(1f)) 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) { TextButton(onClick = onUndo, enabled = canUndo) {
Text("Undo") Text("Undo")
} }
@@ -74,4 +153,107 @@ fun EditorToolbar(
Text("Redo") 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.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import androidx.core.graphics.createBitmap
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
@@ -14,10 +16,6 @@ import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.model.Stroke import net.metacircular.engpad.data.model.Stroke
import java.io.File 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 { object PdfExporter {
private const val CANONICAL_TO_PDF = 72f / 300f // 0.24 private const val CANONICAL_TO_PDF = 72f / 300f // 0.24
@@ -39,23 +37,15 @@ object PdfExporter {
val pdfPage = pdfDoc.startPage(pageInfo) val pdfPage = pdfDoc.startPage(pageInfo)
val canvas = pdfPage.canvas val canvas = pdfPage.canvas
// Scale from canonical (300 DPI) to PDF (72 DPI)
canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF) canvas.scale(CANONICAL_TO_PDF, CANONICAL_TO_PDF)
drawStrokes(canvas, strokes)
// 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)
}
pdfDoc.finishPage(pdfPage) pdfDoc.finishPage(pdfPage)
} }
val exportDir = File(context.cacheDir, "exports") val exportDir = File(context.cacheDir, "exports")
exportDir.mkdirs() exportDir.mkdirs()
val sanitizedTitle = notebookTitle.replace(Regex("[^a-zA-Z0-9._-]"), "_") val sanitizedTitle = sanitize(notebookTitle)
val file = File(exportDir, "$sanitizedTitle.pdf") val file = File(exportDir, "$sanitizedTitle.pdf")
file.outputStream().use { pdfDoc.writeTo(it) } file.outputStream().use { pdfDoc.writeTo(it) }
pdfDoc.close() pdfDoc.close()
@@ -63,18 +53,52 @@ object PdfExporter {
return file 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( val uri = FileProvider.getUriForFile(
context, context,
"${context.packageName}.fileprovider", "${context.packageName}.fileprovider",
file, file,
) )
val intent = Intent(Intent.ACTION_SEND).apply { val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf" type = mimeType
putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 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 { private fun buildPath(points: FloatArray): Path {
@@ -99,4 +123,7 @@ object PdfExporter {
isAntiAlias = true 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.db.EngPadDatabase
import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.NotebookRepository 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.editor.EditorScreen
import net.metacircular.engpad.ui.notebooks.NotebookListScreen import net.metacircular.engpad.ui.notebooks.NotebookListScreen
import net.metacircular.engpad.ui.pages.PageListScreen import net.metacircular.engpad.ui.pages.PageListScreen
object Routes { object Routes {
const val NOTEBOOKS = "notebooks" const val NOTEBOOKS = "notebooks"
const val EDITOR = "editor/{notebookId}"
const val PAGES = "pages/{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 pages(notebookId: Long) = "pages/$notebookId"
fun editor(pageId: Long, pageSize: PageSize) = "editor/$pageId/${pageSize.name}"
} }
@Composable @Composable
@@ -37,10 +39,65 @@ fun EngPadNavGraph(
NotebookListScreen( NotebookListScreen(
database = database, database = database,
onNotebookClick = { notebookId -> onNotebookClick = { 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)) navController.navigate(Routes.pages(notebookId))
}, },
) )
} }
}
composable( composable(
route = Routes.PAGES, route = Routes.PAGES,
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }), arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
@@ -68,27 +125,15 @@ fun EngPadNavGraph(
notebookTitle = notebookTitle, notebookTitle = notebookTitle,
pageSize = pageSize, pageSize = pageSize,
database = database, database = database,
onPageClick = { pageId, ps -> onPageClick = { pageId, _ ->
navController.navigate(Routes.editor(pageId, ps)) // 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 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -19,16 +22,24 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import net.metacircular.engpad.data.db.EngPadDatabase 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.Page
import net.metacircular.engpad.data.model.PageSize import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.model.Stroke
import net.metacircular.engpad.data.repository.PageRepository import net.metacircular.engpad.data.repository.PageRepository
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -82,7 +93,9 @@ fun PageListScreen(
items(pages, key = { it.id }) { page -> items(pages, key = { it.id }) { page ->
PageThumbnail( PageThumbnail(
page = page, page = page,
pageSize = pageSize,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
repository = repository,
onClick = { onPageClick(page.id, pageSize) }, onClick = { onPageClick(page.id, pageSize) },
) )
} }
@@ -94,24 +107,64 @@ fun PageListScreen(
@Composable @Composable
private fun PageThumbnail( private fun PageThumbnail(
page: Page, page: Page,
pageSize: PageSize,
aspectRatio: Float, aspectRatio: Float,
repository: PageRepository,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Card( var strokes by remember(page.id) { mutableStateOf<List<Stroke>>(emptyList()) }
modifier = Modifier
.fillMaxWidth() LaunchedEffect(page.id) {
.clickable(onClick = onClick), strokes = repository.getStrokes(page.id)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick),
) { ) {
Box( Card(
modifier = Modifier.fillMaxWidth(),
) {
Canvas(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(aspectRatio), .aspectRatio(aspectRatio),
contentAlignment = Alignment.Center,
) { ) {
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(
text = "Page ${page.pageNumber}", text = "Page ${page.pageNumber}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
) )
} }
} }
}