M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
17 changed files with 2211 additions and 176 deletions
Showing only changes of commit cf0ce382a0 - Show all commits
@@ -4,14 +4,17 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
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.lifecycle.viewmodel.compose.viewModel
import com.fabledsword.thoughtsync.core.ThoughtSync
import com.fabledsword.thoughtsync.ui.BoardScreen
import com.fabledsword.thoughtsync.ui.BoardViewModel
import com.fabledsword.thoughtsync.ui.ComposeSheet
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
@@ -31,31 +34,63 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
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,
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
},
)
}
App(core)
}
}
}
}
}
/**
* The whole app, once the store is open.
*
* Board or editor, never both: the editor is a full screen, so composing the board
* behind it would keep a two-column grid measuring and recomposing under something
* that entirely covers it.
*
* No navigation library. There are exactly two destinations and the back gesture
* is handled by the editor itself — a nav graph here would be ceremony around a
* single nullable, and the editor's state already lives in the view model where a
* process death can restore it.
*/
@Composable
private fun App(core: ThoughtSync) {
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) }
val editing = model.state.editing
if (editing != null) {
NoteEditorScreen(
note = editing,
labels = model.state.labels,
saving = model.state.saving,
error = model.state.error,
// The one seam between the editor and the store. Exhaustive at the
// other end, so a new action cannot be added without being handled.
onAction = { model.onEditorAction(editing, it) },
)
return
}
BoardScreen(
state = model.state,
onOpen = model::open,
onOpenNote = model::openNote,
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,8 +1,5 @@
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
@@ -41,16 +38,11 @@ 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.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
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.unit.dp
@@ -63,6 +55,7 @@ import kotlinx.coroutines.launch
fun BoardScreen(
state: BoardState,
onOpen: (Destination) -> Unit,
onOpenNote: (Note) -> Unit,
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onDismissError: () -> Unit,
@@ -110,7 +103,7 @@ fun BoardScreen(
when {
state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state)
else -> NoteBoard(notes = state.notes)
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
}
}
}
@@ -147,21 +140,17 @@ private fun SearchBar(
IconButton(onClick = onMenu) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
}
TextField(
PlainTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.search_hint)) },
hint = R.string.search_hint,
singleLine = true,
// The search key is decorative here: results already land as you
// type, so pressing it should dismiss the keyboard and change
// nothing, which is what an empty handler does.
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("") }) {
@@ -241,7 +230,10 @@ private fun DrawerRow(
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
*/
@Composable
private fun NoteBoard(notes: List<Note>) {
private fun NoteBoard(
notes: List<Note>,
onOpenNote: (Note) -> Unit,
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
modifier = Modifier.fillMaxSize(),
@@ -253,7 +245,9 @@ private fun NoteBoard(notes: List<Note>) {
// 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) }
items(items = notes, key = { it.id }) { note ->
NoteCard(note = note, onOpen = { onOpenNote(note) })
}
}
}
@@ -307,33 +301,6 @@ private fun LoadingBoard() {
}
}
@Composable
private fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
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.
*
@@ -362,5 +329,4 @@ fun StoreUnavailableScreen(reason: String?) {
private const val BOARD_COLUMNS = 2
private val GUTTER = 12.dp
private val CARD_RADIUS = 12.dp
private val SEARCH_RADIUS = 28.dp
@@ -9,6 +9,7 @@ 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.NoteEdit
import com.fabledsword.thoughtsync.core.NoteQuery
import com.fabledsword.thoughtsync.core.ThoughtSync
import kotlinx.coroutines.Dispatchers
@@ -61,6 +62,15 @@ data class BoardState(
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
/**
* The note the editor is open on, or null for the board.
*
* The NOTE and not its id, so the editor always renders from the same object
* the store last returned. Every mutation hands back the reloaded note, so
* ticking a box or picking a colour updates this in place and the editor never
* has to re-query to see its own change.
*/
val editing: Note? = null,
) {
/** Search overrides the destination while there is a query to run. */
val searching: Boolean get() = query.isNotBlank()
@@ -72,7 +82,15 @@ data class BoardState(
* 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.
*
* ONE view model for both screens, over detekt's objection. The obvious split —
* a second one for the editor — fails on the fact that every editor mutation has
* to reload the board behind it, so the editor's view model would need a
* reference back into this one and the two would share the note list anyway. What
* is left is a dozen small functions around a single coherent state machine,
* which is what the suppression says rather than hides.
*/
@Suppress("TooManyFunctions")
class BoardViewModel(
private val core: ThoughtSync,
) : ViewModel() {
@@ -127,11 +145,6 @@ class BoardViewModel(
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() } }
@@ -206,37 +219,187 @@ class BoardViewModel(
}
}
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() },
// ─────────────────────────────── the editor ──────────────────────────────
fun openNote(note: Note) {
state = state.copy(editing = note)
}
/**
* Apply one editor action to the note the editor is open on.
*
* The `when` is exhaustive by construction, so adding a variant to
* [EditorAction] breaks THIS function until it is handled — which is the whole
* reason the editor speaks in actions rather than through a bundle of
* callbacks. The note is passed in rather than read from `state.editing` so a
* mutation that lands between a tap and its dispatch cannot redirect the
* action at a different note.
*
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
* total function over it is twenty branches and sixty-odd lines no matter how
* it is written. Splitting it into sub-dispatchers is the only way to shorten
* it, and each of those would need an `else` — which throws away precisely the
* exhaustiveness this shape exists for. Suppressed rather than worked around,
* because the rules are measuring the action type's size, not this function's.
*/
@Suppress("CyclomaticComplexMethod", "LongMethod")
fun onEditorAction(
note: Note,
action: EditorAction,
) {
val id = note.id
when (action) {
EditorAction.Close -> state = state.copy(editing = null)
EditorAction.DismissError -> dismissError()
// Text is the only edit that batches: title and body are typed
// together and saved together on close, so they cost one write and
// one revision snapshot rather than two of each.
is EditorAction.SaveText ->
mutate {
it.updateNote(
id,
listOf(
// An emptied title CLEARS the column rather than
// storing "". The core derives `display_title` from
// the first body line when the title is null, so the
// difference is whether an untitled note is nameable
// or blank — exactly what `ClearTitle` exists for.
if (action.title.isBlank()) {
NoteEdit.ClearTitle
} else {
NoteEdit.Title(action.title.trim())
},
NoteEdit.Body(action.body),
),
)
}
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
EditorAction.ToggleKind ->
edit(id, NoteEdit.Kind(if (note.kind == KIND_LIST) KIND_TEXT else KIND_LIST))
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open.
is EditorAction.SetPinned -> edit(id, NoteEdit.Pinned(action.pinned))
// Archiving, trashing and restoring all take the note out of the list
// you were looking at, so the editor closes behind them: staying open
// on a note that has visibly left the board reads as a bug.
is EditorAction.SetArchived ->
mutate(closeEditor = true) {
it.updateNote(id, listOf(NoteEdit.Archived(action.archived)))
}
EditorAction.Trash -> mutate(closeEditor = true) { it.trashNote(id) }
EditorAction.Restore -> mutate(closeEditor = true) { it.restoreNote(id) }
EditorAction.DeleteForever ->
mutate(closeEditor = true) {
it.deleteNoteForever(id)
// Nothing to hand back — the row is gone. The board reload
// inside `mutate` is what makes it disappear.
null
}
is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) }
}
is EditorAction.SetItemChecked ->
mutate { it.setItemChecked(id, action.itemId, action.checked) }
is EditorAction.SetItemText ->
mutate { it.setItemText(id, action.itemId, action.text) }
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
is EditorAction.CreateLabel ->
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
mutate {
val label = it.createLabel(name)
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
it.setNoteLabels(id, (manual + label.id).distinct())
}
// The drawer lists labels with their note counts, and both
// just changed.
loadLabels()
}
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
EditorAction.ClearReminder -> edit(id, NoteEdit.ClearRemindAt)
EditorAction.CompleteReminder -> mutate { it.completeReminder(id) }
is EditorAction.SnoozeReminder -> mutate { it.snoozeReminder(id, action.minutes) }
is EditorAction.SetRecurrence ->
edit(
id,
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
)
}
}
/** The common case: one field-level edit to one note. */
private fun edit(
id: String,
change: NoteEdit,
) = mutate { it.updateNote(id, listOf(change)) }
/**
* The one path every store mutation takes.
*
* Each core mutation returns the reloaded note, which goes straight into
* [BoardState.editing] so an open editor shows its own change without a
* re-query. The BOARD list is then reloaded rather than patched in place:
* pinning re-sorts it, archiving removes the note from it, and adding a label
* can move it in or out of a label view — a splice would have to reimplement
* the core's ordering and membership rules in Kotlin to get any of that right.
* The reload is a local SQLite query, so it costs less than the code that would
* avoid it.
*
* Quiet, deliberately: no spinner, because the board is already on screen with
* correct-until-a-moment-ago content, and flashing it empty would be a worse
* lie than showing it one frame stale.
*
* Search results are left alone — they are the answer to a query, not a live
* view, and re-running the board query underneath them would replace the hits
* with the whole board.
*/
private fun mutate(
closeEditor: Boolean = false,
block: (ThoughtSync) -> Note?,
) {
viewModelScope.launch {
state = state.copy(saving = true)
state =
try {
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
if (state.searching) {
state.notes
} else {
withContext(Dispatchers.IO) { load(state.destination) }
}
state.copy(
notes = notes,
editing = if (closeEditor) null else updated ?: state.editing,
saving = false,
error = null,
)
} catch (e: Exception) {
// Broad by intent, as elsewhere: the core reports every failure
// as one error type carrying a message meant to be shown, and a
// half-applied edit must still leave a usable screen.
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
}
}
}
fun dismissError() {
state = state.copy(error = null)
}
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 core's board vocabulary. "archived", not "archive" — it matches on
@@ -252,3 +415,41 @@ class BoardViewModel(
}
}
}
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
// from arguments. Kept at file scope so the class above holds only things that
// actually depend on it — which is also what keeps its function count meaningful.
private fun query(
view: String,
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
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. At CAPTURE time the whole list is already in your
// head, so typing it in one go beats a tap between each row; the editor
// has the per-row control for when the list is being revised instead.
DraftKind.LIST ->
NoteDraft(
title = title,
body = "",
color = DEFAULT_COLOR,
kind = KIND_LIST,
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
)
}
@@ -1,6 +1,5 @@
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
@@ -14,8 +13,6 @@ 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
@@ -26,7 +23,6 @@ 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
@@ -83,22 +79,23 @@ fun ComposeSheet(
)
}
SheetField(
PlainTextField(
value = title,
onValueChange = { title = it },
hint = R.string.compose_title_hint,
singleLine = true,
)
SheetField(
PlainTextField(
value = content,
onValueChange = { content = it },
modifier = Modifier.focusRequester(contentFocus),
hint =
if (kind == DraftKind.LIST) {
R.string.compose_list_hint
} else {
R.string.compose_body_hint
},
modifier = Modifier.focusRequester(contentFocus),
minLines = MIN_CONTENT_LINES,
)
@@ -128,45 +125,4 @@ private fun SheetActions(
}
}
/**
* 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,121 @@
package com.fabledsword.thoughtsync.ui
/**
* Everything the editor can ask for, as one type.
*
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
* here breaks the dispatcher until it is handled, which is precisely the guarantee
* the callback bundle could not offer.
*
* No variant carries a note id. The editor is open on exactly one note and the
* dispatcher already has it, so threading it through every action would only
* create the possibility of the two disagreeing.
*/
sealed interface EditorAction {
/** Leave the editor. Text is saved separately, via [SaveText], before this. */
data object Close : EditorAction
/** Clear the error banner. Shared state — the board shows the same one. */
data object DismissError : EditorAction
data class SaveText(
val title: String,
val body: String,
) : EditorAction
data class SetColor(
val color: String,
) : EditorAction
/**
* Note ⇄ checklist.
*
* Only `kind` changes: the body text and any existing items both stay where
* they are, so switching back and forth is lossless and a mis-tap costs
* nothing.
*/
data object ToggleKind : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
data class SetArchived(
val archived: Boolean,
) : EditorAction
data object Trash : EditorAction
data object Restore : EditorAction
data object DeleteForever : EditorAction
data class AddItem(
val text: String,
) : EditorAction
data class SetItemChecked(
val itemId: String,
val checked: Boolean,
) : EditorAction
data class SetItemText(
val itemId: String,
val text: String,
) : EditorAction
data class DeleteItem(
val itemId: String,
) : EditorAction
/**
* The note's MANUAL labels, replacing whatever was there.
*
* `#tag` labels must never appear in this list. They are owned by the body
* text and the core re-derives them on every body edit — see
* `set_note_labels` in the FFI crate.
*/
data class SetLabels(
val labelIds: List<String>,
) : EditorAction
/**
* Create a label and attach it to this note in one gesture.
*
* Typing a new label in the picker and then having to tick it as well would
* be two steps for one intention. The core finds-or-creates, so typing the
* name of a label that already exists simply attaches that one.
*/
data class CreateLabel(
val name: String,
) : EditorAction
/** `at` is an RFC3339 instant — see `Time.kt` for why the UI writes it. */
data class SetReminder(
val at: String,
) : EditorAction
data object ClearReminder : EditorAction
/**
* Mark the reminder dealt with.
*
* Distinct from [ClearReminder] even though the core does the same thing to
* the column today: this is where recurrence advancement lands when it is
* built, so a recurring reminder finished through the generic clear would
* silently stop recurring.
*/
data object CompleteReminder : EditorAction
data class SnoozeReminder(
val minutes: Long,
) : EditorAction
/** null is "does not repeat". */
data class SetRecurrence(
val rule: String?,
) : EditorAction
}
@@ -0,0 +1,144 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.ChecklistItem
import com.fabledsword.thoughtsync.core.Note
/**
* The checklist, with real checkboxes this time.
*
* The card renders glyphs because it is a preview; here every row is live. This is
* the other half of the answer to how a list gets typed on a phone: the capture
* sheet takes a whole list at once, one item per line, because at capture time the
* list is already in your head and a tap per row would be the slow part. The
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
* where the per-row control lives.
*
* No empty state: a checklist with no items already shows the add row with its
* hint, which says the same thing an empty state would and can be typed into.
*/
@Composable
fun ChecklistEditor(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column {
note.items.forEach { item ->
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
}
if (!readOnly) {
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) })
}
}
}
/**
* One row: a live checkbox, editable text, and a remove button.
*
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
* store write that reloads the note, so per-keystroke saving would both hammer
* SQLite and race the reload against the next character.
*/
@Composable
private fun ChecklistRow(
item: ChecklistItem,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
// text being typed here.
var text by remember(item.id) { mutableStateOf(item.text) }
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
enabled = !readOnly,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier =
Modifier
.weight(1f)
.onFocusChanged { if (!it.isFocused) commit() },
enabled = !readOnly,
singleLine = true,
textStyle =
MaterialTheme.typography.bodyLarge.copy(
// Struck through when done, matching the card and the web.
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { commit() }),
)
if (!readOnly) {
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_item),
)
}
}
}
}
/**
* The always-present row at the bottom for adding an item.
*
* It clears but keeps focus after a submit, so a list can be typed straight
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
* is the same speed the capture sheet's one-item-per-line field buys, carried into
* the editor so refining a list never feels slower than making one.
*/
@Composable
private fun AddItemRow(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Add,
contentDescription = null,
modifier = Modifier.padding(horizontal = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
PlainTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
hint = R.string.editor_add_item,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAdd(text)
text = ""
}),
)
}
}
@@ -0,0 +1,268 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Note
/**
* The editor's action bar, at the bottom where a thumb already is.
*
* The three affordances with a permanent slot are the ones reached for while still
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
* archive, delete) is one tap further into the overflow, where it is spelled out
* in WORDS.
*
* That split is a deliberate trade against icon-guessing. `material-icons-core`
* carries no pin, archive or label glyph, and the two ways out were pulling in the
* ~1,000-vector extended set for four icons, or pressing unrelated ones into
* service — a star meaning "pin" is a star meaning "favourite" to everyone who has
* used another app. Text says exactly what it does and reads correctly aloud.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditorBottomBar(
note: Note,
readOnly: Boolean,
tint: NoteTint,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
BottomAppBar(containerColor = tint.background(dark)) {
if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
contentDescription = stringResource(R.string.editor_reminder),
)
}
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) {
val list = note.kind == KIND_LIST
Icon(
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List,
contentDescription =
stringResource(
if (list) R.string.editor_make_note else R.string.editor_make_list,
),
)
}
}
Box(modifier = Modifier.weight(1f))
OverflowMenu(
note = note,
readOnly = readOnly,
onPicker = onPicker,
onConfirmDelete = onConfirmDelete,
onAction = onAction,
)
}
}
@Composable
private fun OverflowMenu(
note: Note,
readOnly: Boolean,
onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
var open by remember { mutableStateOf(false) }
val close = { open = false }
Box {
IconButton(onClick = { open = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
}
DropdownMenu(expanded = open, onDismissRequest = close) {
if (readOnly) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
}
}
}
}
@Composable
private fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
// Close BEFORE acting. An overflow menu left hanging over the sheet
// that just opened underneath it is the classic version of this bug,
// and doing it here means no call site can forget.
onClose()
onClick()
},
)
}
/**
* The note's labels, each removable.
*
* `#tag` labels get no remove button: they are owned by the body text and the core
* re-derives them on the next edit, so a cross that undid itself a second later
* would look broken. The way to remove one is to delete the tag from the text,
* which is what the trailing note says.
*/
@Composable
fun EditorLabelRow(
note: Note,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
Column(modifier = Modifier.padding(top = 12.dp)) {
note.labels.forEach { label ->
val tint = noteTint(label.color)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
Text(
text = label.name,
style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark),
modifier =
Modifier
.clip(CircleShape)
.background(tint.chipBackground(dark))
.padding(horizontal = 10.dp, vertical = 4.dp),
)
if (label.viaTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
} else if (!readOnly) {
IconButton(onClick = {
// Only the MANUAL labels are sent: the core replaces
// exactly those, and including a tag label here would ask
// it to own something the body text already owns.
val kept =
note.labels
.filterNot { it.viaTag || it.id == label.id }
.map { it.id }
onAction(EditorAction.SetLabels(kept))
}) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.editor_remove_label),
)
}
}
}
}
}
}
/**
* The set reminder, with the one-tap actions beside it.
*
* Done / 1h / 1d are the same three the web editor offers, for the same reason:
* when a reminder surfaces, the answer is almost always "handled" or "not yet",
* and making either of those cost a trip through the date picker is how a reminder
* ends up ignored instead of dealt with.
*/
@Composable
fun EditorReminderRow(
at: String,
recurrence: String?,
readOnly: Boolean,
onAction: (EditorAction) -> Unit,
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = reminderLabel(at, recurrence),
style = MaterialTheme.typography.labelLarge,
color =
if (isPast(at)) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (!readOnly) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onAction(EditorAction.CompleteReminder) }) {
Text(stringResource(R.string.reminder_done))
}
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_HOUR)) }) {
Text(stringResource(R.string.reminder_snooze_hour))
}
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_DAY)) }) {
Text(stringResource(R.string.reminder_snooze_day))
}
}
}
}
}
private val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -0,0 +1,468 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
// The three things you pick rather than type: a colour, a set of labels, a time.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at.
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSheet(
selected: String,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.color_picker_title)
// Chunked into fixed rows rather than a flow layout: ten swatches
// always lay out as two rows of five on every phone width, and a flow
// would reshuffle them between devices for no gain.
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
row.forEach { (key, tint) ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(SWATCH_SIZE)
.clip(CircleShape)
.background(tint.background(dark))
.border(
// The selected swatch gets a heavier ring
// as well as a tick: on the pale tints the
// tick alone is nearly invisible.
if (key == selected) 2.dp else 1.dp,
if (key == selected) {
MaterialTheme.colorScheme.primary
} else {
tint.border(dark)
},
CircleShape,
).clickable(onClickLabel = tint.label) { onPick(key) },
) {
if (key == selected) {
Icon(
Icons.Filled.Check,
contentDescription = tint.label,
modifier = Modifier.size(18.dp),
)
}
}
}
// Pad a short final row so its swatches line up with the row
// above instead of spreading across the full width.
repeat(SWATCHES_PER_ROW - row.size) {
Box(modifier = Modifier.size(SWATCH_SIZE))
}
}
}
}
}
}
/**
* Every label, ticked where it is on the note.
*
* `#tag` labels appear ticked and disabled — they are true of the note, and they
* are owned by its text, so showing them unticked would be a lie and letting them
* be unticked would be a control that undoes itself.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelSheet(
note: Note,
labels: List<Label>,
onAction: (EditorAction) -> Unit,
onDismiss: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
val manual =
note.labels
.filterNot { it.viaTag }
.map { it.id }
.toSet()
val viaTag =
note.labels
.filter { it.viaTag }
.map { it.id }
.toSet()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
) {
SheetTitle(R.string.label_picker_title)
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
)
if (fromTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
}
/**
* When to be reminded.
*
* Presets first, and a full picker behind them. On a phone almost every reminder
* is "this evening", "tomorrow morning" or "next week" — the web's raw
* `datetime-local` field is the right control for a desktop and three taps too
* many for the common case here. The exact picker is still there, one tap down,
* because "Thursday at 3" is a real thing to want.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReminderSheet(
note: Note,
onAction: (EditorAction) -> Unit,
onDismiss: () -> Unit,
) {
var exact by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.reminder_title)
reminderPresets().forEach { (labelRes, at) ->
Text(
text = "${stringResource(labelRes)} · ${formatReminder(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
Text(
text = stringResource(R.string.reminder_pick),
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
)
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
}
}
if (exact) {
ExactReminderPicker(
initial = note.remindAt?.let { localTime(it) } ?: defaultPickerTime(),
onPick = {
onAction(EditorAction.SetReminder(rfc3339(it)))
exact = false
onDismiss()
},
onDismiss = { exact = false },
)
}
}
/**
* Date then time, as two dialogs.
*
* Material 3 ships a date picker and a time picker but nothing that does both, and
* a phone screen has no room for them side by side. Sequential also matches how
* the choice is actually made — you know the day before you know the hour.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ExactReminderPicker(
initial: LocalDateTime,
onPick: (LocalDateTime) -> Unit,
onDismiss: () -> Unit,
) {
var date by remember { mutableStateOf<LocalDate?>(null) }
if (date == null) {
val state =
rememberDatePickerState(
initialSelectedDateMillis =
initial
.toLocalDate()
.atStartOfDay(ZoneId.of("UTC"))
.toInstant()
.toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
// Nothing selected means nothing to confirm — the picker opens
// on a date, so this only guards a user who cleared it.
enabled = state.selectedDateMillis != null,
onClick = {
// The picker reports UTC midnight of the CALENDAR day that
// was tapped, so it has to be read back in UTC. Reading it
// in the device's zone shifts the date by one west of
// Greenwich — the classic off-by-a-day in this control.
date =
state.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate()
}
},
) { Text(stringResource(R.string.picker_next)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
) {
DatePicker(state = state)
}
} else {
val state =
rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.picker_time_title)) },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = {
onPick(
LocalDateTime.of(
requireNotNull(date) { "the time step is only reachable with a date" },
LocalTime.of(state.hour, state.minute),
),
)
}) { Text(stringResource(R.string.picker_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
)
}
}
@Composable
private fun RecurrenceChips(
current: String?,
onPick: (String?) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
RECURRENCE_RULES.forEach { (rule, labelRes) ->
FilterChip(
selected = current.orEmpty() == rule.orEmpty(),
onClick = { onPick(rule) },
label = { Text(stringResource(labelRes)) },
)
}
}
}
@Composable
private fun SheetTitle(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
/**
* The presets, computed against the device clock at the moment the sheet opens.
*
* "Later today" disappears once the evening has passed rather than silently
* meaning tomorrow — an offer that quietly does something else is worse than one
* that isn't there.
*/
private fun reminderPresets(): List<Pair<Int, LocalDateTime>> {
val now = LocalDateTime.now()
val presets = mutableListOf<Pair<Int, LocalDateTime>>()
val evening = now.toLocalDate().atTime(EVENING_HOUR, 0)
if (evening.isAfter(now)) {
presets += R.string.reminder_later_today to evening
}
presets += R.string.reminder_tomorrow to now.toLocalDate().plusDays(1).atTime(MORNING_HOUR, 0)
presets +=
R.string.reminder_next_week to
now
.toLocalDate()
.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
.atTime(MORNING_HOUR, 0)
return presets
}
/** Where the exact picker opens when the note has no reminder yet. */
private fun defaultPickerTime(): LocalDateTime =
LocalDateTime
.now()
.toLocalDate()
.plusDays(1)
.atTime(MORNING_HOUR, 0)
/** The core's recurrence vocabulary; null is "does not repeat". */
private val RECURRENCE_RULES: List<Pair<String?, Int>> =
listOf(
null to R.string.recurrence_none,
"daily" to R.string.recurrence_daily,
"weekly" to R.string.recurrence_weekly,
"monthly" to R.string.recurrence_monthly,
"yearly" to R.string.recurrence_yearly,
)
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -0,0 +1,54 @@
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.material3.TextButton
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.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
// Shared by the board and the editor.
//
// A failed save is most likely to happen WHILE the editor is open — that is where
// the writes are — so a banner only the board could render meant the one screen
// that needed it was the one screen without it.
@Composable
fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("red")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_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)) }
}
}
private val BANNER_RADIUS = 12.dp
@@ -2,6 +2,7 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -28,12 +29,12 @@ 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) {
fun NoteCard(
note: Note,
onOpen: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
@@ -41,7 +42,10 @@ fun NoteCard(note: Note) {
modifier =
Modifier
.fillMaxWidth()
// Clipped BEFORE clickable, so the ripple is bounded by the card's
// rounded corners instead of a rectangle overhanging them.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
@@ -60,7 +64,7 @@ fun NoteCard(note: Note) {
Spacer(Modifier.height(4.dp))
}
if (note.kind == KIND_CHECKLIST) {
if (note.kind == KIND_LIST) {
Checklist(items = note.items)
} else if (note.body.isNotBlank()) {
Text(
@@ -158,21 +162,23 @@ private fun LabelChips(labels: List<NoteLabel>) {
}
}
/**
* The reminder, red once it has passed.
*
* Red for overdue and neutral otherwise, matching the web card exactly — the same
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
* used to be blue for every reminder here, which made "you missed this" and
* "coming up on Friday" look identical on a board full of both.
*/
@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) }
}
val tint = noteTint(if (isPast(instant)) "red" else "default")
Text(
text = text,
text = reminderLabel(instant, recurrence),
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
@@ -185,23 +191,6 @@ private fun ReminderChip(
)
}
/**
* 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
@@ -0,0 +1,314 @@
package com.fabledsword.thoughtsync.ui
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
/**
* The note editor: a full screen, not a sheet.
*
* A sheet works for capture, where the board behind it is reassurance that the
* thought landed somewhere. Editing is different — a sustained task with the
* keyboard up — and a sheet would spend the whole time fighting the IME for the
* bottom half of the display. Full screen also gives the actions a bottom bar,
* which is where a thumb already is.
*
* The note's own colour paints the WHOLE screen rather than a card inside it, so
* opening a note reads as the same object growing to fill the display.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
note: Note,
labels: List<Label>,
saving: Boolean,
error: String?,
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(note.color)
// Keyed by note id: the editor is reused across notes, and without the key the
// second note opened would show the first one's text.
var title by remember(note.id) { mutableStateOf(note.title.orEmpty()) }
var body by remember(note.id) { mutableStateOf(note.body) }
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
// A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with
// Restore and Delete forever as the only things to do with it.
val readOnly = note.trashed
// Persist the text, if it changed. The baseline check is what makes "open a
// note, read it, back out" write nothing at all — without it every glance
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
// revision identical to the one before it.
val flush = {
if (!readOnly && (title != note.title.orEmpty() || body != note.body)) {
onAction(EditorAction.SaveText(title, body))
}
}
val leave = {
flush()
onAction(EditorAction.Close)
}
BackHandler(onBack = leave)
// Leaving the APP is not closing the editor, so the text has to be saved
// without the screen being torn down. Losing a paragraph to an incoming call
// is exactly the failure that makes someone stop trusting a notes app.
FlushOnStop(flush)
Scaffold(
containerColor = tint.background(dark),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = leave) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)),
)
},
bottomBar = {
EditorBottomBar(
note = note,
readOnly = readOnly,
tint = tint,
onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true },
onAction = onAction,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
// A one-pixel line, not a spinner: a save slow enough to see is worth
// showing, and one that isn't must not make the screen jump.
if (saving) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// A failed save has to be visible HERE. The board renders the same
// banner, but a write that fails while the editor is open would
// otherwise report itself only after the user had already left.
error?.let { message ->
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
}
EditorField(
value = title,
onValueChange = { title = it },
hint = R.string.editor_title_hint,
enabled = !readOnly,
bold = true,
)
if (note.kind == KIND_LIST) {
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
} else {
EditorField(
value = body,
onValueChange = { body = it },
hint = R.string.editor_body_hint,
enabled = !readOnly,
minLines = MIN_BODY_LINES,
)
}
if (note.labels.isNotEmpty()) {
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
}
note.remindAt?.let { at ->
EditorReminderRow(
at = at,
recurrence = note.recurrence,
readOnly = readOnly,
onAction = onAction,
)
}
}
}
EditorOverlays(
note = note,
labels = labels,
picker = picker,
onPicker = { picker = it },
onAction = onAction,
)
if (confirmingDelete) {
// The only irreversible action in the app earns the only confirmation in
// it. Everything else — archive, trash, even unlinking a server — undoes.
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
}) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) {
Text(stringResource(R.string.editor_cancel))
}
},
)
}
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable
private fun EditorOverlays(
note: Note,
labels: List<Label>,
picker: Picker,
onPicker: (Picker) -> Unit,
onAction: (EditorAction) -> Unit,
) {
val dismiss = { onPicker(Picker.NONE) }
when (picker) {
Picker.NONE -> Unit
Picker.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS ->
LabelSheet(
note = note,
labels = labels,
onAction = onAction,
onDismiss = dismiss,
)
Picker.REMINDER ->
ReminderSheet(
note = note,
onAction = onAction,
onDismiss = dismiss,
)
}
}
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*/
@Composable
private fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* The title and body fields.
*
* Undecorated, via the shared [PlainTextField]: the screen is already painted in
* the note's colour, and a filled field would draw a second surface over the first
* and turn a note into a form.
*/
@Composable
private fun EditorField(
value: String,
onValueChange: (String) -> Unit,
@StringRes hint: Int,
enabled: Boolean,
bold: Boolean = false,
minLines: Int = 1,
) {
PlainTextField(
value = value,
onValueChange = onValueChange,
hint = hint,
enabled = enabled,
// The title is one line by contract — it is a name, and a name that wraps
// has become a body. The body itself never is.
singleLine = bold,
minLines = minLines,
textStyle =
if (bold) {
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
} else {
MaterialTheme.typography.bodyLarge
},
)
}
private const val MIN_BODY_LINES = 6
@@ -0,0 +1,16 @@
package com.fabledsword.thoughtsync.ui
/**
* The core's `kind` vocabulary, which the UI has to match exactly.
*
* Shared rather than repeated because it was already living in three places — the
* card deciding whether to draw checkboxes, the editor deciding which field to
* show, and the view model deciding what to create — and a typo in any one of them
* would silently render a checklist as a paragraph rather than fail.
*
* Strings and not an enum: this is a value the STORE owns, arriving from a server
* that may be newer than this client, and an unrecognised kind has to fall through
* to "render it as a note" rather than throw.
*/
internal const val KIND_TEXT = "text"
internal const val KIND_LIST = "list"
@@ -0,0 +1,72 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
/**
* A text field with no box around it.
*
* Every writing surface in the app — the capture sheet, the editor's title and
* body, each checklist row — sits on a surface that already has its own edges and
* its own colour. Material's filled field would draw a second, differently
* coloured box inside the first, which makes writing a note look like filling in a
* form. Stripping the container and the indicator in four places independently is
* how they drift apart, so it happens once, here.
*
* The disabled colours are stripped too: a trashed note is shown through this
* field read-only, and Material's disabled treatment would grey out text the user
* is meant to be reading.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlainTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
@StringRes hint: Int? = null,
enabled: Boolean = true,
singleLine: Boolean = false,
minLines: Int = 1,
textStyle: TextStyle = LocalTextStyle.current,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine,
minLines = minLines,
textStyle = textStyle,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
colors =
TextFieldDefaults.colors(
// Full-strength, not Material's 38%-alpha disabled treatment: a
// trashed note is rendered read-only through this field and its
// text is meant to be READ, not visually retired.
disabledTextColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
}
@@ -0,0 +1,69 @@
package com.fabledsword.thoughtsync.ui
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// The timestamp seam between the core and the phone.
//
// The core stores and syncs RFC3339 in UTC, because that is what SQLite holds and
// what the server speaks. Deciding how a human should READ an instant is the UI's
// job and the answer differs per device, so the conversion lives here — once,
// rather than in the card and the editor separately, where the two would
// eventually format the same reminder differently.
/**
* Exactly the shape the core writes: UTC, milliseconds, `Z`.
*
* `Instant.toString()` would also be valid RFC3339, but it varies its precision
* with the value — it drops the fractional part on a whole second. Matching the
* core's `to_rfc3339_opts(Millis, true)` byte for byte means a reminder set on the
* phone is indistinguishable from one set on the desktop, including to anything
* downstream that compares the strings rather than parsing them.
*/
private val RFC3339_UTC: DateTimeFormatter =
DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
.withZone(ZoneId.of("UTC"))
/** A local wall-clock time, as the instant the core will store. */
fun rfc3339(local: LocalDateTime): String = RFC3339_UTC.format(local.atZone(ZoneId.systemDefault()).toInstant())
/** An instant from the core, as this device's local wall-clock time. */
fun localTime(raw: String): LocalDateTime? =
runCatching {
OffsetDateTime.parse(raw).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
}.getOrNull()
/**
* A stored instant in the device's own locale and zone.
*
* A string we cannot parse is shown verbatim rather than swallowed: a visibly odd
* reminder beats a silently missing one, and the raw value is what someone would
* need in order to report it.
*/
fun formatReminder(raw: String): String =
localTime(raw)
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
?: raw
/** The reminder as one line, with its repeat rule if it has one. */
fun reminderLabel(
raw: String,
recurrence: String?,
): String =
buildString {
append("")
append(formatReminder(raw))
if (!recurrence.isNullOrBlank()) {
append(" · ↻ ")
append(recurrence)
}
}
/** Whether a stored reminder has already passed, for showing it as overdue. */
fun isPast(raw: String): Boolean =
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
@@ -38,6 +38,60 @@
<string name="empty_reminders_title">No reminders</string>
<string name="empty_reminders_body">Notes with a reminder set will appear here.</string>
<!-- Editor -->
<string name="board_open_note">Open note</string>
<string name="editor_back">Back to notes</string>
<string name="editor_title_hint">Title</string>
<string name="editor_body_hint">Note</string>
<string name="editor_add_item">Add item</string>
<string name="editor_remove_item">Remove item</string>
<string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_make_list">Make a checklist</string>
<string name="editor_make_note">Switch to a note</string>
<string name="editor_more">More actions</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>
<string name="editor_archive">Archive</string>
<string name="editor_unarchive">Unarchive</string>
<string name="editor_trash">Move to trash</string>
<string name="editor_restore">Restore</string>
<string name="editor_cancel">Cancel</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so
the copy says exactly that rather than asking "Are you sure?". -->
<string name="editor_delete_forever">Delete forever</string>
<string name="editor_delete_forever_title">Delete this note?</string>
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Pickers -->
<string name="color_picker_title">Color</string>
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
<string name="picker_next">Next</string>
<string name="picker_set">Set</string>
<string name="picker_time_title">Pick a time</string>
<!-- Reminders -->
<string name="reminder_title">Remind me</string>
<string name="reminder_later_today">Later today</string>
<string name="reminder_tomorrow">Tomorrow</string>
<string name="reminder_next_week">Next week</string>
<string name="reminder_pick">Pick a date &amp; time</string>
<string name="reminder_clear">Remove reminder</string>
<string name="reminder_done">Done</string>
<string name="reminder_snooze_hour">Snooze 1h</string>
<string name="reminder_snooze_day">Snooze 1d</string>
<string name="recurrence_none">Once</string>
<string name="recurrence_daily">Daily</string>
<string name="recurrence_weekly">Weekly</string>
<string name="recurrence_monthly">Monthly</string>
<string name="recurrence_yearly">Yearly</string>
<!-- Store failure -->
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
+23
View File
@@ -24,6 +24,29 @@ style:
# constant.
ignorePropertyDeclaration: true
complexity:
# Compose breaks the PREMISE of both rules below, not just their thresholds.
#
# * LongParameterList assumes a long list means an over-general function. A
# composable's parameters ARE its UI contract — Material's own TextField
# takes twenty — and collapsing them into a parameter object makes the call
# site worse, not better, because named arguments are what keep a Compose
# tree readable.
# * LongMethod assumes length tracks branching. A composable's length tracks
# how many ELEMENTS are on the screen; a full-screen editor with a title, a
# body, a checklist, labels and a reminder row is long because it renders
# five things, and cutting it into five one-call wrappers would add
# indirection without removing a single decision.
#
# Scoped to @Composable rather than disabled: on ordinary functions both rules
# are right, and one of them still fires below (see BoardViewModel).
LongParameterList:
ignoreAnnotated:
- "Composable"
LongMethod:
ignoreAnnotated:
- "Composable"
exceptions:
TooGenericExceptionCaught:
# Catching broadly is DELIBERATE in these two places, and each site says so.
+285
View File
@@ -212,6 +212,127 @@ impl ThoughtSync {
.map_err(CoreError::store)
}
/// Remove a note permanently.
///
/// Returns nothing, unlike every other mutation here: there is no note left to
/// return. The core also records a pending delete, so a linked device tells the
/// server rather than having the next pull resurrect the row.
pub fn delete_note_forever(&self, id: String) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
}
// ──────────────────────────── checklist items ────────────────────────────
//
// Every one of these returns the whole reloaded note rather than the item it
// touched. That is the core's shape, and it is the right one for a UI: ticking
// a box changes `updated_at` and can change what the board shows, so handing
// back only the item would leave Kotlin to guess at the rest.
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::add_item(&conn, &note_id, &text)
.map(Note::from)
.map_err(CoreError::store)
}
/// Retitle one item.
///
/// Split from `set_item_checked` rather than exposing the core's
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
/// optional-field struct cannot say "leave this alone" in Kotlin without
/// colliding with "set it to null", and two unambiguous calls beat one
/// ambiguous one when each is three lines.
pub fn set_item_text(
&self,
note_id: String,
item_id: String,
text: String,
) -> Result<Note, CoreError> {
self.patch_item(&note_id, &item_id, serde_json::json!({ "text": text }))
}
pub fn set_item_checked(
&self,
note_id: String,
item_id: String,
checked: bool,
) -> Result<Note, CoreError> {
self.patch_item(
&note_id,
&item_id,
serde_json::json!({ "checked": checked }),
)
}
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::delete_item(&conn, &note_id, &item_id)
.map(Note::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── reminders ───────────────────────────────
/// Clear the reminder, marking it dealt with.
///
/// Distinct from `NoteEdit::ClearRemindAt` even though today they do the same
/// thing: the core reserves this one for "the reminder fired and is finished",
/// which is where recurrence advancement lands when it is built. A UI that
/// called the generic clear instead would silently stop recurring reminders
/// from recurring the day that changes.
pub fn complete_reminder(&self, id: String) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::complete_reminder(&conn, &id)
.map(Note::from)
.map_err(CoreError::store)
}
/// Push the reminder out by `minutes` from now.
///
/// The core computes the new instant from its own clock rather than taking one
/// from the caller — so "in an hour" means the same thing on every surface,
/// and a phone with a skewed clock can't write a reminder the server reads as
/// already past.
pub fn snooze_reminder(&self, id: String, minutes: i64) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::snooze_reminder(&conn, &id, minutes)
.map(Note::from)
.map_err(CoreError::store)
}
// ───────────────────────────────── labels ────────────────────────────────
/// Replace the note's MANUAL labels.
///
/// `#tag` labels are owned by the body text and the core re-derives them on
/// every body edit, so they are deliberately untouched here. A picker that
/// sent the full visible set would strip a tag label the text still mandates —
/// and the next keystroke in the body would put it straight back, which is the
/// kind of fight a UI should never pick with its store.
pub fn set_note_labels(
&self,
note_id: String,
label_ids: Vec<String>,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::set_labels(&conn, &note_id, &label_ids)
.map(Note::from)
.map_err(CoreError::store)
}
/// Find or create a label by name, returning it either way.
///
/// Find-or-create rather than create: the core matches case-insensitively, so
/// typing "Errands" when "errands" exists has to attach the existing label
/// instead of minting a near-duplicate that then diverges on colour.
pub fn create_label(&self, name: String) -> Result<Label, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::create_label(&conn, &name)
.map(Label::from)
.map_err(CoreError::store)
}
// ─────────────────────────────── sync ────────────────────────────────
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
@@ -333,6 +454,22 @@ impl ThoughtSync {
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side.
impl ThoughtSync {
/// Apply a `{text}` or `{checked}` patch to one checklist item.
///
/// The two public setters differ only in the key they write, and the lock +
/// convert + map-error dance around it is identical, so it lives once here.
fn patch_item(
&self,
note_id: &str,
item_id: &str,
changes: serde_json::Value,
) -> Result<Note, CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
local::store::update_item(&conn, note_id, item_id, &changes)
.map(Note::from)
.map_err(CoreError::store)
}
/// The server URL + token, or the `NotLinked` state. Every networked call needs
/// exactly this, and none of them may hold the lock past it.
fn credentials(&self) -> Result<(String, String), CoreError> {
@@ -479,4 +616,152 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
/// it, drop it. Each call returns the reloaded note, which is what the UI
/// splices back into the board rather than re-querying.
#[test]
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(NoteDraft {
title: "Packing".to_string(),
body: String::new(),
color: "default".to_string(),
kind: Some("list".to_string()),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
assert_eq!(note.items.len(), 1);
let with_two = app
.add_item(note.id.clone(), "charger".to_string())
.expect("add");
assert_eq!(with_two.items.len(), 2);
// Appended, not prepended — a new row belongs at the bottom of the list the
// user is looking at.
assert_eq!(with_two.items[1].text, "charger");
let item_id = with_two.items[1].id.clone();
let ticked = app
.set_item_checked(note.id.clone(), item_id.clone(), true)
.expect("tick");
assert!(ticked.items[1].checked);
assert_eq!(
ticked.items[1].text, "charger",
"ticking a box must not disturb its text — the two setters write \
different columns and neither may clear the other"
);
let renamed = app
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
.expect("rename");
assert_eq!(renamed.items[1].text, "usb-c cable");
assert!(
renamed.items[1].checked,
"and the same in the other direction"
);
let trimmed = app
.delete_item(note.id.clone(), item_id)
.expect("delete item");
assert_eq!(trimmed.items.len(), 1);
assert_eq!(trimmed.items[0].text, "socks");
std::fs::remove_dir_all(&dir).ok();
}
/// A `#tag` in the body owns its label. The picker replaces MANUAL labels only,
/// so sending an empty set must not strip one the text still mandates —
/// otherwise the next body edit would re-derive it and the UI would appear to
/// fight itself.
#[test]
fn setting_labels_leaves_tag_derived_ones_alone() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app
.create_note(draft("Trip", "book the ferry #travel"))
.expect("create");
assert_eq!(
note.labels.len(),
1,
"the #tag should have attached a label"
);
assert!(note.labels[0].via_tag);
let errands = app
.create_label("errands".to_string())
.expect("create label");
let tagged = app
.set_note_labels(note.id.clone(), vec![errands.id.clone()])
.expect("set labels");
assert_eq!(tagged.labels.len(), 2);
let cleared = app
.set_note_labels(note.id.clone(), vec![])
.expect("clear manual labels");
assert_eq!(cleared.labels.len(), 1);
assert!(cleared.labels[0].via_tag);
// Find-or-create, not create: a second "Errands" must be the same label,
// or the picker mints near-duplicates that then diverge on colour.
let again = app
.create_label("Errands".to_string())
.expect("create label again");
assert_eq!(again.id, errands.id);
std::fs::remove_dir_all(&dir).ok();
}
/// Deleting forever has to actually remove the row, and the note must then be
/// unreadable rather than merely hidden.
#[test]
fn deleting_forever_removes_the_note() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Ephemeral", "body")).expect("create");
app.delete_note_forever(note.id.clone())
.expect("delete forever");
assert!(
app.get_note(note.id.clone()).is_err(),
"a permanently deleted note must not still load"
);
std::fs::remove_dir_all(&dir).ok();
}
/// Snooze writes a future instant from the CORE's clock; complete clears it.
#[test]
fn reminders_can_be_snoozed_and_completed() {
let dir = scratch_dir();
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
let note = app.create_note(draft("Call back", "")).expect("create");
assert_eq!(note.remind_at, None);
let snoozed = app.snooze_reminder(note.id.clone(), 60).expect("snooze");
let at = snoozed.remind_at.expect("snoozing must set a reminder");
let parsed = chrono_free_parse(&at);
assert!(
parsed > 0,
"the reminder must be a parseable RFC3339 instant, got {at:?}"
);
let done = app.complete_reminder(note.id.clone()).expect("complete");
assert_eq!(done.remind_at, None);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize {
if raw.len() >= 20 && raw.as_bytes()[4] == b'-' && raw.contains('T') {
raw.len()
} else {
0
}
}
}