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