Add page long-press menu (delete/export JPG) in view all pages

- Long-press a page thumbnail to show context menu with Delete and
  Export as JPG options
- Delete shows confirmation dialog
- Export renders page at 300 DPI and shares via intent
- FAB always creates new pages (no empty-page restriction in page list)
- Note: drag-to-reorder deferred — requires custom grid drag handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:11:23 -07:00
parent 6a628d2435
commit 984f19af06
2 changed files with 116 additions and 33 deletions

View File

@@ -1,11 +1,12 @@
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.Paint as AndroidPaint
import android.graphics.Path as AndroidPath import android.graphics.Path as AndroidPath
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
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
@@ -14,12 +15,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.LaunchedEffect
@@ -27,20 +32,24 @@ 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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.core.graphics.withScale import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.withScale
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
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.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.model.Stroke
import net.metacircular.engpad.data.repository.PageRepository import net.metacircular.engpad.data.repository.PageRepository
import net.metacircular.engpad.ui.export.PdfExporter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -59,6 +68,10 @@ fun PageListScreen(
) )
val pages by viewModel.pages.collectAsState() val pages by viewModel.pages.collectAsState()
val aspectRatio = pageSize.widthPt.toFloat() / pageSize.heightPt.toFloat() val aspectRatio = pageSize.widthPt.toFloat() / pageSize.heightPt.toFloat()
val context = LocalContext.current
val scope = rememberCoroutineScope()
var pageToDelete by remember { mutableStateOf<Page?>(null) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -97,13 +110,45 @@ fun PageListScreen(
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
repository = repository, repository = repository,
onClick = { onPageClick(page.id, pageSize) }, onClick = { onPageClick(page.id, pageSize) },
onDelete = { pageToDelete = page },
onExportJpg = {
scope.launch {
val strokes = viewModel.getStrokes(page.id)
val file = PdfExporter.exportPageAsJpg(
context = context,
notebookTitle = notebookTitle,
pageSize = pageSize,
pageNumber = page.pageNumber,
strokes = strokes,
)
PdfExporter.shareFile(context, file, "image/jpeg")
}
},
) )
} }
} }
} }
} }
pageToDelete?.let { page ->
AlertDialog(
onDismissRequest = { pageToDelete = null },
title = { Text("Delete Page") },
text = { Text("Delete page ${page.pageNumber}? This cannot be undone.") },
confirmButton = {
TextButton(onClick = {
viewModel.deletePage(page.id)
pageToDelete = null
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { pageToDelete = null }) { Text("Cancel") }
},
)
}
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun PageThumbnail( private fun PageThumbnail(
page: Page, page: Page,
@@ -111,8 +156,11 @@ private fun PageThumbnail(
aspectRatio: Float, aspectRatio: Float,
repository: PageRepository, repository: PageRepository,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit,
onExportJpg: () -> Unit,
) { ) {
var strokes by remember(page.id) { mutableStateOf<List<Stroke>>(emptyList()) } var strokes by remember(page.id) { mutableStateOf<List<Stroke>>(emptyList()) }
var showMenu by remember { mutableStateOf(false) }
LaunchedEffect(page.id) { LaunchedEffect(page.id) {
strokes = repository.getStrokes(page.id) strokes = repository.getStrokes(page.id)
@@ -120,46 +168,71 @@ private fun PageThumbnail(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable(onClick = onClick),
) { ) {
Card( Box {
modifier = Modifier.fillMaxWidth(), Card(
) {
Canvas(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(aspectRatio), .combinedClickable(
onClick = onClick,
onLongClick = { showMenu = true },
),
) { ) {
val scaleX = size.width / pageSize.widthPt.toFloat() Canvas(
val scaleY = size.height / pageSize.heightPt.toFloat() modifier = Modifier
val scale = minOf(scaleX, scaleY) .fillMaxWidth()
.aspectRatio(aspectRatio),
) {
val scaleX = size.width / pageSize.widthPt.toFloat()
val scaleY = size.height / pageSize.heightPt.toFloat()
val scale = minOf(scaleX, scaleY)
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas val nativeCanvas = canvas.nativeCanvas
nativeCanvas.withScale(scale, scale) { nativeCanvas.withScale(scale, scale) {
for (stroke in strokes) { for (stroke in strokes) {
val points = stroke.pointData.toFloatArray() val points = stroke.pointData.toFloatArray()
if (points.size < 2) continue if (points.size < 2) continue
val path = AndroidPath() val path = AndroidPath()
path.moveTo(points[0], points[1]) path.moveTo(points[0], points[1])
var i = 2 var i = 2
while (i < points.size - 1) { while (i < points.size - 1) {
path.lineTo(points[i], points[i + 1]) path.lineTo(points[i], points[i + 1])
i += 2 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)
} }
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)
} }
} }
} }
} }
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Export as JPG") },
onClick = {
showMenu = false
onExportJpg()
},
)
DropdownMenuItem(
text = { Text("Delete page") },
onClick = {
showMenu = false
onDelete()
},
)
}
} }
Text( Text(
text = "Page ${page.pageNumber}", text = "Page ${page.pageNumber}",

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.repository.PageRepository import net.metacircular.engpad.data.repository.PageRepository
class PageListViewModel( class PageListViewModel(
@@ -24,6 +25,15 @@ class PageListViewModel(
} }
} }
fun deletePage(pageId: Long) {
viewModelScope.launch {
repository.deletePage(pageId)
}
}
suspend fun getStrokes(pageId: Long): List<Stroke> =
repository.getStrokes(pageId)
class Factory( class Factory(
private val notebookId: Long, private val notebookId: Long,
private val repository: PageRepository, private val repository: PageRepository,