From 1e54b80f15f673f57a5bc061e5f809411dccd6d0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 31 Aug 2026 19:44:54 -0400 Subject: [PATCH] android: a Tags screen, so the phone can do more than attach tags to a note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android could list tags and mint new ones. It could not rename, recolour, delete or merge one — and since the per-note colour picker was removed with 2949, tag colour is the ONLY colour control in the product, which meant an Android-only session had no way to change any colour anywhere. A destination reached from the drawer, not a modal. The web's LabelsModal is a modal because a desktop can float one over the board; on a phone this is a place you go to tidy up, and a full screen is what that is. The manage entry is an action ON the drawer's Tags header rather than a row in it, so it cannot be mistaken for a sixth lens. The header now renders even when there are no tags: this screen is where you make the first one, and hiding the way in until one exists is a door that only appears once you are already inside. ## The two calls this needed RENAME and MERGE deliberately do not follow the same rule, and the screen says so rather than hiding it. * A rename that lands on an existing name merges, older survives (3324). That path is accident-prone — it is a text field, and a typo reaches it — so it needs a rule that cannot depend on which way round it was typed. The screen catches the collision against the LIST, not from what the core returns: the survivor may be the tag being renamed, so an unchanged id afterwards proves nothing. Then it asks before merging. * An explicit merge keeps its direction. Here the person is choosing, and the direction IS the intent — folding #grocery into #groceries is a decision, and overriding it with age would refuse the thing they asked for. The price is that the direction has to be unmissable, so the body names the tag that stops existing and every row offered is the survivor. Delete quotes the note count, because "it is on 40 notes" is a different decision from "delete this tag?". The count comes from `list_labels`, the only call the core populates one on. It also says that a tag written as #tag in a body comes back on that note's next edit — deleting the row cannot un-write the word, and that is better said than discovered. ## The board had to learn something `Destination.WithLabel` holds an id, and deleting or merging a tag the board is currently LOOKING at would strand it on a lens that queries a row which no longer exists — permanently empty, escapable only via the drawer. So `loadLabels` became `refreshLabels`: public, and it drops back to Notes when the current lens is gone. A failed listing deliberately does NOT trigger that fallback — "I could not read the tags" is not evidence that this one went. Reused rather than rewritten: `ErrorBanner` (the board and editor already share it), `MenuItem` from Panel.kt (it closes the menu before acting so a dialog cannot open under a hanging menu), `PlainTextField`, and the `NOTE_TINTS` palette — the screen consumes it and does not fork a copy. `default` stays in the palette on purpose: a tag with that colour gets a hue derived from its name, so it means "let it pick", and removing it would leave no way back to that. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c --- .../fabledsword/thoughtsync/MainActivity.kt | 30 +- .../fabledsword/thoughtsync/ui/BoardScreen.kt | 35 +- .../thoughtsync/ui/BoardViewModel.kt | 31 +- .../fabledsword/thoughtsync/ui/TagsScreen.kt | 584 ++++++++++++++++++ .../thoughtsync/ui/TagsViewModel.kt | 181 ++++++ android/app/src/main/res/values/strings.xml | 45 ++ 6 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/TagsScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/TagsViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index a3a2596..a2a2c9d 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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 } } /** diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index 62dfe60..fb72540 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -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 -> diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index ca644fd..f1ef302 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -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)) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/TagsScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/TagsScreen.kt new file mode 100644 index 0000000..94b65d8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/TagsScreen.kt @@ -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(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