diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 200558a..2bf1987 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -232,6 +232,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.core) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index d5cc812..f74a9c0 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -4,9 +4,14 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.thoughtsync.ui.BoardViewModel +import com.fabledsword.thoughtsync.ui.ComposeSheet import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme @@ -27,11 +32,28 @@ class MainActivity : ComponentActivity() { StoreUnavailableScreen(reason = app.openFailure) } else { val model: BoardViewModel = viewModel(factory = BoardViewModel.factory(core)) + // Sheet visibility is view STATE, not view-model state: it is + // about what is on screen, and nothing in the store cares. + var composing by remember { mutableStateOf(false) } + BoardScreen( state = model.state, - onCapture = model::capture, + onOpen = model::open, + onSearch = model::search, + onCompose = { composing = true }, onDismissError = model::dismissError, ) + + if (composing) { + ComposeSheet( + saving = model.state.saving, + onDismiss = { composing = false }, + onSave = { kind, title, content -> + model.create(kind, title, content) + composing = false + }, + ) + } } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index c34d334..b27240c 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -1,5 +1,8 @@ package com.fabledsword.thoughtsync.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -8,135 +11,168 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.Card +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R +import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Note +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BoardScreen( state: BoardState, - onCapture: (String) -> Unit, + onOpen: (Destination) -> Unit, + onSearch: (String) -> Unit, + onCompose: () -> Unit, onDismissError: () -> Unit, ) { - Scaffold( - topBar = { TopAppBar(title = { Text(stringResource(R.string.board_title)) }) }, - modifier = Modifier.imePadding(), - ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - state.error?.let { message -> - ErrorBanner(message = message, onDismiss = onDismissError) - } + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() - CaptureField(saving = state.saving, onCapture = onCapture) - - when { - state.loading -> LoadingBoard() - state.notes.isEmpty() -> EmptyBoard() - else -> NoteList(notes = state.notes) - } - } - } -} - -@Composable -private fun CaptureField( - saving: Boolean, - onCapture: (String) -> Unit, -) { - var text by remember { mutableStateOf("") } - - fun submit() { - if (text.isNotBlank()) { - onCapture(text) - text = "" - } - } - - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.capture_hint)) }, - // The north star is a thought captured in under a second, so the - // keyboard's action key saves rather than inserting a newline. - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { submit() }), - enabled = !saving, - singleLine = false, - maxLines = MAX_CAPTURE_LINES, - ) - Button(onClick = ::submit, enabled = !saving && text.isNotBlank()) { - Text(stringResource(R.string.capture_action)) - } - } -} - -@Composable -private fun NoteList(notes: List) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Keyed by id so Compose reuses rows across a refresh instead of - // rebuilding the list — and so a prepended note animates in rather than - // making every row below it flicker. - items(items = notes, key = { it.id }) { note -> NoteCard(note) } - } -} - -@Composable -private fun NoteCard(note: Note) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - // display_title is always present — the core derives it from the - // first body line when there is no title, so a body-only note is - // still nameable. The fallback is for a note with neither. - text = note.displayTitle.ifBlank { stringResource(R.string.board_untitled) }, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + NavigationDrawer( + current = state.destination, + labels = state.labels, + onOpen = { + onOpen(it) + scope.launch { drawerState.close() } + }, ) - if (note.body.isNotBlank() && note.body != note.displayTitle) { - Spacer(Modifier.height(4.dp)) - Text( - text = note.body, - style = MaterialTheme.typography.bodyMedium, - maxLines = MAX_PREVIEW_LINES, - overflow = TextOverflow.Ellipsis, + }, + ) { + Scaffold( + floatingActionButton = { + // The + is the ONLY way in, by design: one obvious target rather + // than a capture bar and a button competing for the same job. + FloatingActionButton( + onClick = onCompose, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.compose_open)) + } + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + SearchBar( + query = state.query, + onQueryChange = onSearch, + onMenu = { scope.launch { drawerState.open() } }, + ) + + state.error?.let { message -> + ErrorBanner(message = message, onDismiss = onDismissError) + } + + when { + state.loading -> LoadingBoard() + state.notes.isEmpty() -> EmptyBoard(state) + else -> NoteBoard(notes = state.notes) + } + } + } + } +} + +/** + * A search field IS the top bar, following the phone convention rather than the + * desktop's title-plus-sidebar. + * + * On a phone, finding a note you already wrote is the most common thing after + * writing one, and burying it behind an icon costs a tap every time. The drawer + * lives inside it on the left, which is where every Android user reaches for + * navigation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + onMenu: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = GUTTER, vertical = 8.dp), + shape = RoundedCornerShape(SEARCH_RADIUS), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 0.dp, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onMenu) { + Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open)) + } + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.search_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = {}), + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_clear)) + } + } else { + Icon( + Icons.Filled.Search, + contentDescription = null, + modifier = Modifier.padding(end = 12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -144,20 +180,118 @@ private fun NoteCard(note: Note) { } @Composable -private fun EmptyBoard() { +private fun NavigationDrawer( + current: Destination, + labels: List