From cf0ce382a09afba0383c33b8bd698673d1ce8dce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 11:18:24 -0400 Subject: [PATCH] android: the note editor (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a card now opens something. Until this commit the phone could create, find and navigate; it could not change anything. A FULL SCREEN, not a sheet. Capture is a sheet because the board behind it is reassurance that the thought landed; editing is 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 puts the actions in a bottom bar, which is where a thumb already is. The note's colour paints the whole screen, so opening one reads as the same object growing to fill the display. Text saves ONCE, on close — plus on ON_STOP, so app-switching mid-paragraph doesn't lose it. Not debounced autosave: the core snapshots a revision on every title/body change, so saving per typing pause would fill version history with near-identical entries. A baseline check means opening a note and backing out writes nothing at all, rather than bumping updated_at and marking it dirty for sync. Same shape the web editor settled on, for the same reason. The editor speaks in ACTIONS, not callbacks. The first version passed a bundle of twenty lambdas and the doc comment on it was already worrying about two of the same-shaped ones getting swapped, with nothing to catch it. `EditorAction` plus one `(EditorAction) -> Unit` costs a `when` at the far end and buys exhaustiveness: adding a variant breaks the dispatcher until it is handled. Checklist rows are live here — real checkboxes, editable text, remove, and an add row that keeps focus so a list types straight through. That is the answer to the open question about list entry: the capture sheet stays one-item-per- line because at capture time the list is already in your head and a tap per row is the slow part; the editor is where a list is REVISED, and revising is item-at-a-time. Row text commits on focus loss, not per keystroke — each commit is a store write that reloads the note. Colour, labels and reminders are bottom sheets. Reminders lead with presets (later today / tomorrow / next week) and keep the exact picker one tap down: the web's raw datetime-local is right for a desktop and three taps too many for the common case on a phone. Recurrence only appears once there is a reminder to recur from. The date picker reports UTC midnight of the calendar day tapped and is read back in UTC — reading it in the device zone is the classic off-by-a-day in that control. Pin, labels, archive and delete live in the overflow as WORDS. `material-icons-core` has no pin, archive or label glyph, and the alternatives 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. The colour button is a dot in the note's current colour, which says what the colour IS as well as what the button does. A trashed note renders read-only. Editing one would silently resurrect work that was meant to be thrown away; Restore and Delete forever are the only things to do with it. Deleting for good is the one irreversible action in the app and gets the one confirmation in it. `#tag` labels are never sent to `set_labels` and 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. FFI additions: delete_note_forever, add_item, set_item_text, set_item_checked, delete_item, complete_reminder, snooze_reminder, set_note_labels, create_label. `set_item_text`/`set_item_checked` are split rather than exposing the core's {text?, checked?} patch, for the same reason NoteEdit is a list — an optional-field struct cannot say "leave this alone" in Kotlin without colliding with "set it to null". Four new tests (11 total in the crate). Found while extracting shared helpers: the card painted EVERY reminder blue, so "you missed this" and "coming up Friday" looked identical. Now red when overdue and neutral otherwise, matching the web card's exact pairs. And the error banner was renderable only by the board — the one screen that needed it, where the writes happen, was the one screen without it. DRY, since three copies each had appeared: PlainTextField (the undecorated field used by capture, editor, checklist rows and the search bar), Time.kt (the RFC3339 seam), NoteKind.kt, ErrorBanner. detekt: LongMethod and LongParameterList now ignore @Composable. Compose breaks those rules' PREMISE, not just their thresholds — a composable's parameters are its UI contract and its length tracks how many elements are on screen, not branching. Two suppressions carry their reasoning at the site instead: onEditorAction is sixty lines because EditorAction has twenty variants, and splitting it would need an `else` that throws away the exhaustiveness; and BoardViewModel stays one class because every editor mutation has to reload the board behind it. Verified locally before pushing, per ci-requirements.md: fmt/clippy/test in ci-tauri:1.97 (89 + 11 + 11 tests, four crates present), ktlint and detekt in ci-rust-android:1.97, uniffi bindings generated from a host build and read to confirm every method and field name the Kotlin calls. Still unbuilt: attachments, link previews, version history, and label management (rename/recolour/delete). Setting up a server from the phone is next. Scribe #2777 Co-Authored-By: Claude Opus 5 (1M context) --- .../fabledsword/thoughtsync/MainActivity.kt | 81 ++- .../fabledsword/thoughtsync/ui/BoardScreen.kt | 62 +-- .../thoughtsync/ui/BoardViewModel.kt | 255 +++++++++- .../thoughtsync/ui/ComposeSheet.kt | 52 +- .../thoughtsync/ui/EditorAction.kt | 121 +++++ .../thoughtsync/ui/EditorChecklist.kt | 144 ++++++ .../thoughtsync/ui/EditorChrome.kt | 268 ++++++++++ .../thoughtsync/ui/EditorPickers.kt | 468 ++++++++++++++++++ .../fabledsword/thoughtsync/ui/ErrorBanner.kt | 54 ++ .../fabledsword/thoughtsync/ui/NoteCard.kt | 49 +- .../thoughtsync/ui/NoteEditorScreen.kt | 314 ++++++++++++ .../fabledsword/thoughtsync/ui/NoteKind.kt | 16 + .../fabledsword/thoughtsync/ui/PlainField.kt | 72 +++ .../com/fabledsword/thoughtsync/ui/Time.kt | 69 +++ android/app/src/main/res/values/strings.xml | 54 ++ android/config/detekt.yml | 23 + android/ffi/src/lib.rs | 285 +++++++++++ 17 files changed, 2211 insertions(+), 176 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ErrorBanner.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteKind.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/Time.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index f74a9c0..d7bb849 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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 + }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index b27240c..46db948 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -1,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) { +private fun NoteBoard( + notes: List, + onOpenNote: (Note) -> Unit, +) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), modifier = Modifier.fillMaxSize(), @@ -253,7 +245,9 @@ private fun NoteBoard(notes: List) { // 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 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index b279dc3..4466fcb 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -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() }, + ) + } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt index e02571b..bfd4c4c 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt new file mode 100644 index 0000000..c500b84 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorAction.kt @@ -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, + ) : 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 +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt new file mode 100644 index 0000000..e51adfa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChecklist.kt @@ -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 = "" + }), + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt new file mode 100644 index 0000000..4fbb146 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt new file mode 100644 index 0000000..f8fa0ee --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorPickers.kt @@ -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