android: phone-shaped chrome and the real note card (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m2s

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 09:23:00 -04:00
co-authored by Claude Opus 5
parent eb3dc3d893
commit 64e016f32d
12 changed files with 1174 additions and 189 deletions
+1
View File
@@ -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)
@@ -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
},
)
}
}
}
}
@@ -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<Note>) {
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<Label>,
onOpen: (Destination) -> Unit,
) {
ModalDrawerSheet {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 28.dp, top = 24.dp, bottom = 16.dp),
)
listOf(Destination.Notes, Destination.Reminders).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
if (labels.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
Text(
text = stringResource(R.string.nav_labels),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
)
labels.forEach { label ->
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
DrawerRow(destination, current, onOpen)
}
}
}
}
@Composable
private fun DrawerRow(
destination: Destination,
current: Destination,
onOpen: (Destination) -> Unit,
) {
NavigationDrawerItem(
label = { Text(destination.title) },
selected = destination == current,
onClick = { onOpen(destination) },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
/**
* The board: a two-column masonry, matching the web and desktop.
*
* Staggered rather than a uniform grid because notes are wildly different heights
* — a one-line thought beside a twelve-item checklist — and forcing them to a
* common height either clips the long ones or strands whitespace under the short
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
*/
@Composable
private fun NoteBoard(notes: List<Note>) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
modifier = Modifier.fillMaxSize(),
// Bottom padding clears the FAB, so the last note is never trapped under it.
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Keyed by id so Compose reuses cards across a refresh rather than
// rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker.
items(items = notes, key = { it.id }) { note -> NoteCard(note) }
}
}
/**
* The empty state, which has to say something DIFFERENT per destination.
*
* "Nothing here yet" is encouraging on an empty board and wrong in Trash, where it
* should read as reassurance, and misleading after a search, where the notes exist
* but did not match.
*/
@Composable
private fun EmptyBoard(state: BoardState) {
val (title, body) =
when {
state.searching ->
stringResource(R.string.empty_search_title) to
stringResource(R.string.empty_search_body, state.query)
state.destination == Destination.Trash ->
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
state.destination == Destination.Archive ->
stringResource(R.string.empty_archive_title) to stringResource(R.string.empty_archive_body)
state.destination == Destination.Reminders ->
stringResource(R.string.empty_reminders_title) to stringResource(R.string.empty_reminders_body)
else ->
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
}
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.board_empty_title),
style = MaterialTheme.typography.titleMedium,
)
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.board_empty_body),
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -169,7 +303,7 @@ private fun LoadingBoard() {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
CircularProgressIndicator(modifier = Modifier.size(32.dp))
}
}
@@ -178,27 +312,33 @@ private fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
}
val dark = isSystemInDarkTheme()
val tint = noteTint("red")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = GUTTER, vertical = 4.dp)
.clip(RoundedCornerShape(CARD_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
}
}
/**
* Shown when the store could not be opened at all.
*
* There is no retry: whatever stopped SQLite from opening will stop it again this
* launch. Saying so plainly beats a button that does nothing.
* No retry: whatever stopped SQLite opening will stop it again this launch. Saying
* so plainly beats a button that does nothing.
*/
@Composable
fun StoreUnavailableScreen(reason: String?) {
@@ -215,9 +355,12 @@ fun StoreUnavailableScreen(reason: String?) {
Text(
text = reason ?: stringResource(R.string.store_unavailable_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private const val MAX_CAPTURE_LINES = 5
private const val MAX_PREVIEW_LINES = 4
private const val BOARD_COLUMNS = 2
private val GUTTER = 12.dp
private val CARD_RADIUS = 12.dp
private val SEARCH_RADIUS = 28.dp
@@ -6,30 +6,72 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteDraft
import com.fabledsword.thoughtsync.core.NoteQuery
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which pile of notes the board is showing. Mirrors the desktop sidebar.
*
* A sealed type rather than a string so the `when` that loads them is exhaustive —
* adding a destination becomes a compile error at the loader instead of a silently
* empty board.
*/
sealed interface Destination {
val title: String
data object Notes : Destination {
override val title = "Notes"
}
data object Reminders : Destination {
override val title = "Reminders"
}
data object Archive : Destination {
override val title = "Archive"
}
data object Trash : Destination {
override val title = "Trash"
}
data class WithLabel(
val id: String,
override val title: String,
) : Destination
}
/** What kind of thing the compose sheet is making. */
enum class DraftKind { NOTE, LIST }
/** Everything the board renders from, in one immutable snapshot. */
data class BoardState(
val destination: Destination = Destination.Notes,
val notes: List<Note> = emptyList(),
val labels: List<Label> = emptyList(),
val query: String = "",
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
)
) {
/** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank()
}
/**
* Drives the board off the shared Rust core.
*
* Every core call is a BLOCKING FFI call — the store is synchronous SQLite behind
* a mutex — so they run on [Dispatchers.IO]. Doing otherwise would block the main
* thread on disk, which is exactly the jank a native client is supposed to avoid.
* (The sync methods are the exception: those are `suspend` on the Kotlin side
* already, because uniffi bridges the Rust async to coroutines.)
* Every store call is a BLOCKING FFI call — synchronous SQLite behind a mutex — so
* they run on [Dispatchers.IO]. Doing otherwise would block the main thread on
* disk, which is the jank a native client exists to avoid.
*/
class BoardViewModel(
private val core: ThoughtSync,
@@ -37,8 +79,25 @@ class BoardViewModel(
var state by mutableStateOf(BoardState())
private set
/**
* The in-flight search. Held so each keystroke cancels the previous one:
* without it, a fast typist queues one full-text query per character and the
* results arrive out of order, so the board can settle on a stale answer.
*/
private var searchJob: Job? = null
init {
refresh()
loadLabels()
}
fun open(destination: Destination) {
// Clearing the query is deliberate: picking Archive while a search is
// running should show the archive, not search results filtered by a box
// the user has visually moved on from.
searchJob?.cancel()
state = state.copy(destination = destination, query = "")
refresh()
}
fun refresh() {
@@ -46,47 +105,130 @@ class BoardViewModel(
state = state.copy(loading = true)
state =
try {
val notes = withContext(Dispatchers.IO) { core.listNotes(BOARD_QUERY) }
val notes = withContext(Dispatchers.IO) { load(state.destination) }
state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) {
// Broad by intent: the board must render something for any
// failure, and the core reports most problems as one error
// type carrying a message meant to be shown.
// failure, and the core reports problems as one error type
// carrying a message meant to be shown.
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
private fun load(destination: Destination): List<Note> =
when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
// Not a board view: the core models reminders as its own query, since
// "has a reminder" cuts across archived and active alike.
Destination.Reminders -> core.reminderNotes()
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
}
private fun query(
view: String,
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
private fun loadLabels() {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { state = state.copy(labels = it) }
// A drawer that cannot list labels is a degraded drawer, not a
// broken board — the notes are still there. Failing quietly here
// beats an error banner over working content.
.onFailure { state = state.copy(labels = emptyList()) }
}
}
fun search(text: String) {
state = state.copy(query = text)
searchJob?.cancel()
if (text.isBlank()) {
refresh()
return
}
searchJob =
viewModelScope.launch {
// Let the typing settle before hitting the store. Short enough to
// feel live, long enough that a whole word is one query.
delay(SEARCH_DEBOUNCE_MS)
state = state.copy(loading = true)
state =
try {
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
state.copy(notes = hits, loading = false, error = null)
} catch (e: Exception) {
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
/**
* Save a captured thought.
* Save a new note or list.
*
* Blank input is ignored rather than rejected with a message: an empty save is
* a slip, not a mistake worth interrupting someone over.
* Blank input is ignored rather than rejected: an empty save is a slip, not a
* mistake worth interrupting someone over.
*/
fun capture(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
fun create(
kind: DraftKind,
title: String,
content: String,
) {
val cleanTitle = title.trim()
val cleanContent = content.trim()
if (cleanTitle.isEmpty() && cleanContent.isEmpty()) return
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
// Title left empty on purpose — the core derives display_title
// from the first body line, so a captured thought is nameable
// without making the user name it. Same behaviour as the
// desktop's quick-add.
val draft = NoteDraft(title = "", body = trimmed, color = DEFAULT_COLOR, kind = null, items = null)
val created = withContext(Dispatchers.IO) { core.createNote(draft) }
// Prepend rather than re-query: the new note belongs at the top
// of an unsorted board, and a full reload would cost a round
// trip to tell us something we already know.
state.copy(notes = listOf(created) + state.notes, saving = false, error = null)
val created = withContext(Dispatchers.IO) { core.createNote(draft(kind, cleanTitle, cleanContent)) }
// Prepend rather than reload: the new note belongs at the top
// of the board, and a full re-query would cost a round trip to
// tell us what we already know. Skipped when the board is not
// showing plain notes — a note created while looking at Trash
// does not belong in that list.
val notes =
if (state.destination == Destination.Notes && !state.searching) {
listOf(created) + state.notes
} else {
state.notes
}
state.copy(notes = notes, saving = false, error = null)
} catch (e: Exception) {
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
private fun draft(
kind: DraftKind,
title: String,
content: String,
): NoteDraft =
when (kind) {
// Body left to carry the text; the core derives display_title from its
// first line when no title was given, so a captured thought is nameable
// without making the user name it.
DraftKind.NOTE ->
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null)
// One line per item. Fast to type on a phone and unambiguous, versus a
// row-by-row editor that costs a tap per entry.
DraftKind.LIST ->
NoteDraft(
title = title,
body = "",
color = DEFAULT_COLOR,
kind = KIND_LIST,
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
)
}
fun dismissError() {
state = state.copy(error = null)
}
@@ -94,9 +236,14 @@ class BoardViewModel(
companion object {
private const val DEFAULT_COLOR = "default"
private const val FALLBACK_ERROR = "Something went wrong."
private const val KIND_LIST = "list"
private const val SEARCH_DEBOUNCE_MS = 180L
/** The default board: everything not archived or trashed. */
private val BOARD_QUERY = NoteQuery(view = "notes", labelId = null, sort = null, facets = null)
// The core's board vocabulary. "archived", not "archive" — it matches on
// the former and silently falls through to the default board otherwise.
private const val VIEW_NOTES = "notes"
private const val VIEW_ARCHIVE = "archived"
private const val VIEW_TRASH = "trash"
fun factory(core: ThoughtSync): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@@ -0,0 +1,172 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
/**
* The new-note surface, opened by the + button.
*
* A bottom sheet rather than a full screen: capture should feel like a quick aside
* from the board, not a place you navigate to and have to come back from. The
* board stays visible behind it, so the note lands somewhere you can already see.
*
* It asks note-or-list up front rather than making that a mode you discover later,
* because on a phone the two are genuinely different typing tasks and switching
* halfway is worse than choosing at the start.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComposeSheet(
saving: Boolean,
onDismiss: () -> Unit,
onSave: (DraftKind, String, String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var kind by remember { mutableStateOf(DraftKind.NOTE) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
// Land in the body, not the title. Most captures are a thought, not a titled
// document, and making someone tab past an optional field is the difference
// between "under a second" and not.
LaunchedEffect(Unit) { contentFocus.requestFocus() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = kind == DraftKind.NOTE,
onClick = { kind = DraftKind.NOTE },
label = { Text(stringResource(R.string.compose_kind_note)) },
)
FilterChip(
selected = kind == DraftKind.LIST,
onClick = { kind = DraftKind.LIST },
label = { Text(stringResource(R.string.compose_kind_list)) },
)
}
SheetField(
value = title,
onValueChange = { title = it },
hint = R.string.compose_title_hint,
)
SheetField(
value = content,
onValueChange = { content = it },
hint =
if (kind == DraftKind.LIST) {
R.string.compose_list_hint
} else {
R.string.compose_body_hint
},
modifier = Modifier.focusRequester(contentFocus),
minLines = MIN_CONTENT_LINES,
)
SheetActions(
canSave = !saving && (title.isNotBlank() || content.isNotBlank()),
onCancel = onDismiss,
onSave = { onSave(kind, title, content) },
)
}
}
}
@Composable
private fun SheetActions(
canSave: Boolean,
onCancel: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) { Text(stringResource(R.string.compose_cancel)) }
Button(onClick = onSave, enabled = canSave) {
Text(stringResource(R.string.compose_save))
}
}
}
/**
* An undecorated field.
*
* The sheet is already a surface with its own edges; a filled field inside it
* draws a second box around the same content and makes a quick note feel like a
* form to fill in.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SheetField(
value: String,
onValueChange: (String) -> Unit,
@StringRes hint: Int,
modifier: Modifier = Modifier,
minLines: Int = 1,
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text(stringResource(hint)) },
// A one-line field is a single-line field; keeping both as separate knobs
// only invited call sites that set them to disagree.
singleLine = minLines == 1,
minLines = minLines,
colors = transparentFieldColors(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun transparentFieldColors() =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
private const val MIN_CONTENT_LINES = 4
@@ -0,0 +1,209 @@
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun NoteCard(note: Note) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(CARD_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// A title only renders when one was actually set. `displayTitle` is
// derived from the first body line when it wasn't, so printing both would
// show the same text twice.
note.title?.takeIf { it.isNotBlank() }?.let { title ->
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
}
if (note.kind == KIND_CHECKLIST) {
Checklist(items = note.items)
} else if (note.body.isNotBlank()) {
Text(
text = note.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_PREVIEW_LINES,
overflow = TextOverflow.Ellipsis,
)
}
// A note with no title, no body and no items still has to occupy the
// board legibly — otherwise it reads as a rendering bug.
if (note.title.isNullOrBlank() && note.body.isBlank() && note.items.isEmpty()) {
Text(
text = stringResource(R.string.board_empty_note),
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (note.labels.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LabelChips(labels = note.labels)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
}
}
@Composable
private fun Checklist(items: List<ChecklistItem>) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
Row(verticalAlignment = Alignment.Top) {
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
// a live control here would invite taps that the board cannot yet
// honour. It becomes interactive with the editor.
Text(
text = if (item.checked) "" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
Text(
text = item.text,
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
if (item.checked) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
val hidden = items.size - MAX_CHECKLIST_ROWS
if (hidden > 0) {
Text(
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme()
// A plain row that clips rather than wraps: a card with eight labels should
// not grow taller than its content. The editor shows the full set.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = noteTint(label.color)
Text(
text = label.name,
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
@Composable
private fun ReminderChip(
instant: String,
recurrence: String?,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
val text =
buildString {
append("")
append(formatReminder(instant))
if (!recurrence.isNullOrBlank()) append(" · ↻ ").also { append(recurrence) }
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/**
* Render an RFC3339 timestamp in the device's own locale and zone.
*
* The core stores and syncs RFC3339 because that is what SQLite holds and what the
* server speaks; deciding how a human should read it is the UI's job, and the
* answer differs per device. A string we cannot parse is shown verbatim rather
* than swallowed — a visibly odd reminder beats a silently missing one.
*/
private fun formatReminder(raw: String): String =
runCatching {
OffsetDateTime
.parse(raw)
.toLocalDateTime()
.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
}.getOrDefault(raw)
private const val KIND_CHECKLIST = "list"
private const val MAX_PREVIEW_LINES = 8
private const val MAX_CHECKLIST_ROWS = 8
private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CHIP_RADIUS = 6.dp
@@ -0,0 +1,177 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A note's colour is stored by the core as a key ("red", "teal", …) and every
* surface resolves it to its own tints. The web app resolves through Tailwind
* classes; this table is those same Tailwind colours as literals, so a note that
* is amber on the desktop is the same amber on the phone rather than a near-miss.
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
*
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
* precomputed blend — Compose composites a translucent colour over what's beneath
* exactly as CSS does, so the card sits on the background the same way in both.
*
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces.
*/
data class NoteTint(
val label: String,
val lightBackground: Color,
val lightBorder: Color,
val darkBackground: Color,
val darkBorder: Color,
val lightChipBackground: Color,
val lightChipForeground: Color,
val darkChipBackground: Color,
val darkChipForeground: Color,
) {
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
}
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
mapOf(
"default" to
NoteTint(
label = "Default",
lightBackground = Color(0xFFFFFFFF),
lightBorder = Color(0xFFE5E5E5),
darkBackground = Color(0xFF171717),
darkBorder = Color(0xFF404040),
lightChipBackground = Color(0x0D000000),
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
),
"red" to
NoteTint(
label = "Red",
lightBackground = Color(0xFFFEF2F2),
lightBorder = Color(0xFFFECACA),
darkBackground = Color(0x66450A0A),
darkBorder = Color(0xFF7F1D1D),
lightChipBackground = Color(0xFFFEE2E2),
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
),
"orange" to
NoteTint(
label = "Orange",
lightBackground = Color(0xFFFFF7ED),
lightBorder = Color(0xFFFED7AA),
darkBackground = Color(0x66431407),
darkBorder = Color(0xFF7C2D12),
lightChipBackground = Color(0xFFFFEDD5),
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
),
"yellow" to
NoteTint(
label = "Yellow",
lightBackground = Color(0xFFFFFBEB),
lightBorder = Color(0xFFFDE68A),
darkBackground = Color(0x66451A03),
darkBorder = Color(0xFF78350F),
lightChipBackground = Color(0xFFFEF3C7),
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
),
"green" to
NoteTint(
label = "Green",
lightBackground = Color(0xFFF0FDF4),
lightBorder = Color(0xFFBBF7D0),
darkBackground = Color(0x66052E16),
darkBorder = Color(0xFF14532D),
lightChipBackground = Color(0xFFDCFCE7),
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
),
"teal" to
NoteTint(
label = "Teal",
lightBackground = Color(0xFFF0FDFA),
lightBorder = Color(0xFF99F6E4),
darkBackground = Color(0x66042F2E),
darkBorder = Color(0xFF134E4A),
lightChipBackground = Color(0xFFCCFBF1),
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
),
"blue" to
NoteTint(
label = "Blue",
lightBackground = Color(0xFFEFF6FF),
lightBorder = Color(0xFFBFDBFE),
darkBackground = Color(0x66172554),
darkBorder = Color(0xFF1E3A8A),
lightChipBackground = Color(0xFFDBEAFE),
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
),
"purple" to
NoteTint(
label = "Purple",
lightBackground = Color(0xFFFAF5FF),
lightBorder = Color(0xFFE9D5FF),
darkBackground = Color(0x663B0764),
darkBorder = Color(0xFF581C87),
lightChipBackground = Color(0xFFF3E8FF),
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
),
"pink" to
NoteTint(
label = "Pink",
lightBackground = Color(0xFFFDF2F8),
lightBorder = Color(0xFFFBCFE8),
darkBackground = Color(0x66500724),
darkBorder = Color(0xFF831843),
lightChipBackground = Color(0xFFFCE7F3),
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
),
"gray" to
NoteTint(
label = "Gray",
lightBackground = Color(0xFFF5F5F5),
lightBorder = Color(0xFFD4D4D4),
darkBackground = Color(0xFF262626),
darkBorder = Color(0xFF404040),
lightChipBackground = Color(0xFFE5E5E5),
lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5),
),
)
/**
* Resolve a stored colour key.
*
* An unknown key falls back to `default` rather than throwing: colours are data
* that arrives from a server which may be newer than this client, and a note
* whose tint we don't recognise should still be readable.
*/
@Composable
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
@@ -1,55 +1,83 @@
package com.fabledsword.thoughtsync.ui
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// The brand colour, the same #F5C518 the web app's manifest and the adaptive icon
// use. One answer to "what colour is ThoughtSync" across all three surfaces.
// The brand colour: the same #F5C518 the web app's manifest, its <meta
// name="theme-color"> and the adaptive launcher icon all use.
private val Brand = Color(0xFFF5C518)
// Neutral surfaces lifted from the web app's palette so the three clients share a
// ground, not just an accent. style.css paints neutral-50 in light and neutral-950
// in dark, which is also what the desktop window is painted before the webview
// draws its first frame.
private val Neutral50 = Color(0xFFFAFAFA)
private val Neutral200 = Color(0xFFE5E5E5)
private val Neutral500 = Color(0xFF737373)
private val Neutral700 = Color(0xFF404040)
private val Neutral800 = Color(0xFF262626)
private val Neutral900 = Color(0xFF171717)
private val Neutral950 = Color(0xFF0A0A0A)
private val Ink = Color(0xFF1A1A1A)
private val LightColors =
lightColorScheme(
primary = Brand,
// Black on gold, not white: the brand colour is bright enough that white
// text on it fails contrast badly.
onPrimary = Color(0xFF1A1A1A),
// Black on gold, never white: the brand colour is bright enough that white
// text on it fails contrast outright.
onPrimary = Ink,
primaryContainer = Brand,
onPrimaryContainer = Ink,
background = Neutral50,
onBackground = Neutral900,
surface = Neutral50,
onSurface = Neutral900,
surfaceVariant = Neutral200,
onSurfaceVariant = Neutral700,
outline = Neutral500,
outlineVariant = Neutral200,
)
private val DarkColors =
darkColorScheme(
primary = Brand,
onPrimary = Color(0xFF1A1A1A),
onPrimary = Ink,
primaryContainer = Brand,
onPrimaryContainer = Ink,
background = Neutral950,
onBackground = Neutral50,
surface = Neutral950,
onSurface = Neutral50,
surfaceVariant = Neutral800,
onSurfaceVariant = Neutral200,
outline = Neutral500,
outlineVariant = Neutral700,
)
/**
* Material 3, following the system light/dark setting.
* Material 3 in ThoughtSync's own colours, following the system light/dark setting.
*
* Dynamic colour is used where the platform offers it (Android 12+), because a
* phone user's expectation is that apps take the wallpaper palette — and falls
* back to the brand scheme below that. The desktop makes the equivalent choice by
* reading the live window theme rather than hardcoding one.
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
* saw the first build. Dynamic colour is the more Android-native choice and it
* makes the app look like a different product on the phone than on the desktop and
* the web — on a stock device with no wallpaper it renders as undifferentiated
* grey. The three surfaces are peers held to one quality bar, so they share one
* identity; taking the wallpaper's palette instead would throw that away for
* platform convention.
*
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
*/
@Composable
fun ThoughtSyncTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colors =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colors, content = content)
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
content = content,
)
}
+32 -7
View File
@@ -2,15 +2,41 @@
<resources>
<string name="app_name">ThoughtSync</string>
<!-- Capture -->
<string name="capture_hint">Take a note</string>
<string name="capture_action">Save</string>
<!-- Search bar -->
<string name="search_hint">Search your notes</string>
<string name="search_clear">Clear search</string>
<string name="nav_open">Open navigation</string>
<string name="nav_labels">Labels</string>
<!-- Compose sheet -->
<string name="compose_open">New note</string>
<string name="compose_kind_note">Note</string>
<string name="compose_kind_list">List</string>
<string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_list_hint">One item per line</string>
<string name="compose_cancel">Cancel</string>
<string name="compose_save">Save</string>
<!-- Board -->
<string name="board_title">Notes</string>
<string name="board_empty_note">Empty note</string>
<plurals name="board_more_items">
<item quantity="one">+%d more item</item>
<item quantity="other">+%d more items</item>
</plurals>
<!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
<string name="board_empty_title">Nothing here yet</string>
<string name="board_empty_body">Your notes stay on this device. Connect a server later if you want them everywhere.</string>
<string name="board_untitled">Untitled</string>
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
<string name="empty_search_title">No matches</string>
<string name="empty_search_body">Nothing matched “%1$s”.</string>
<string name="empty_trash_title">Trash is empty</string>
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
<string name="empty_archive_title">Nothing archived</string>
<string name="empty_archive_body">Archived notes leave the board but stay searchable.</string>
<string name="empty_reminders_title">No reminders</string>
<string name="empty_reminders_body">Notes with a reminder set will appear here.</string>
<!-- Store failure -->
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
@@ -18,5 +44,4 @@
<!-- Errors -->
<string name="error_dismiss">Dismiss</string>
<string name="error_retry">Try again</string>
</resources>
+29 -1
View File
@@ -43,7 +43,7 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{
patch_from, Identity, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome,
patch_from, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery, ProbeResult, RevokeOutcome,
SyncOutcome, SyncStatus,
};
@@ -170,6 +170,34 @@ impl ThoughtSync {
.map_err(CoreError::store)
}
/// Full-text search across titles, bodies and checklist items.
///
/// The core owns the query — it searches the same columns the desktop and web
/// search, so "what matches" cannot drift between surfaces. Filtering the
/// board list in Kotlin would have been less code and a different product.
pub fn search_notes(&self, query: String) -> Result<Vec<Note>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let notes = local::store::search(&conn, &query).map_err(CoreError::store)?;
Ok(notes.into_iter().map(Note::from).collect())
}
/// Notes carrying a reminder, soonest first.
///
/// A dedicated call rather than a board `view`, because that is how the core
/// models it — `list_notes` only understands trashed/archived/default.
pub fn reminder_notes(&self) -> Result<Vec<Note>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let notes = local::store::reminders(&conn).map_err(CoreError::store)?;
Ok(notes.into_iter().map(Note::from).collect())
}
/// Every label with its note count, for the navigation drawer.
pub fn list_labels(&self) -> Result<Vec<Label>, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let labels = local::store::list_labels(&conn).map_err(CoreError::store)?;
Ok(labels.into_iter().map(Label::from).collect())
}
pub fn trash_note(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::trash(&conn, &id)
+29
View File
@@ -213,6 +213,35 @@ impl From<core_models::LinkPreview> for LinkPreview {
}
}
/// A label, as the sidebar lists them.
#[derive(Debug, Clone, uniffi::Record)]
pub struct Label {
pub id: String,
pub name: String,
/// Same colour vocabulary as notes, so one palette serves both.
pub color: String,
/// How many notes carry it. Only populated in listings — `None` elsewhere,
/// matching the REST single-label responses.
pub count: Option<i64>,
}
impl From<core_models::Label> for Label {
fn from(value: core_models::Label) -> Self {
let core_models::Label {
id,
name,
color,
count,
} = value;
Label {
id,
name,
color,
count,
}
}
}
// ───────────────────────────── queries and edits ─────────────────────────────
/// What the board is asking for. Mirrors the core's `ListQuery`.
+4
View File
@@ -37,6 +37,10 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
# Icons only from -core, deliberately: it carries the common set (Menu, Search,
# Close, Add) and is already on the material3 path. -extended adds ~1,000 vectors
# for the handful the drawer would use.
compose-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
junit = { module = "junit:junit", version.ref = "junit" }