diff --git a/PROGRESS.md b/PROGRESS.md index b9f9fa0..5d280e1 100644 --- a/PROGRESS.md +++ b/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 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 5ec7006..8028698 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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 diff --git a/app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt b/app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt index 2c2e083..dbc2844 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/MainActivity.kt @@ -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, + ) + } } } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..523a43a --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/navigation/NavGraph.kt @@ -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") + } + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt new file mode 100644 index 0000000..24f6dd3 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt @@ -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(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") + } + }, + ) +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListViewModel.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListViewModel.kt new file mode 100644 index 0000000..a786ff3 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListViewModel.kt @@ -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> = 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 create(modelClass: Class): T { + return NotebookListViewModel(repository) as T + } + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/theme/Theme.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/theme/Theme.kt new file mode 100644 index 0000000..4f88002 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/theme/Theme.kt @@ -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, + ) +}