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:
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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._-]"), "_")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user