android: phone-shaped chrome and the real note card (M12 step 6)
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user