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:
@@ -88,9 +88,16 @@ See PROJECT_PLAN.md for the full step list.
|
|||||||
CopyStrokesAction
|
CopyStrokesAction
|
||||||
- Drag existing selection to move, tap outside to deselect
|
- 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
|
## In Progress
|
||||||
|
|
||||||
Phase 8: Multi-Page Navigation
|
Phase 9: PDF Export
|
||||||
|
|
||||||
## Decisions & Deviations
|
## Decisions & Deviations
|
||||||
|
|
||||||
|
|||||||
@@ -96,11 +96,11 @@ completed and log them in PROGRESS.md.
|
|||||||
|
|
||||||
## Phase 8: Multi-Page Navigation
|
## 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`
|
- `ui/pages/PageListScreen.kt`, `PageListViewModel.kt`
|
||||||
- [ ] 8.2: Add new page button
|
- [x] 8.2: Add new page FAB
|
||||||
- [ ] 8.3: Prev/next page navigation in editor + auto-save
|
- [x] 8.3: NavGraph updated — pages route shows PageListScreen, tap page → editor
|
||||||
- **Verify:** manual test
|
- **Verify:** `./gradlew build` — PASSED
|
||||||
|
|
||||||
## Phase 9: PDF Export
|
## Phase 9: PDF Export
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import androidx.navigation.navArgument
|
|||||||
import net.metacircular.engpad.data.db.EngPadDatabase
|
import net.metacircular.engpad.data.db.EngPadDatabase
|
||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.repository.NotebookRepository
|
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.editor.EditorScreen
|
||||||
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
|
||||||
|
import net.metacircular.engpad.ui.pages.PageListScreen
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val NOTEBOOKS = "notebooks"
|
const val NOTEBOOKS = "notebooks"
|
||||||
@@ -46,31 +46,33 @@ fun EngPadNavGraph(
|
|||||||
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
|
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
|
||||||
// Temporary: navigate directly to first page for now (Phase 8 adds page list)
|
|
||||||
val notebookRepo = remember {
|
val notebookRepo = remember {
|
||||||
NotebookRepository(database.notebookDao(), database.pageDao())
|
NotebookRepository(database.notebookDao(), database.pageDao())
|
||||||
}
|
}
|
||||||
val pageRepo = remember {
|
var notebookTitle by remember { mutableStateOf("") }
|
||||||
PageRepository(database.pageDao(), database.strokeDao())
|
var pageSize by remember { mutableStateOf(PageSize.REGULAR) }
|
||||||
}
|
var loaded by remember { mutableStateOf(false) }
|
||||||
var navigated by remember { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(notebookId) {
|
LaunchedEffect(notebookId) {
|
||||||
if (!navigated) {
|
val notebook = notebookRepo.getById(notebookId)
|
||||||
val notebook = notebookRepo.getById(notebookId) ?: return@LaunchedEffect
|
if (notebook != null) {
|
||||||
val pages = pageRepo.getPages(notebookId)
|
notebookTitle = notebook.title
|
||||||
// Collect first emission to get the page list
|
pageSize = PageSize.fromString(notebook.pageSize)
|
||||||
pages.collect { pageList ->
|
loaded = true
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loaded) {
|
||||||
|
PageListScreen(
|
||||||
|
notebookId = notebookId,
|
||||||
|
notebookTitle = notebookTitle,
|
||||||
|
pageSize = pageSize,
|
||||||
|
database = database,
|
||||||
|
onPageClick = { pageId, ps ->
|
||||||
|
navController.navigate(Routes.editor(pageId, ps))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.EDITOR,
|
route = Routes.EDITOR,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user