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