Implement Phase 2: notebook list screen with navigation

- NotebookListScreen: lazy list, create dialog (title + page size),
  long-press delete with confirmation, empty state
- NotebookListViewModel: StateFlow-based, create/delete operations
- EngPadTheme: high-contrast light scheme for e-ink displays
- NavGraph: three routes (notebooks, pages stub, editor stub)
- MainActivity wired to NavHost with database injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 14:15:48 -07:00
parent 8f41c41589
commit 644b8a4732
7 changed files with 381 additions and 11 deletions

View File

@@ -31,9 +31,19 @@ See PROJECT_PLAN.md for the full step list.
- Foojay resolver added for automatic JDK toolchain download - Foojay resolver added for automatic JDK toolchain download
- compileSdk/targetSdk bumped to 36 (required by latest androidx dependencies) - compileSdk/targetSdk bumped to 36 (required by latest androidx dependencies)
### Phase 2: Notebook List Screen (2026-03-24)
- [x] 2.1: MainActivity with Compose NavHost (three routes: notebooks, pages, editor)
- [x] 2.2: NotebookListViewModel with StateFlow, create/delete operations
- [x] 2.3: NotebookListScreen — lazy list, create dialog (title + page size radio),
long-press delete with confirmation, empty state
- [x] 2.4: Auto-create page 1 in NotebookRepository.create()
- [x] 2.5: Navigation wired — tap notebook → pages stub, editor stub
- EngPadTheme: high-contrast light color scheme (black on white for e-ink)
## In Progress ## In Progress
Phase 2: Notebook List Screen Phase 3: Canvas — Basic Drawing
## Decisions & Deviations ## Decisions & Deviations

View File

@@ -35,16 +35,16 @@ completed and log them in PROGRESS.md.
## Phase 2: Notebook List Screen ## Phase 2: Notebook List Screen
- [ ] 2.1: Set up `MainActivity` with Compose and `NavHost` - [x] 2.1: Set up `MainActivity` with Compose and `NavHost`
- `MainActivity.kt`, `ui/navigation/NavGraph.kt`, `ui/theme/Theme.kt` - `MainActivity.kt`, `ui/navigation/NavGraph.kt`, `ui/theme/Theme.kt`
- [ ] 2.2: Implement `NotebookListViewModel` - [x] 2.2: Implement `NotebookListViewModel`
- `ui/notebooks/NotebookListViewModel.kt` - `ui/notebooks/NotebookListViewModel.kt`
- [ ] 2.3: Implement `NotebookListScreen` - [x] 2.3: Implement `NotebookListScreen`
- `ui/notebooks/NotebookListScreen.kt` — list, create dialog, delete - `ui/notebooks/NotebookListScreen.kt` — list, create dialog, delete confirmation
- [ ] 2.4: Auto-create page 1 on notebook creation - [x] 2.4: Auto-create page 1 on notebook creation
- In `NotebookRepository` or ViewModel - In `NotebookRepository.create()`
- [ ] 2.5: Navigation: tap notebook → page list (stub screen) - [x] 2.5: Navigation: tap notebook → page list (stub screen)
- **Verify:** `./gradlew build && ./gradlew test` - **Verify:** `./gradlew build` — PASSED (build + test + lint)
## Phase 3: Canvas — Basic Drawing ## Phase 3: Canvas — Basic Drawing

View File

@@ -3,13 +3,22 @@ package net.metacircular.engpad
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.material3.Text import androidx.navigation.compose.rememberNavController
import net.metacircular.engpad.ui.navigation.EngPadNavGraph
import net.metacircular.engpad.ui.theme.EngPadTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val database = (application as EngPadApp).database
setContent { setContent {
Text("eng-pad") EngPadTheme {
val navController = rememberNavController()
EngPadNavGraph(
navController = navController,
database = database,
)
}
} }
} }
} }

View File

@@ -0,0 +1,52 @@
package net.metacircular.engpad.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import net.metacircular.engpad.data.db.EngPadDatabase
import net.metacircular.engpad.ui.notebooks.NotebookListScreen
object Routes {
const val NOTEBOOKS = "notebooks"
const val PAGES = "pages/{notebookId}"
const val EDITOR = "editor/{pageId}"
fun pages(notebookId: Long) = "pages/$notebookId"
fun editor(pageId: Long) = "editor/$pageId"
}
@Composable
fun EngPadNavGraph(
navController: NavHostController,
database: EngPadDatabase,
) {
NavHost(navController = navController, startDestination = Routes.NOTEBOOKS) {
composable(Routes.NOTEBOOKS) {
NotebookListScreen(
database = database,
onNotebookClick = { notebookId ->
navController.navigate(Routes.pages(notebookId))
},
)
}
composable(
route = Routes.PAGES,
arguments = listOf(navArgument("notebookId") { type = NavType.LongType }),
) { backStackEntry ->
val notebookId = backStackEntry.arguments?.getLong("notebookId") ?: return@composable
// Stub — Phase 8 will implement PageListScreen
androidx.compose.material3.Text("Pages for notebook $notebookId")
}
composable(
route = Routes.EDITOR,
arguments = listOf(navArgument("pageId") { type = NavType.LongType }),
) { backStackEntry ->
val pageId = backStackEntry.arguments?.getLong("pageId") ?: return@composable
// Stub — Phase 3 will implement EditorScreen
androidx.compose.material3.Text("Editor for page $pageId")
}
}
}

View File

@@ -0,0 +1,238 @@
package net.metacircular.engpad.ui.notebooks
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.Notebook
import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.NotebookRepository
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotebookListScreen(
database: EngPadDatabase,
onNotebookClick: (Long) -> Unit,
) {
val repository = remember {
NotebookRepository(database.notebookDao(), database.pageDao())
}
val viewModel: NotebookListViewModel = viewModel(
factory = NotebookListViewModel.Factory(repository),
)
val notebooks by viewModel.notebooks.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
var notebookToDelete by remember { mutableStateOf<Notebook?>(null) }
Scaffold(
topBar = {
TopAppBar(title = { Text("eng-pad") })
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Text("+")
}
},
) { padding ->
if (notebooks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("No notebooks yet", style = MaterialTheme.typography.bodyLarge)
Text("Tap + to create one", style = MaterialTheme.typography.bodyMedium)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
items(notebooks, key = { it.id }) { notebook ->
NotebookItem(
notebook = notebook,
onClick = { onNotebookClick(notebook.id) },
onLongClick = { notebookToDelete = notebook },
)
}
}
}
}
if (showCreateDialog) {
CreateNotebookDialog(
onDismiss = { showCreateDialog = false },
onCreate = { title, pageSize ->
viewModel.createNotebook(title, pageSize)
showCreateDialog = false
},
)
}
notebookToDelete?.let { notebook ->
DeleteNotebookDialog(
notebook = notebook,
onDismiss = { notebookToDelete = null },
onConfirm = {
viewModel.deleteNotebook(notebook.id)
notebookToDelete = null
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NotebookItem(
notebook: Notebook,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = notebook.title,
style = MaterialTheme.typography.titleMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = notebook.pageSize.lowercase().replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodySmall,
)
Text(
text = dateFormat.format(Date(notebook.updatedAt)),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@Composable
private fun CreateNotebookDialog(
onDismiss: () -> Unit,
onCreate: (title: String, pageSize: String) -> Unit,
) {
var title by remember { mutableStateOf("") }
var selectedSize by remember { mutableStateOf(PageSize.REGULAR) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Notebook") },
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
"Page Size",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
)
PageSize.entries.forEach { size ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
RadioButton(
selected = selectedSize == size,
onClick = { selectedSize = size },
)
Text(
when (size) {
PageSize.REGULAR -> "Regular (8.5 \u00d7 11 in)"
PageSize.LARGE -> "Large (11 \u00d7 17 in)"
}
)
}
}
}
},
confirmButton = {
TextButton(
onClick = { onCreate(title.ifBlank { "Untitled" }, selectedSize.name) },
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun DeleteNotebookDialog(
notebook: Notebook,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Notebook") },
text = { Text("Delete \"${notebook.title}\"? This cannot be undone.") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}

View File

@@ -0,0 +1,38 @@
package net.metacircular.engpad.ui.notebooks
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.Notebook
import net.metacircular.engpad.data.repository.NotebookRepository
class NotebookListViewModel(
private val repository: NotebookRepository,
) : ViewModel() {
val notebooks: StateFlow<List<Notebook>> = repository.getAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun createNotebook(title: String, pageSize: String) {
viewModelScope.launch {
repository.create(title, pageSize)
}
}
fun deleteNotebook(id: Long) {
viewModelScope.launch {
repository.delete(id)
}
}
class Factory(private val repository: NotebookRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return NotebookListViewModel(repository) as T
}
}
}

View File

@@ -0,0 +1,23 @@
package net.metacircular.engpad.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Color.Black,
onPrimary = Color.White,
surface = Color.White,
onSurface = Color.Black,
background = Color.White,
onBackground = Color.Black,
)
@Composable
fun EngPadTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColorScheme,
content = content,
)
}