Add drag-to-reorder pages and fix page navigation from page list

Drag-to-reorder:
- Added sh.calvin.reorderable library for LazyVerticalGrid drag support
- Long-press and drag page thumbnails to reorder
- Page numbers updated in DB on drop
- Visual feedback: slight scale + transparency while dragging

Page navigation fix:
- Clicking a page in "view all pages" now correctly opens that page
  (key(pid) forces EditorScreen recreation with the selected page)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 17:20:28 -07:00
parent 984f19af06
commit 368351f9c6
7 changed files with 91 additions and 23 deletions

View File

@@ -68,6 +68,7 @@ dependencies {
ksp(libs.room.compiler) ksp(libs.room.compiler)
implementation(libs.coroutines.android) implementation(libs.coroutines.android)
implementation(libs.reorderable)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)

View File

@@ -25,4 +25,7 @@ interface PageDao {
@Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC") @Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC")
suspend fun getByNotebookIdList(notebookId: Long): List<Page> suspend fun getByNotebookIdList(notebookId: Long): List<Page>
@Query("UPDATE pages SET page_number = :pageNumber WHERE id = :id")
suspend fun updatePageNumber(id: Long, pageNumber: Int)
} }

View File

@@ -34,6 +34,12 @@ class PageRepository(
suspend fun deletePage(id: Long) = pageDao.deleteById(id) suspend fun deletePage(id: Long) = pageDao.deleteById(id)
suspend fun reorderPages(notebookId: Long, orderedPageIds: List<Long>) {
orderedPageIds.forEachIndexed { index, pageId ->
pageDao.updatePageNumber(pageId, index + 1)
}
}
suspend fun getStrokes(pageId: Long): List<Stroke> = suspend fun getStrokes(pageId: Long): List<Stroke> =
strokeDao.getByPageId(pageId) strokeDao.getByPageId(pageId)

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -98,6 +99,8 @@ fun EngPadNavGraph(
val ps = pageSize val ps = pageSize
val pid = initialPageId val pid = initialPageId
if (ps != null && pid != null) { if (ps != null && pid != null) {
// key(pid) forces EditorScreen + ViewModel recreation when page changes
key(pid) {
EditorScreen( EditorScreen(
notebookId = notebookId, notebookId = notebookId,
pageSize = ps, pageSize = ps,
@@ -111,6 +114,7 @@ fun EngPadNavGraph(
navController.navigate(Routes.pages(notebookId)) navController.navigate(Routes.pages(notebookId))
}, },
) )
} // key(pid)
} }
} }
composable( composable(

View File

@@ -15,6 +15,7 @@ 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.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -34,9 +35,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
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.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -50,6 +53,8 @@ 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 import net.metacircular.engpad.ui.export.PdfExporter
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyGridState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -73,6 +78,23 @@ fun PageListScreen(
var pageToDelete by remember { mutableStateOf<Page?>(null) } var pageToDelete by remember { mutableStateOf<Page?>(null) }
// Maintain a local mutable list for drag reorder
val reorderablePages = remember { mutableListOf<Page>() }
LaunchedEffect(pages) {
reorderablePages.clear()
reorderablePages.addAll(pages)
}
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
val fromIndex = reorderablePages.indexOfFirst { it.id == from.key as Long }
val toIndex = reorderablePages.indexOfFirst { it.id == to.key as Long }
if (fromIndex >= 0 && toIndex >= 0) {
val item = reorderablePages.removeAt(fromIndex)
reorderablePages.add(toIndex, item)
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar(title = { Text(notebookTitle) }) TopAppBar(title = { Text(notebookTitle) })
@@ -100,31 +122,44 @@ fun PageListScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(8.dp), .padding(8.dp),
state = lazyGridState,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(pages, key = { it.id }) { page -> items(reorderablePages.toList(), key = { it.id }) { page ->
PageThumbnail( ReorderableItem(reorderableLazyGridState, key = page.id) { isDragging ->
page = page, PageThumbnail(
pageSize = pageSize, page = page,
aspectRatio = aspectRatio, pageSize = pageSize,
repository = repository, aspectRatio = aspectRatio,
onClick = { onPageClick(page.id, pageSize) }, repository = repository,
onDelete = { pageToDelete = page }, isDragging = isDragging,
onExportJpg = { onClick = { onPageClick(page.id, pageSize) },
scope.launch { onDelete = { pageToDelete = page },
val strokes = viewModel.getStrokes(page.id) onExportJpg = {
val file = PdfExporter.exportPageAsJpg( scope.launch {
context = context, val strokes = viewModel.getStrokes(page.id)
notebookTitle = notebookTitle, val file = PdfExporter.exportPageAsJpg(
pageSize = pageSize, context = context,
pageNumber = page.pageNumber, notebookTitle = notebookTitle,
strokes = strokes, pageSize = pageSize,
) pageNumber = page.pageNumber,
PdfExporter.shareFile(context, file, "image/jpeg") strokes = strokes,
} )
}, PdfExporter.shareFile(context, file, "image/jpeg")
) }
},
modifier = Modifier.longPressDraggableHandle(
onDragStopped = {
// Persist the new order
viewModel.reorderPages(
notebookId,
reorderablePages.map { it.id },
)
},
),
)
}
} }
} }
} }
@@ -155,9 +190,11 @@ private fun PageThumbnail(
pageSize: PageSize, pageSize: PageSize,
aspectRatio: Float, aspectRatio: Float,
repository: PageRepository, repository: PageRepository,
isDragging: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onExportJpg: () -> Unit, onExportJpg: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
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) } var showMenu by remember { mutableStateOf(false) }
@@ -168,6 +205,15 @@ private fun PageThumbnail(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer {
if (isDragging) {
scaleX = 1.05f
scaleY = 1.05f
alpha = 0.8f
}
}
.then(modifier),
) { ) {
Box { Box {
Card( Card(
@@ -175,7 +221,7 @@ private fun PageThumbnail(
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = { showMenu = true }, onLongClick = { if (!isDragging) showMenu = true },
), ),
) { ) {
Canvas( Canvas(

View File

@@ -31,6 +31,12 @@ class PageListViewModel(
} }
} }
fun reorderPages(notebookId: Long, orderedPageIds: List<Long>) {
viewModelScope.launch {
repository.reorderPages(notebookId, orderedPageIds)
}
}
suspend fun getStrokes(pageId: Long): List<Stroke> = suspend fun getStrokes(pageId: Long): List<Stroke> =
repository.getStrokes(pageId) repository.getStrokes(pageId)

View File

@@ -10,6 +10,7 @@ coroutines = "1.10.2"
core-ktx = "1.18.0" core-ktx = "1.18.0"
activity-compose = "1.13.0" activity-compose = "1.13.0"
junit = "4.13.2" junit = "4.13.2"
reorderable = "3.0.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
@@ -35,6 +36,7 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }