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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:46:48 -07:00
parent 34ad68d1ce
commit 47b6ffc489
5 changed files with 187 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,30 +46,32 @@ 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(

View File

@@ -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,
)
}
}
}

View File

@@ -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<List<Page>> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
return PageListViewModel(notebookId, repository) as T
}
}
}