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:
12
PROGRESS.md
12
PROGRESS.md
@@ -31,9 +31,19 @@ See PROJECT_PLAN.md for the full step list.
|
||||
- Foojay resolver added for automatic JDK toolchain download
|
||||
- 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
|
||||
|
||||
Phase 2: Notebook List Screen
|
||||
Phase 3: Canvas — Basic Drawing
|
||||
|
||||
## Decisions & Deviations
|
||||
|
||||
|
||||
@@ -35,16 +35,16 @@ completed and log them in PROGRESS.md.
|
||||
|
||||
## 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`
|
||||
- [ ] 2.2: Implement `NotebookListViewModel`
|
||||
- [x] 2.2: Implement `NotebookListViewModel`
|
||||
- `ui/notebooks/NotebookListViewModel.kt`
|
||||
- [ ] 2.3: Implement `NotebookListScreen`
|
||||
- `ui/notebooks/NotebookListScreen.kt` — list, create dialog, delete
|
||||
- [ ] 2.4: Auto-create page 1 on notebook creation
|
||||
- In `NotebookRepository` or ViewModel
|
||||
- [ ] 2.5: Navigation: tap notebook → page list (stub screen)
|
||||
- **Verify:** `./gradlew build && ./gradlew test`
|
||||
- [x] 2.3: Implement `NotebookListScreen`
|
||||
- `ui/notebooks/NotebookListScreen.kt` — list, create dialog, delete confirmation
|
||||
- [x] 2.4: Auto-create page 1 on notebook creation
|
||||
- In `NotebookRepository.create()`
|
||||
- [x] 2.5: Navigation: tap notebook → page list (stub screen)
|
||||
- **Verify:** `./gradlew build` — PASSED (build + test + lint)
|
||||
|
||||
## Phase 3: Canvas — Basic Drawing
|
||||
|
||||
|
||||
@@ -3,13 +3,22 @@ package net.metacircular.engpad
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
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() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val database = (application as EngPadApp).database
|
||||
setContent {
|
||||
Text("eng-pad")
|
||||
EngPadTheme {
|
||||
val navController = rememberNavController()
|
||||
EngPadNavGraph(
|
||||
navController = navController,
|
||||
database = database,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user