M12 — the Android client, end to end #2
@@ -4,14 +4,17 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||||
|
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||||
|
|
||||||
@@ -31,14 +34,50 @@ class MainActivity : ComponentActivity() {
|
|||||||
// than render an empty board that looks like data loss.
|
// than render an empty board that looks like data loss.
|
||||||
StoreUnavailableScreen(reason = app.openFailure)
|
StoreUnavailableScreen(reason = app.openFailure)
|
||||||
} else {
|
} else {
|
||||||
|
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))
|
val model: BoardViewModel = viewModel(factory = BoardViewModel.factory(core))
|
||||||
// Sheet visibility is view STATE, not view-model state: it is
|
// Sheet visibility is view STATE, not view-model state: it is about what is on
|
||||||
// about what is on screen, and nothing in the store cares.
|
// screen, and nothing in the store cares.
|
||||||
var composing by remember { mutableStateOf(false) }
|
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(
|
BoardScreen(
|
||||||
state = model.state,
|
state = model.state,
|
||||||
onOpen = model::open,
|
onOpen = model::open,
|
||||||
|
onOpenNote = model::openNote,
|
||||||
onSearch = model::search,
|
onSearch = model::search,
|
||||||
onCompose = { composing = true },
|
onCompose = { composing = true },
|
||||||
onDismissError = model::dismissError,
|
onDismissError = model::dismissError,
|
||||||
@@ -54,8 +93,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -41,16 +38,11 @@ import androidx.compose.material3.NavigationDrawerItem
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
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.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -63,6 +55,7 @@ import kotlinx.coroutines.launch
|
|||||||
fun BoardScreen(
|
fun BoardScreen(
|
||||||
state: BoardState,
|
state: BoardState,
|
||||||
onOpen: (Destination) -> Unit,
|
onOpen: (Destination) -> Unit,
|
||||||
|
onOpenNote: (Note) -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
@@ -110,7 +103,7 @@ fun BoardScreen(
|
|||||||
when {
|
when {
|
||||||
state.loading -> LoadingBoard()
|
state.loading -> LoadingBoard()
|
||||||
state.notes.isEmpty() -> EmptyBoard(state)
|
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) {
|
IconButton(onClick = onMenu) {
|
||||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
|
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
|
||||||
}
|
}
|
||||||
TextField(
|
PlainTextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text(stringResource(R.string.search_hint)) },
|
hint = R.string.search_hint,
|
||||||
singleLine = true,
|
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),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = {}),
|
keyboardActions = KeyboardActions(onSearch = {}),
|
||||||
colors =
|
|
||||||
TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = Color.Transparent,
|
|
||||||
unfocusedContainerColor = Color.Transparent,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (query.isNotEmpty()) {
|
if (query.isNotEmpty()) {
|
||||||
IconButton(onClick = { onQueryChange("") }) {
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
@@ -241,7 +230,10 @@ private fun DrawerRow(
|
|||||||
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
|
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoteBoard(notes: List<Note>) {
|
private fun NoteBoard(
|
||||||
|
notes: List<Note>,
|
||||||
|
onOpenNote: (Note) -> Unit,
|
||||||
|
) {
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
||||||
modifier = Modifier.fillMaxSize(),
|
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
|
// Keyed by id so Compose reuses cards across a refresh rather than
|
||||||
// rebuilding them — and so a newly captured note slides in instead of
|
// rebuilding them — and so a newly captured note slides in instead of
|
||||||
// making every card below it flicker.
|
// 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.
|
* 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 const val BOARD_COLUMNS = 2
|
||||||
private val GUTTER = 12.dp
|
private val GUTTER = 12.dp
|
||||||
private val CARD_RADIUS = 12.dp
|
|
||||||
private val SEARCH_RADIUS = 28.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.Label
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
import com.fabledsword.thoughtsync.core.NoteDraft
|
import com.fabledsword.thoughtsync.core.NoteDraft
|
||||||
|
import com.fabledsword.thoughtsync.core.NoteEdit
|
||||||
import com.fabledsword.thoughtsync.core.NoteQuery
|
import com.fabledsword.thoughtsync.core.NoteQuery
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -61,6 +62,15 @@ data class BoardState(
|
|||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
val error: String? = null,
|
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. */
|
/** Search overrides the destination while there is a query to run. */
|
||||||
val searching: Boolean get() = query.isNotBlank()
|
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
|
* 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
|
* they run on [Dispatchers.IO]. Doing otherwise would block the main thread on
|
||||||
* disk, which is the jank a native client exists to avoid.
|
* 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(
|
class BoardViewModel(
|
||||||
private val core: ThoughtSync,
|
private val core: ThoughtSync,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -127,11 +145,6 @@ class BoardViewModel(
|
|||||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
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() {
|
private fun loadLabels() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
@@ -206,37 +219,187 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun draft(
|
// ─────────────────────────────── the editor ──────────────────────────────
|
||||||
kind: DraftKind,
|
|
||||||
title: String,
|
fun openNote(note: Note) {
|
||||||
content: String,
|
state = state.copy(editing = note)
|
||||||
): 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
|
* Apply one editor action to the note the editor is open on.
|
||||||
// without making the user name it.
|
*
|
||||||
DraftKind.NOTE ->
|
* The `when` is exhaustive by construction, so adding a variant to
|
||||||
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null)
|
* [EditorAction] breaks THIS function until it is handled — which is the whole
|
||||||
// One line per item. Fast to type on a phone and unambiguous, versus a
|
* reason the editor speaks in actions rather than through a bundle of
|
||||||
// row-by-row editor that costs a tap per entry.
|
* callbacks. The note is passed in rather than read from `state.editing` so a
|
||||||
DraftKind.LIST ->
|
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||||
NoteDraft(
|
* action at a different note.
|
||||||
title = title,
|
*
|
||||||
body = "",
|
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
|
||||||
color = DEFAULT_COLOR,
|
* total function over it is twenty branches and sixty-odd lines no matter how
|
||||||
kind = KIND_LIST,
|
* it is written. Splitting it into sub-dispatchers is the only way to shorten
|
||||||
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
|
* 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() {
|
fun dismissError() {
|
||||||
state = state.copy(error = null)
|
state = state.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEFAULT_COLOR = "default"
|
|
||||||
private const val FALLBACK_ERROR = "Something went wrong."
|
private const val FALLBACK_ERROR = "Something went wrong."
|
||||||
private const val KIND_LIST = "list"
|
|
||||||
private const val SEARCH_DEBOUNCE_MS = 180L
|
private const val SEARCH_DEBOUNCE_MS = 180L
|
||||||
|
|
||||||
// The core's board vocabulary. "archived", not "archive" — it matches on
|
// 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
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -14,8 +13,6 @@ import androidx.compose.material3.FilterChip
|
|||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -26,7 +23,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
@@ -83,22 +79,23 @@ fun ComposeSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SheetField(
|
PlainTextField(
|
||||||
value = title,
|
value = title,
|
||||||
onValueChange = { title = it },
|
onValueChange = { title = it },
|
||||||
hint = R.string.compose_title_hint,
|
hint = R.string.compose_title_hint,
|
||||||
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
SheetField(
|
PlainTextField(
|
||||||
value = content,
|
value = content,
|
||||||
onValueChange = { content = it },
|
onValueChange = { content = it },
|
||||||
|
modifier = Modifier.focusRequester(contentFocus),
|
||||||
hint =
|
hint =
|
||||||
if (kind == DraftKind.LIST) {
|
if (kind == DraftKind.LIST) {
|
||||||
R.string.compose_list_hint
|
R.string.compose_list_hint
|
||||||
} else {
|
} else {
|
||||||
R.string.compose_body_hint
|
R.string.compose_body_hint
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(contentFocus),
|
|
||||||
minLines = MIN_CONTENT_LINES,
|
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
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.ChecklistItem
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoteCard(note: Note) {
|
fun NoteCard(
|
||||||
|
note: Note,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val tint = noteTint(note.color)
|
val tint = noteTint(note.color)
|
||||||
|
|
||||||
@@ -41,7 +42,10 @@ fun NoteCard(note: Note) {
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.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))
|
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||||
|
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
||||||
.background(tint.background(dark))
|
.background(tint.background(dark))
|
||||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
@@ -60,7 +64,7 @@ fun NoteCard(note: Note) {
|
|||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.kind == KIND_CHECKLIST) {
|
if (note.kind == KIND_LIST) {
|
||||||
Checklist(items = note.items)
|
Checklist(items = note.items)
|
||||||
} else if (note.body.isNotBlank()) {
|
} else if (note.body.isNotBlank()) {
|
||||||
Text(
|
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
|
@Composable
|
||||||
private fun ReminderChip(
|
private fun ReminderChip(
|
||||||
instant: String,
|
instant: String,
|
||||||
recurrence: String?,
|
recurrence: String?,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val tint = noteTint("blue")
|
val tint = noteTint(if (isPast(instant)) "red" else "default")
|
||||||
val text =
|
|
||||||
buildString {
|
|
||||||
append("⏰ ")
|
|
||||||
append(formatReminder(instant))
|
|
||||||
if (!recurrence.isNullOrBlank()) append(" · ↻ ").also { append(recurrence) }
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = reminderLabel(instant, recurrence),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = tint.chipForeground(dark),
|
color = tint.chipForeground(dark),
|
||||||
maxLines = 1,
|
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_PREVIEW_LINES = 8
|
||||||
private const val MAX_CHECKLIST_ROWS = 8
|
private const val MAX_CHECKLIST_ROWS = 8
|
||||||
private const val MAX_LABEL_CHIPS = 3
|
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_title">No reminders</string>
|
||||||
<string name="empty_reminders_body">Notes with a reminder set will appear here.</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 -->
|
<!-- Store failure -->
|
||||||
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
|
<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>
|
<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.
|
# constant.
|
||||||
ignorePropertyDeclaration: true
|
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:
|
exceptions:
|
||||||
TooGenericExceptionCaught:
|
TooGenericExceptionCaught:
|
||||||
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
||||||
|
|||||||
@@ -212,6 +212,127 @@ impl ThoughtSync {
|
|||||||
.map_err(CoreError::store)
|
.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 ────────────────────────────────
|
// ─────────────────────────────── sync ────────────────────────────────
|
||||||
|
|
||||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
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]`
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||||
/// block names, so these stay Rust-side.
|
/// block names, so these stay Rust-side.
|
||||||
impl ThoughtSync {
|
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
|
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||||
/// exactly this, and none of them may hold the lock past it.
|
/// exactly this, and none of them may hold the lock past it.
|
||||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||||
@@ -479,4 +616,152 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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