From 64e016f32d124d7e483589dc87b54e9c69f5dba4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 09:23:00 -0400 Subject: [PATCH] android: phone-shaped chrome and the real note card (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things at once, because they answer one question: what should this look like, and what should it look like ON A PHONE. IDENTITY IS SHARED, INTERACTION IS NOT. The card now renders exactly what the web and desktop render — note colour, checklists, label chips, reminders — using the same palette values, so a note looks like your note on every surface. The chrome does not: the desktop's title bar and sidebar are wrong for a thumb. * NoteTint.kt carries the Tailwind colours from frontend/src/notes/colors.ts VALUE FOR VALUE, generated from tailwindcss 3.4 rather than eyeballed. Dark tints keep the web's alpha (dark:bg-*-950/40) instead of a precomputed blend, because Compose composites translucency over the background exactly as CSS does. * Dynamic colour is GONE. It was the more Android-native choice and it made the app look like a different product — on a stock emulator with no wallpaper it renders as undifferentiated grey, which is what the operator saw. Three peer surfaces share one identity; the brand #F5C518 is the same value the web manifest and the launcher icon already use. * The board is a two-column staggered grid, the Compose equivalent of the CSS multi-column NoteGrid.vue uses. PHONE ERGONOMICS, chosen with the operator: * Search IS the top bar. After writing a note, finding one is the most common thing you do, and burying it behind an icon costs a tap every time. Debounced 180ms and cancelled per keystroke — without that a fast typist queues one full-text query per character and results land out of order. * A + button is the only way in. One obvious target beat a capture bar and a button competing for the same job. * Navigation moved into a drawer behind the search bar's menu icon, which is where archive/trash/labels/reminders now live. They had nowhere to go once search took the top bar, and would otherwise have been unreachable. * The compose sheet asks note-or-list up front. On a phone those are different typing tasks and switching halfway is worse than choosing at the start. A list takes one item per line — fast to type, versus a tap per row. Three new bindings the UI needed: search_notes, reminder_notes, list_labels. Search goes through the CORE so "what matches" cannot drift between surfaces; filtering the loaded list in Kotlin would have been less code and a different product. reminder_notes is its own call because the core models it that way — "has a reminder" cuts across archived and active alike. Empty states are per-destination. "Nothing here yet" is encouraging on an empty board, wrong in Trash, and misleading after a search where the notes exist but did not match. Verified locally before pushing: bindings generated from a host .so and read back, ktlint and detekt clean from the image's pinned CLIs, cargo fmt/clippy/test green (107 tests). Two detekt findings were fixed by extraction rather than by relaxing the rules — this is the first Compose code in the repo and the thresholds should have to earn their exceptions. Still unbuilt: tapping a card does nothing. The editor is next. Co-Authored-By: Claude Opus 5 (1M context) --- android/app/build.gradle.kts | 1 + .../fabledsword/thoughtsync/MainActivity.kt | 24 +- .../fabledsword/thoughtsync/ui/BoardScreen.kt | 399 ++++++++++++------ .../thoughtsync/ui/BoardViewModel.kt | 201 +++++++-- .../thoughtsync/ui/ComposeSheet.kt | 172 ++++++++ .../fabledsword/thoughtsync/ui/NoteCard.kt | 209 +++++++++ .../fabledsword/thoughtsync/ui/NoteTint.kt | 177 ++++++++ .../com/fabledsword/thoughtsync/ui/Theme.kt | 78 ++-- android/app/src/main/res/values/strings.xml | 39 +- android/ffi/src/lib.rs | 30 +- android/ffi/src/models.rs | 29 ++ android/gradle/libs.versions.toml | 4 + 12 files changed, 1174 insertions(+), 189 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteTint.kt 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