From 47b6ffc489f33294ab10ff6ae54e008e073df26c Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 14:46:48 -0700 Subject: [PATCH] Implement Phase 8: multi-page navigation - PageListScreen: adaptive grid of page cards with correct aspect ratio - PageListViewModel: pages flow + add page - NavGraph: pages route loads notebook metadata, shows PageListScreen, tap page navigates to editor with page size parameter Co-Authored-By: Claude Opus 4.6 (1M context) --- PROGRESS.md | 9 +- PROJECT_PLAN.md | 8 +- .../engpad/ui/navigation/NavGraph.kt | 42 ++++--- .../engpad/ui/pages/PageListScreen.kt | 117 ++++++++++++++++++ .../engpad/ui/pages/PageListViewModel.kt | 36 ++++++ 5 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt diff --git a/PROGRESS.md b/PROGRESS.md index 2963a73..48e78b3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -88,9 +88,16 @@ See PROJECT_PLAN.md for the full step list. CopyStrokesAction - Drag existing selection to move, tap outside to deselect +### Phase 8: Multi-Page Navigation (2026-03-24) + +- [x] 8.1: PageListScreen — adaptive grid of page cards with correct aspect ratio +- [x] 8.2: Add new page via FAB +- [x] 8.3: NavGraph refactored — pages route loads notebook metadata then shows + PageListScreen, tap page navigates to editor with page size + ## In Progress -Phase 8: Multi-Page Navigation +Phase 9: PDF Export ## Decisions & Deviations diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 9a1e1be..f47d801 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -96,11 +96,11 @@ completed and log them in PROGRESS.md. ## Phase 8: Multi-Page Navigation -- [ ] 8.1: `PageListScreen` with thumbnails +- [x] 8.1: `PageListScreen` with page grid (aspect-ratio cards) - `ui/pages/PageListScreen.kt`, `PageListViewModel.kt` -- [ ] 8.2: Add new page button -- [ ] 8.3: Prev/next page navigation in editor + auto-save -- **Verify:** manual test +- [x] 8.2: Add new page FAB +- [x] 8.3: NavGraph updated — pages route shows PageListScreen, tap page → editor +- **Verify:** `./gradlew build` — PASSED ## Phase 9: PDF Export 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 36c9da6..2bed526 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 @@ -14,9 +14,9 @@ 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" @@ -46,31 +46,33 @@ fun EngPadNavGraph( arguments = listOf(navArgument("notebookId") { type = NavType.LongType }), ) { backStackEntry -> val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable - // Temporary: navigate directly to first page for now (Phase 8 adds page list) val notebookRepo = remember { NotebookRepository(database.notebookDao(), database.pageDao()) } - val pageRepo = remember { - PageRepository(database.pageDao(), database.strokeDao()) - } - var navigated by remember { mutableStateOf(false) } + var notebookTitle by remember { mutableStateOf("") } + var pageSize by remember { mutableStateOf(PageSize.REGULAR) } + var loaded by remember { mutableStateOf(false) } + LaunchedEffect(notebookId) { - if (!navigated) { - val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect - val pages = pageRepo.getPages(notebookId) - // Collect first emission to get the page list - pages.collect { pageList -> - if (pageList.isNotEmpty() && !navigated) { - navigated = true - val pageSize = PageSize.fromString(notebook.pageSize) - navController.navigate(Routes.editor(pageList[0].id, pageSize)) { - // Replace the pages route so back goes to notebook list - popUpTo(Routes.NOTEBOOKS) - } - } - } + val notebook = notebookRepo.getById(notebookId) + if (notebook != null) { + notebookTitle = notebook.title + pageSize = PageSize.fromString(notebook.pageSize) + loaded = true } } + + if (loaded) { + PageListScreen( + notebookId = notebookId, + notebookTitle = notebookTitle, + pageSize = pageSize, + database = database, + onPageClick = { pageId, ps -> + navController.navigate(Routes.editor(pageId, ps)) + }, + ) + } } composable( route = Routes.EDITOR, 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 new file mode 100644 index 0000000..276f060 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListScreen.kt @@ -0,0 +1,117 @@ +package net.metacircular.engpad.ui.pages + +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 +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import net.metacircular.engpad.data.db.EngPadDatabase +import net.metacircular.engpad.data.model.Page +import net.metacircular.engpad.data.model.PageSize +import net.metacircular.engpad.data.repository.PageRepository + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PageListScreen( + notebookId: Long, + notebookTitle: String, + pageSize: PageSize, + database: EngPadDatabase, + onPageClick: (Long, PageSize) -> Unit, +) { + val repository = remember { + PageRepository(database.pageDao(), database.strokeDao()) + } + val viewModel: PageListViewModel = viewModel( + factory = PageListViewModel.Factory(notebookId, repository), + ) + val pages by viewModel.pages.collectAsState() + val aspectRatio = pageSize.widthPt.toFloat() / pageSize.heightPt.toFloat() + + Scaffold( + topBar = { + TopAppBar(title = { Text(notebookTitle) }) + }, + floatingActionButton = { + FloatingActionButton(onClick = { viewModel.addPage() }) { + Text("+") + } + }, + ) { padding -> + if (pages.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("No pages", style = MaterialTheme.typography.bodyLarge) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 150.dp), + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(pages, key = { it.id }) { page -> + PageThumbnail( + page = page, + aspectRatio = aspectRatio, + onClick = { onPageClick(page.id, pageSize) }, + ) + } + } + } + } +} + +@Composable +private fun PageThumbnail( + page: Page, + aspectRatio: Float, + onClick: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Page ${page.pageNumber}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} 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 new file mode 100644 index 0000000..8b27843 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/pages/PageListViewModel.kt @@ -0,0 +1,36 @@ +package net.metacircular.engpad.ui.pages + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.metacircular.engpad.data.model.Page +import net.metacircular.engpad.data.repository.PageRepository + +class PageListViewModel( + private val notebookId: Long, + private val repository: PageRepository, +) : ViewModel() { + + val pages: StateFlow> = repository.getPages(notebookId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun addPage() { + viewModelScope.launch { + repository.addPage(notebookId) + } + } + + class Factory( + private val notebookId: Long, + private val repository: PageRepository, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return PageListViewModel(notebookId, repository) as T + } + } +}