AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -31,6 +31,8 @@ import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.TagsScreen
|
||||
import com.fabledsword.thoughtsync.ui.TagsViewModel
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
@@ -95,7 +97,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
/** Which screen is up. Exactly one at a time. */
|
||||
private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
@@ -104,7 +106,7 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
* both cover the display completely, so keeping the board's two-column grid
|
||||
* measuring and recomposing underneath one would be pure waste.
|
||||
*
|
||||
* Still no navigation library. Three destinations, each entered from exactly one
|
||||
* Still no navigation library. Four destinations, each entered from exactly one
|
||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||
* state that actually matters (which note is open, whether this device is linked)
|
||||
* already lives in view models.
|
||||
@@ -149,6 +151,13 @@ private fun App(
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Tag writes reach the board two ways at once: the drawer lists tags, and the
|
||||
// board may be LOOKING at one that a delete or a merge just removed. Both are
|
||||
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
|
||||
val tags: TagsViewModel =
|
||||
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
val settings = remember(context) { SyncSettings(context) }
|
||||
@@ -161,6 +170,9 @@ private fun App(
|
||||
val screen =
|
||||
when {
|
||||
showingSync -> Screen.SYNC
|
||||
// Above the editor: tags are reached only from the board's drawer, so
|
||||
// there is never an open note underneath one to go back to.
|
||||
showingTags -> Screen.TAGS
|
||||
editing != null -> Screen.EDITOR
|
||||
else -> Screen.BOARD
|
||||
}
|
||||
@@ -188,6 +200,18 @@ private fun App(
|
||||
onInstallOutcome = update::consumeInstallOutcome,
|
||||
)
|
||||
|
||||
Screen.TAGS ->
|
||||
TagsScreen(
|
||||
state = tags.state,
|
||||
onClose = { showingTags = false },
|
||||
onCreate = tags::create,
|
||||
onRename = tags::rename,
|
||||
onColour = tags::setColour,
|
||||
onDelete = tags::remove,
|
||||
onMerge = tags::merge,
|
||||
onDismissError = tags::dismissError,
|
||||
)
|
||||
|
||||
Screen.EDITOR ->
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
@@ -218,6 +242,7 @@ private fun App(
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onManageTags = { showingTags = true },
|
||||
onSearch = board::search,
|
||||
onCompose = board::compose,
|
||||
onToggleItem = board::toggleItem,
|
||||
@@ -247,6 +272,7 @@ private fun App(
|
||||
// The sync screen has no back handler of its own, so one lives here. The
|
||||
// editor keeps its own, because it has to save the open note before leaving.
|
||||
BackHandler(enabled = showingSync) { showingSync = false }
|
||||
BackHandler(enabled = showingTags) { showingTags = false }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -74,6 +75,7 @@ fun BoardScreen(
|
||||
onOpenNote: (Note) -> Unit,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||
@@ -138,6 +140,10 @@ fun BoardScreen(
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onManageTags = {
|
||||
onManageTags()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -367,6 +373,7 @@ private fun NavigationDrawer(
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
@@ -380,18 +387,36 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
// The header renders even with no tags, unlike the rows below it: the
|
||||
// manage screen is where you go to MAKE the first one, and hiding the
|
||||
// way in until one exists would be a door that appears only once you
|
||||
// are already inside. It is an action ON the section rather than a row
|
||||
// in it, so it cannot be mistaken for one more lens.
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
IconButton(onClick = onManageTags) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.tags_manage),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
labels.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||
|
||||
@@ -123,7 +123,7 @@ class BoardViewModel(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
@@ -162,13 +162,34 @@ class BoardViewModel(
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
/**
|
||||
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
|
||||
*
|
||||
* Public because the Tags screen owns operations this board cannot see: a
|
||||
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
|
||||
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
|
||||
* nothing forever. Without the fallback, tidying up tags could strand the board
|
||||
* on a permanently empty lens whose only escape is the drawer.
|
||||
*
|
||||
* A rename needs no fallback: the id survives, and re-listing gives the drawer
|
||||
* the new name. A rename that MERGED is a delete of one of the two, which this
|
||||
* catches by id like any other.
|
||||
*/
|
||||
fun refreshLabels() {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.onSuccess { state = state.copy(labels = it) }
|
||||
.onSuccess { labels ->
|
||||
state = state.copy(labels = labels)
|
||||
val lens = state.destination
|
||||
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||
open(Destination.Notes)
|
||||
}
|
||||
}
|
||||
// A drawer that cannot list labels is a degraded drawer, not a
|
||||
// broken board — the notes are still there. Failing quietly here
|
||||
// beats an error banner over working content.
|
||||
// beats an error banner over working content. The lens is left
|
||||
// alone in this case on purpose: "I could not read the tags" is not
|
||||
// evidence that this one is gone.
|
||||
.onFailure { state = state.copy(labels = emptyList()) }
|
||||
}
|
||||
}
|
||||
@@ -387,7 +408,7 @@ class BoardViewModel(
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
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.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
|
||||
|
||||
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||
private val SWATCH = 22.dp
|
||||
|
||||
/** What the row's overflow menu is currently asking about. */
|
||||
private sealed interface TagDialog {
|
||||
data class Rename(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||
data class ConfirmMerge(
|
||||
val tag: Label,
|
||||
val into: Label,
|
||||
val name: String,
|
||||
) : TagDialog
|
||||
|
||||
data class Merge(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Delete(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
|
||||
data class Colour(
|
||||
val tag: Label,
|
||||
) : TagDialog
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag management: list, create, rename, recolour, delete, merge.
|
||||
*
|
||||
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||
* because a desktop has room to float one over the board; on a phone this is a
|
||||
* place you visit to tidy up, and a full screen is what that is.
|
||||
*
|
||||
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||
* control in the product. That is why the swatch is a first-class tap target on
|
||||
* every row rather than something behind the overflow menu.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TagsScreen(
|
||||
state: TagsState,
|
||||
onClose: () -> Unit,
|
||||
onCreate: (String) -> Unit,
|
||||
onRename: (String, String) -> Unit,
|
||||
onColour: (String, String) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onMerge: (String, String) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.tags_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.tags_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||
// local SQLite call and usually finishes before this is seen at all.
|
||||
if (state.busy) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
// The same banner the board and the editor use. A third way of saying
|
||||
// "that did not work" would be a third thing to keep consistent.
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
|
||||
NewTagField(
|
||||
enabled = !state.busy,
|
||||
onCreate = onCreate,
|
||||
)
|
||||
|
||||
if (!state.loading && state.tags.isEmpty()) {
|
||||
EmptyTags()
|
||||
}
|
||||
|
||||
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||
// whole height would measure the list against space the field and the
|
||||
// banner have already taken — pushing the end of the list off-screen.
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(state.tags, key = { it.id }) { tag ->
|
||||
TagRow(
|
||||
tag = tag,
|
||||
enabled = !state.busy,
|
||||
onColour = { dialog = TagDialog.Colour(tag) },
|
||||
onRename = { dialog = TagDialog.Rename(tag) },
|
||||
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val open = dialog) {
|
||||
null -> Unit
|
||||
|
||||
is TagDialog.Rename ->
|
||||
RenameDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags,
|
||||
onDismiss = { dialog = null },
|
||||
onRename = { name ->
|
||||
dialog = null
|
||||
onRename(open.tag.id, name)
|
||||
},
|
||||
// Renaming onto a name another tag holds MERGES the two, and that
|
||||
// cannot be undone by repeating it, so the confirmation replaces
|
||||
// this dialog rather than the rename just happening.
|
||||
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||
)
|
||||
|
||||
is TagDialog.ConfirmMerge ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onRename(open.tag.id, open.name)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Merge ->
|
||||
MergeDialog(
|
||||
tag = open.tag,
|
||||
others = state.tags.filter { it.id != open.tag.id },
|
||||
onDismiss = { dialog = null },
|
||||
onMerge = { target ->
|
||||
dialog = null
|
||||
onMerge(open.tag.id, target.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Delete ->
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||
// The count is the part that makes the consequence real — "it is on
|
||||
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||
// from the LIST, the only call the core populates a count on.
|
||||
body =
|
||||
open.tag.count
|
||||
?.takeIf { it > 0 }
|
||||
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||
?: stringResource(R.string.tags_delete_body),
|
||||
footnote = stringResource(R.string.tags_delete_from_text),
|
||||
confirm = stringResource(R.string.tags_delete_confirm),
|
||||
onDismiss = { dialog = null },
|
||||
onConfirm = {
|
||||
dialog = null
|
||||
onDelete(open.tag.id)
|
||||
},
|
||||
)
|
||||
|
||||
is TagDialog.Colour ->
|
||||
ColourDialog(
|
||||
tag = open.tag,
|
||||
onDismiss = { dialog = null },
|
||||
onPick = { key ->
|
||||
dialog = null
|
||||
onColour(open.tag.id, key)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `#` on the name, everywhere it is spoken about.
|
||||
*
|
||||
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||
* be talking about something else.
|
||||
*/
|
||||
private fun hash(name: String): String = "#$name"
|
||||
|
||||
@Composable
|
||||
private fun NewTagField(
|
||||
enabled: Boolean,
|
||||
onCreate: (String) -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val submit = {
|
||||
if (text.isNotBlank()) {
|
||||
onCreate(text)
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
hint = R.string.tags_new_hint,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||
Text(stringResource(R.string.tags_create))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||
* #2949) and this is the natural place to say so.
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyTags() {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.tags_empty_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagRow(
|
||||
tag: Label,
|
||||
enabled: Boolean,
|
||||
onColour: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onMerge: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = labelTintFor(tag.name, tag.color)
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.clickable(enabled = enabled, onClick = onColour),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = hash(tag.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
)
|
||||
Text(
|
||||
text = countLabel(tag.count),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = stringResource(R.string.tags_actions),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||
// dialog cannot open underneath a menu still hanging over it.
|
||||
val close = { menuOpen = false }
|
||||
MenuItem(R.string.tags_rename, close, onRename)
|
||||
MenuItem(R.string.tags_merge, close, onMerge)
|
||||
MenuItem(R.string.tags_delete, close, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero is its own sentence, not "0 notes".
|
||||
*
|
||||
* A count of null means the core did not populate one — only `list_labels` does —
|
||||
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||
* rather than as empty.
|
||||
*/
|
||||
@Composable
|
||||
private fun countLabel(count: Long?): String =
|
||||
when {
|
||||
count == null -> ""
|
||||
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||
count == 1L -> stringResource(R.string.tags_count_one)
|
||||
else -> stringResource(R.string.tags_count, count.toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename, with the merge caught before it happens.
|
||||
*
|
||||
* The collision is detected HERE, against the list, rather than from what the
|
||||
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||
* Matching is case-insensitive because the core's is.
|
||||
*/
|
||||
@Composable
|
||||
private fun RenameDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onWouldMerge: (Label, String) -> Unit,
|
||||
) {
|
||||
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||
val trimmed = text.trim()
|
||||
val clash =
|
||||
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||
val submit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
clash != null -> onWouldMerge(clash, trimmed)
|
||||
else -> onRename(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||
text = {
|
||||
PlainTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
hint = R.string.tags_new_hint,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||
Text(stringResource(R.string.tags_rename_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge, with the direction stated and the survivor named.
|
||||
*
|
||||
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||
* depend on which way round it was typed — this one is deliberate, so the
|
||||
* direction the person chooses IS the intent and is honoured. The price of that
|
||||
* is that the direction has to be unmissable, which is why the body names the tag
|
||||
* that stops existing and every row here is the one that survives.
|
||||
*/
|
||||
@Composable
|
||||
private fun MergeDialog(
|
||||
tag: Label,
|
||||
others: List<Label>,
|
||||
onDismiss: () -> Unit,
|
||||
onMerge: (Label) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (others.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
others.forEach { other ->
|
||||
val tint = labelTintFor(other.name, other.color)
|
||||
Text(
|
||||
text = hash(other.name),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onMerge(other) }
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette, and since the per-note picker was removed this is the only place in
|
||||
* the product a colour is chosen.
|
||||
*
|
||||
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||
* back to that.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColourDialog(
|
||||
tag: Label,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
NOTE_TINTS.forEach { (key, tint) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(key) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||
)
|
||||
Text(
|
||||
text = tint.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color =
|
||||
if (key == tag.color) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||
@Composable
|
||||
private fun ConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirm: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Everything the Tags screen renders from.
|
||||
*
|
||||
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||
*/
|
||||
data class TagsState(
|
||||
val loading: Boolean = true,
|
||||
val tags: List<Label> = emptyList(),
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create, rename, recolour, delete and merge tags.
|
||||
*
|
||||
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||
* operations over the same core the desktop uses.
|
||||
*
|
||||
* ## Why every write re-lists
|
||||
*
|
||||
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||
*
|
||||
* ## Renaming can merge
|
||||
*
|
||||
* `rename_label` folds two tags together when the new name is one another tag
|
||||
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||
* tag whose id is not the one passed in, and it can make another tag stop
|
||||
* existing. The screen asks first; this view model does not, because a
|
||||
* confirmation belongs to the surface with a person in front of it.
|
||||
*
|
||||
* ## Threading
|
||||
*
|
||||
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||
* the core marks the rows dirty and the next sync carries them.
|
||||
*/
|
||||
class TagsViewModel(
|
||||
private val core: ThoughtSync,
|
||||
/**
|
||||
* Called after any write that landed.
|
||||
*
|
||||
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||
* current destination may BE one of these tags — deleting or merging that one
|
||||
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||
* explicitly is less magic than a shared event bus and makes the dependency
|
||||
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||
*/
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(TagsState())
|
||||
private set
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.fold(
|
||||
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||
// the only thing on the display, and an empty list here
|
||||
// would read as "you have no tags" rather than "I couldn't
|
||||
// look".
|
||||
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun create(name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
// Find-or-create in the core: typing a name that exists in another case
|
||||
// attaches the existing tag rather than minting a near-duplicate.
|
||||
write { it.createLabel(trimmed) }
|
||||
}
|
||||
|
||||
fun rename(
|
||||
id: String,
|
||||
name: String,
|
||||
) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
write { it.renameLabel(id, trimmed) }
|
||||
}
|
||||
|
||||
fun setColour(
|
||||
id: String,
|
||||
colour: String,
|
||||
) = write { it.setLabelColor(id, colour) }
|
||||
|
||||
fun remove(id: String) = write { it.removeLabel(id) }
|
||||
|
||||
/**
|
||||
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||
*
|
||||
* Directional and not undone by repeating it — the caller has to have said
|
||||
* which one survives before this runs, because afterwards there is nothing
|
||||
* left to read the direction from.
|
||||
*/
|
||||
fun merge(
|
||||
sourceId: String,
|
||||
targetId: String,
|
||||
) {
|
||||
if (sourceId == targetId) return
|
||||
write { it.mergeLabels(sourceId, targetId) }
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
state = state.copy(error = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one store write, then re-list and tell the board.
|
||||
*
|
||||
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||
* out of here can leave the screen stuck with its controls disabled.
|
||||
*/
|
||||
private fun write(block: (ThoughtSync) -> Unit) {
|
||||
if (state.busy) return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
val failure =
|
||||
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||
.exceptionOrNull()
|
||||
val tags =
|
||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||
.getOrDefault(state.tags)
|
||||
state =
|
||||
state.copy(
|
||||
tags = tags,
|
||||
busy = false,
|
||||
error = failure?.describeTagFailure(),
|
||||
)
|
||||
// Even a FAILED write can have changed the store — a merge that threw
|
||||
// partway still moved rows — so the board is told either way.
|
||||
onStoreChanged()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The core reports problems as one error type carrying a message meant to be
|
||||
* shown, so the message is used when there is one.
|
||||
*/
|
||||
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||
@@ -66,6 +66,51 @@
|
||||
<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>
|
||||
|
||||
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||
schema still says Label, and no string here needs to know that. -->
|
||||
<string name="tags_manage">Manage tags</string>
|
||||
<string name="tags_title">Tags</string>
|
||||
<string name="tags_back">Back</string>
|
||||
<string name="tags_new_hint">New tag</string>
|
||||
<string name="tags_create">Create</string>
|
||||
<string name="tags_count">%1$d notes</string>
|
||||
<string name="tags_count_one">1 note</string>
|
||||
<string name="tags_count_none">No notes yet</string>
|
||||
<string name="tags_empty_title">No tags yet</string>
|
||||
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||
<string name="tags_actions">More actions</string>
|
||||
<string name="tags_colour">Colour</string>
|
||||
<string name="tags_colour_of">Colour for %1$s</string>
|
||||
|
||||
<string name="tags_rename">Rename</string>
|
||||
<string name="tags_rename_title">Rename %1$s</string>
|
||||
<string name="tags_rename_confirm">Rename</string>
|
||||
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||
<string name="tags_rename_merges_confirm">Merge</string>
|
||||
|
||||
<string name="tags_merge">Merge into…</string>
|
||||
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||
<!-- The survivor is named in the button, not just the title: this is the one
|
||||
operation here that repeating does not undo. -->
|
||||
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||
|
||||
<string name="tags_delete">Delete</string>
|
||||
<string name="tags_delete_title">Delete %1$s?</string>
|
||||
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||
<string name="tags_delete_confirm">Delete</string>
|
||||
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||
row cannot un-write the word, so it comes back on that note's next edit —
|
||||
said here rather than left as a surprise. -->
|
||||
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||
|
||||
<string name="tags_cancel">Cancel</string>
|
||||
|
||||
<!-- Pickers -->
|
||||
<string name="label_picker_title">Tags</string>
|
||||
<string name="label_new_hint">Type a tag and press enter</string>
|
||||
|
||||
Reference in New Issue
Block a user