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