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