From 368351f9c60fb91ba5b0dab6fd5002fd9c22fa5c Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 17:20:28 -0700 Subject: [PATCH] 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) --- app/build.gradle.kts | 1 + .../metacircular/engpad/data/db/PageDao.kt | 3 + .../engpad/data/repository/PageRepository.kt | 6 ++ .../engpad/ui/navigation/NavGraph.kt | 4 + .../engpad/ui/pages/PageListScreen.kt | 92 ++++++++++++++----- .../engpad/ui/pages/PageListViewModel.kt | 6 ++ gradle/libs.versions.toml | 2 + 7 files changed, 91 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf584bf..416c061 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { ksp(libs.room.compiler) implementation(libs.coroutines.android) + implementation(libs.reorderable) testImplementation(libs.junit) testImplementation(libs.coroutines.test) diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt b/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt index 3933b4e..05fa41e 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/db/PageDao.kt @@ -25,4 +25,7 @@ interface PageDao { @Query("SELECT * FROM pages WHERE notebook_id = :notebookId ORDER BY page_number ASC") suspend fun getByNotebookIdList(notebookId: Long): List + + @Query("UPDATE pages SET page_number = :pageNumber WHERE id = :id") + suspend fun updatePageNumber(id: Long, pageNumber: Int) } diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt b/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt index 8f065f9..8434306 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/data/repository/PageRepository.kt @@ -34,6 +34,12 @@ class PageRepository( suspend fun deletePage(id: Long) = pageDao.deleteById(id) + suspend fun reorderPages(notebookId: Long, orderedPageIds: List) { + orderedPageIds.forEachIndexed { index, pageId -> + pageDao.updatePageNumber(pageId, index + 1) + } + } + suspend fun getStrokes(pageId: Long): List = strokeDao.getByPageId(pageId) diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt index 0a5d7a6..0a80c4b 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -98,6 +99,8 @@ fun EngPadNavGraph( val ps = pageSize val pid = initialPageId if (ps != null && pid != null) { + // key(pid) forces EditorScreen + ViewModel recreation when page changes + key(pid) { EditorScreen( notebookId = notebookId, pageSize = ps, @@ -111,6 +114,7 @@ fun EngPadNavGraph( navController.navigate(Routes.pages(notebookId)) }, ) + } // key(pid) } } composable( diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt index 94afa4b..48a5759 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu @@ -34,9 +35,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalContext 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.repository.PageRepository import net.metacircular.engpad.ui.export.PdfExporter +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyGridState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,6 +78,23 @@ fun PageListScreen( var pageToDelete by remember { mutableStateOf(null) } + // Maintain a local mutable list for drag reorder + val reorderablePages = remember { mutableListOf() } + 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( topBar = { TopAppBar(title = { Text(notebookTitle) }) @@ -100,31 +122,44 @@ fun PageListScreen( .fillMaxSize() .padding(padding) .padding(8.dp), + state = lazyGridState, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(pages, key = { it.id }) { page -> - PageThumbnail( - page = page, - pageSize = pageSize, - aspectRatio = aspectRatio, - repository = repository, - 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") - } - }, - ) + items(reorderablePages.toList(), key = { it.id }) { page -> + ReorderableItem(reorderableLazyGridState, key = page.id) { isDragging -> + PageThumbnail( + page = page, + pageSize = pageSize, + aspectRatio = aspectRatio, + repository = repository, + isDragging = isDragging, + 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") + } + }, + modifier = Modifier.longPressDraggableHandle( + onDragStopped = { + // Persist the new order + viewModel.reorderPages( + notebookId, + reorderablePages.map { it.id }, + ) + }, + ), + ) + } } } } @@ -155,9 +190,11 @@ private fun PageThumbnail( pageSize: PageSize, aspectRatio: Float, repository: PageRepository, + isDragging: Boolean, onClick: () -> Unit, onDelete: () -> Unit, onExportJpg: () -> Unit, + modifier: Modifier = Modifier, ) { var strokes by remember(page.id) { mutableStateOf>(emptyList()) } var showMenu by remember { mutableStateOf(false) } @@ -168,6 +205,15 @@ private fun PageThumbnail( Column( horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .graphicsLayer { + if (isDragging) { + scaleX = 1.05f + scaleY = 1.05f + alpha = 0.8f + } + } + .then(modifier), ) { Box { Card( @@ -175,7 +221,7 @@ private fun PageThumbnail( .fillMaxWidth() .combinedClickable( onClick = onClick, - onLongClick = { showMenu = true }, + onLongClick = { if (!isDragging) showMenu = true }, ), ) { Canvas( diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt index 40067b5..70c4cc0 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt @@ -31,6 +31,12 @@ class PageListViewModel( } } + fun reorderPages(notebookId: Long, orderedPageIds: List) { + viewModelScope.launch { + repository.reorderPages(notebookId, orderedPageIds) + } + } + suspend fun getStrokes(pageId: Long): List = repository.getStrokes(pageId) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8c3cdf..e0a8691 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ coroutines = "1.10.2" core-ktx = "1.18.0" activity-compose = "1.13.0" junit = "4.13.2" +reorderable = "3.0.0" [libraries] 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" } junit = { group = "junit", name = "junit", version.ref = "junit" } +reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }