From 3f0eef145bebeac05fe7503578e31d882581e542 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 07:40:29 -0400 Subject: [PATCH] board: a long press on a card does what the editor's overflow does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trash existed on Android and was three interactions deep — open the note, tap the overflow, Move to trash — with nothing at all on the board itself. The operator's read of that was not "the actions are in the editor"; it was "there are no long hold context menus in the app I have no way to delete notes." (#2946) The card now takes `combinedClickable` and raises a DropdownMenu holding the same items as the editor's overflow, in the same words, from the same string resources, dispatching the same `EditorAction`s through the same `BoardViewModel.onEditorAction`. A note has one vocabulary of things you can do to it, and reusing the exhaustive dispatcher means the board cannot grow a parallel one that drifts. Gated on `note.trashed` rather than on the board's destination — the same reading the editor uses for read-only, and the only one that survives Reminders and search, which both mix piles. Trash gets an UNDO snackbar rather than a confirmation. A long press is a gesture you can make by accident, so the mistake worth designing for is the one nobody meant to make, and a dialog only helps someone paying attention in the moment they were not. Delete forever keeps its dialog; that one does not undo. `MenuItem` and the delete-forever dialog move to Panel.kt now that two surfaces raise them, so there is one place for the close-before-acting order and one wording of the consequences. Colour is deliberately not in this menu, though #2946 suggested it: `note.color` and its picker come out in #3041, so a swatch row here would be building the one control already known to be leaving. Co-Authored-By: Claude Opus 5 --- .../fabledsword/thoughtsync/MainActivity.kt | 5 + .../fabledsword/thoughtsync/ui/BoardScreen.kt | 65 ++++++ .../thoughtsync/ui/EditorChrome.kt | 20 -- .../fabledsword/thoughtsync/ui/NoteCard.kt | 210 +++++++++++++----- .../thoughtsync/ui/NoteEditorScreen.kt | 25 +-- .../com/fabledsword/thoughtsync/ui/Panel.kt | 63 ++++++ android/app/src/main/res/values/strings.xml | 8 + 7 files changed, 298 insertions(+), 98 deletions(-) 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 45c061a..a3a2596 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -221,6 +221,11 @@ private fun App( onSearch = board::search, onCompose = board::compose, onToggleItem = board::toggleItem, + // The SAME seam the editor uses. `onEditorAction` is already the + // exhaustive dispatcher for every action a note has, and it takes + // the note to act on rather than reading the open one — so the board + // can hand it a card without a second dispatcher existing to drift. + onNoteAction = board::onEditorAction, // Null unless there is genuinely something to say — the board is // handed a decision, not a state to interpret. update = 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 1efc831..7c52d62 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 @@ -37,6 +37,10 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults @@ -44,7 +48,11 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -65,11 +73,51 @@ fun BoardScreen( onSearch: (String) -> Unit, onCompose: () -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit, + onNoteAction: (Note, EditorAction) -> Unit, update: BoardUpdate?, onDismissError: () -> Unit, ) { val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() + val snackbars = remember { SnackbarHostState() } + + // Held HERE rather than on the card. A card lives in a lazy grid and is disposed + // the moment it scrolls out of view, which would take its dialog down with it — + // and the board can scroll under an open dialog. + var confirmingDelete by remember { mutableStateOf(null) } + + // Resolved in composition, not inside the coroutine: `stringResource` is a + // composable read and cannot be called from a suspend block. + val trashedMessage = stringResource(R.string.board_trashed) + val undoLabel = stringResource(R.string.board_undo) + + // Trash gets an UNDO rather than a confirmation, and the two are not + // interchangeable. A long press is a gesture you can make by accident — resting a + // thumb while reading is enough — so the mistake worth designing for is the one + // nobody meant to make, and a dialog only helps someone who is paying attention + // in the moment they were not. Trash is already recoverable; the snackbar just + // says so where it happened, instead of leaving you to find the Trash view and + // work out which note went missing. + // + // Delete forever keeps its dialog. That one does not undo. + val onCardAction: (Note, EditorAction) -> Unit = { note, action -> + onNoteAction(note, action) + if (action == EditorAction.Trash) { + scope.launch { + val outcome = + snackbars.showSnackbar( + message = trashedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Short, + ) + // `note` is the pre-trash copy and deliberately so: Restore only needs + // its id, and the id is the one thing trashing does not change. + if (outcome == SnackbarResult.ActionPerformed) { + onNoteAction(note, EditorAction.Restore) + } + } + } + } ModalNavigationDrawer( drawerState = drawerState, @@ -90,6 +138,7 @@ fun BoardScreen( }, ) { Scaffold( + snackbarHost = { SnackbarHost(snackbars) }, floatingActionButton = { // The + is the ONLY way in, by design: one obvious target rather // than a capture bar and a button competing for the same job. @@ -158,6 +207,8 @@ fun BoardScreen( notes = state.notes, onOpenNote = onOpenNote, onToggleItem = onToggleItem, + onNoteAction = onCardAction, + onConfirmDelete = { confirmingDelete = it }, ) } // `PullToRefreshBox` would be less code, but it takes no @@ -171,6 +222,16 @@ fun BoardScreen( } } } + + confirmingDelete?.let { note -> + ConfirmDeleteDialog( + onConfirm = { + confirmingDelete = null + onNoteAction(note, EditorAction.DeleteForever) + }, + onDismiss = { confirmingDelete = null }, + ) + } } } @@ -361,6 +422,8 @@ private fun NoteBoard( notes: List, onOpenNote: (Note) -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit, + onNoteAction: (Note, EditorAction) -> Unit, + onConfirmDelete: (Note) -> Unit, ) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), @@ -378,6 +441,8 @@ private fun NoteBoard( note = note, onOpen = { onOpenNote(note) }, onToggleItem = { index, checked -> onToggleItem(note, index, checked) }, + onAction = { onNoteAction(note, it) }, + onConfirmDelete = { onConfirmDelete(note) }, ) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index b7f624a..6d760f7 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -1,7 +1,6 @@ package com.fabledsword.thoughtsync.ui import android.text.format.DateUtils -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme @@ -23,7 +22,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -295,24 +293,6 @@ private fun OverflowMenu( } } -@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. * diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt index 0f940e5..8ce84e1 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteCard.kt @@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.Spacer @@ -12,15 +14,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -42,70 +50,156 @@ fun NoteCard( note: Note, onOpen: () -> Unit, onToggleItem: (Int, Boolean) -> Unit, + onAction: (EditorAction) -> Unit, + onConfirmDelete: () -> Unit, ) { val dark = isSystemInDarkTheme() + val haptics = LocalHapticFeedback.current + var menuOpen by remember { mutableStateOf(false) } - Column( - modifier = - Modifier - .fillMaxWidth() - // Depth, not the boundary — the edge below is that. 1dp: enough to - // separate a white card from a #fafafa board, and the web's own - // `shadow-sm` is the value it is matching. - .shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS)) - // Clipped BEFORE clickable, so the ripple is bounded by the card's - // rounded corners instead of a rectangle overhanging them. - .clip(RoundedCornerShape(CARD_RADIUS)) - .clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen) - .background(noteCardColor(note, dark)) - // ONE grey edge on every card, regardless of its colour — the tint is - // deliberately not consulted here. See CARD_EDGE_DARK. - .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) - .padding(12.dp), - ) { - // TAGS FIRST. They used to sit under everything else, which on a tall note put - // the one thing that says what a note IS below the fold of a glance. A board is - // scanned, not read, and the answer to "which of these is about the thing I am - // looking for" should be the first thing the eye lands on rather than the last. - // - // Above the body rather than beside it, because the body's first line is the - // note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete - // with the thing that identifies the note. A row of its own costs one line and - // only on notes that have tags at all. - // - // ONLY the labels whose text is not still in the note. `via_tag` means exactly - // "backed by body text" since M311, so a chip for one printed the same tag - // twice — once where it was typed, once up here — and the card was carrying - // furniture for information it was already showing. A tag left in prose is - // tinted in place instead; see [tintTags]. What reaches this row is what the - // body cannot say: a tag lifted off its own line, and a label added by hand. - val chips = note.labels.filterNot { it.viaTag } - if (chips.isNotEmpty()) { - LabelChips(labels = chips) - Spacer(Modifier.height(8.dp)) + // The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no + // space, so the card's size is still the Column's. + Box { + Column( + modifier = + Modifier + .fillMaxWidth() + // Depth, not the boundary — the edge below is that. 1dp: enough to + // separate a white card from a #fafafa board, and the web's own + // `shadow-sm` is the value it is matching. + .shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS)) + // Clipped BEFORE clickable, so the ripple is bounded by the card's + // rounded corners instead of a rectangle overhanging them. + .clip(RoundedCornerShape(CARD_RADIUS)) + .combinedClickable( + onClickLabel = stringResource(R.string.board_open_note), + onLongClickLabel = stringResource(R.string.board_note_actions), + onLongClick = { + // Fired HERE rather than when the menu appears. A long press + // is confirmed by the system before the popup has laid out, + // and the whole point of the buzz is to say "that registered" + // at the moment your finger has been still long enough — a + // menu that arrives with no tick under it reads as a phone + // that missed the gesture and then changed its mind. + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menuOpen = true + }, + onClick = onOpen, + ) + .background(noteCardColor(note, dark)) + // ONE grey edge on every card, regardless of its colour — the tint is + // deliberately not consulted here. See CARD_EDGE_DARK. + .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) + .padding(12.dp), + ) { + // TAGS FIRST. They used to sit under everything else, which on a tall note put + // the one thing that says what a note IS below the fold of a glance. A board is + // scanned, not read, and the answer to "which of these is about the thing I am + // looking for" should be the first thing the eye lands on rather than the last. + // + // Above the body rather than beside it, because the body's first line is the + // note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete + // with the thing that identifies the note. A row of its own costs one line and + // only on notes that have tags at all. + // + // ONLY the labels whose text is not still in the note. `via_tag` means exactly + // "backed by body text" since M311, so a chip for one printed the same tag + // twice — once where it was typed, once up here — and the card was carrying + // furniture for information it was already showing. A tag left in prose is + // tinted in place instead; see [tintTags]. What reaches this row is what the + // body cannot say: a tag lifted off its own line, and a label added by hand. + val chips = note.labels.filterNot { it.viaTag } + if (chips.isNotEmpty()) { + LabelChips(labels = chips) + Spacer(Modifier.height(8.dp)) + } + + // Body then checklist, in order — a note can carry both (M13 step 2). The + // first line of the body IS the note's name, at the same weight as the rest of + // it (M13 steps 3 and 4). + if (note.body.isNotBlank()) { + NoteBody(note = note, onToggleItem = onToggleItem) + } + + // A note with nothing in it still has to occupy the board legibly — otherwise + // it reads as a rendering bug. + if (note.body.isBlank()) { + Text( + text = stringResource(R.string.board_empty_note), + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + note.remindAt?.let { at -> + Spacer(Modifier.height(8.dp)) + ReminderChip(instant = at, recurrence = note.recurrence) + } } - // Body then checklist, in order — a note can carry both (M13 step 2). The - // first line of the body IS the note's name, at the same weight as the rest of - // it (M13 steps 3 and 4). - if (note.body.isNotBlank()) { - NoteBody(note = note, onToggleItem = onToggleItem) - } + NoteMenu( + note = note, + expanded = menuOpen, + onDismiss = { menuOpen = false }, + onAction = onAction, + onConfirmDelete = onConfirmDelete, + ) + } +} - // A note with nothing in it still has to occupy the board legibly — otherwise - // it reads as a rendering bug. - if (note.body.isBlank()) { - Text( - text = stringResource(R.string.board_empty_note), - style = MaterialTheme.typography.bodyMedium, - fontStyle = FontStyle.Italic, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - note.remindAt?.let { at -> - Spacer(Modifier.height(8.dp)) - ReminderChip(instant = at, recurrence = note.recurrence) +/** + * What you can do to a note without opening it. + * + * The board used to have none of this, and the operator's read of that was not "the + * actions are in the editor" — it was *"there are no long hold context menus in the + * app I have no way to delete notes."* Trash was three interactions deep (open, ⋮, + * Move to trash), and on a phone that is far enough from the gesture people reach + * for that it may as well not exist. + * + * **The same items as the editor's overflow, in the same words, from the same string + * resources.** A note has one vocabulary of things that can be done to it, and two + * surfaces that named them differently would be describing two different apps. It + * dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is + * already the exhaustive dispatcher for every one of them, so the board reuses the + * seam rather than growing a parallel one that could drift. + * + * **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor + * gates its own read-only mode on, and it is the only reading that survives the views + * that mix piles: Reminders cuts across archived and active alike, and a search hits + * whatever matches. A menu that offered "Move to trash" on a note already in the + * trash would be offering to do something twice. + * + * **Colour is deliberately absent**, though #2946 suggested it. `note.color` is + * scheduled for removal along with the whole picker (#3041, the last step of M309) — + * colour comes from the note's tags now. Building a swatch row here would be building + * the one control in this menu already known to be coming out. + * + * Labels are absent too, for a duller reason: the picker they open is editor state, + * and hoisting it to the board is a bigger change than the friction actually reported. + */ +@Composable +private fun NoteMenu( + note: Note, + expanded: Boolean, + onDismiss: () -> Unit, + onAction: (EditorAction) -> Unit, + onConfirmDelete: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + if (note.trashed) { + MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) } + MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete) + } else { + MenuItem( + if (note.pinned) R.string.editor_unpin else R.string.editor_pin, + onDismiss, + ) { onAction(EditorAction.SetPinned(!note.pinned)) } + MenuItem( + if (note.archived) R.string.editor_unarchive else R.string.editor_archive, + onDismiss, + ) { onAction(EditorAction.SetArchived(!note.archived)) } + MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) } } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 0772176..edfe0b6 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -12,13 +12,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -264,25 +262,12 @@ fun NoteEditorScreen( ) 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)) - } + ConfirmDeleteDialog( + onConfirm = { + confirmingDelete = false + onAction(EditorAction.DeleteForever) }, + onDismiss = { confirmingDelete = false }, ) } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt index c4c7fd9..e3b4f5e 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Panel.kt @@ -1,5 +1,6 @@ package com.fabledsword.thoughtsync.ui +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme @@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -79,6 +82,66 @@ fun Notice( } } +/** + * One row of a dropdown menu, closing the menu before it acts. + * + * Lives here rather than beside either menu because there are two now — the + * editor's overflow and the board's long-press menu — and they offer the same + * actions in the same words. A second copy of this would be a second place for the + * closing order to be got wrong. + * + * Closing FIRST is the whole point: an action that raises a sheet or a dialog would + * otherwise do it underneath a menu still hanging over the screen. Doing it in here + * means no call site can forget. + */ +@Composable +fun MenuItem( + @StringRes labelRes: Int, + onClose: () -> Unit, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { Text(stringResource(labelRes)) }, + onClick = { + onClose() + onClick() + }, + ) +} + +/** + * The one confirmation in the app. + * + * Delete-forever is the only irreversible thing a note can be asked to do — + * archive, trash, even unlinking a server all undo — so it is the only one that + * interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow + * and the board's long-press menu are two ways to the same act, and two dialogs + * would be two chances to word the consequences differently. + * + * Nothing about a note is passed in. The caller already knows which note it is + * asking about and holds it while this is on screen; taking one here would only let + * the dialog and the action that follows it disagree. + */ +@Composable +fun ConfirmDeleteDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.editor_delete_forever_title)) }, + text = { Text(stringResource(R.string.editor_delete_forever_body)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.editor_delete_forever_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) } + }, + ) +} + /** The three tones a panel or notice can take, mapped onto the note palette. */ enum class Tone { NEUTRAL, WARN, ERROR } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5f40083..2a4af2d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -14,6 +14,14 @@ Empty note + + Note actions + Moved to trash + Undo + Nothing here yet