board: a long press on a card does what the editor's overflow does
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m9s
Android / Kotlin + Rust (APK) (push) Failing after 4m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 07:40:29 -04:00
co-authored by Claude Opus 5
parent 4f351c10ca
commit 3f0eef145b
7 changed files with 298 additions and 98 deletions
@@ -221,6 +221,11 @@ private fun App(
onSearch = board::search, onSearch = board::search,
onCompose = board::compose, onCompose = board::compose,
onToggleItem = board::toggleItem, 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 // Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret. // handed a decision, not a state to interpret.
update = update =
@@ -37,6 +37,10 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults 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.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -65,11 +73,51 @@ fun BoardScreen(
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
update: BoardUpdate?, update: BoardUpdate?,
onDismissError: () -> Unit, onDismissError: () -> Unit,
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() 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<Note?>(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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -90,6 +138,7 @@ fun BoardScreen(
}, },
) { ) {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbars) },
floatingActionButton = { floatingActionButton = {
// The + is the ONLY way in, by design: one obvious target rather // The + is the ONLY way in, by design: one obvious target rather
// than a capture bar and a button competing for the same job. // than a capture bar and a button competing for the same job.
@@ -158,6 +207,8 @@ fun BoardScreen(
notes = state.notes, notes = state.notes,
onOpenNote = onOpenNote, onOpenNote = onOpenNote,
onToggleItem = onToggleItem, onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
) )
} }
// `PullToRefreshBox` would be less code, but it takes no // `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<Note>, notes: List<Note>,
onOpenNote: (Note) -> Unit, onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit, onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
) { ) {
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS), columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
@@ -378,6 +441,8 @@ private fun NoteBoard(
note = note, note = note,
onOpen = { onOpenNote(note) }, onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) }, onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
) )
} }
} }
@@ -1,7 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme 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.MoreVert
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon 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. * The note's labels, each removable.
* *
@@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@@ -42,70 +50,156 @@ fun NoteCard(
note: Note, note: Note,
onOpen: () -> Unit, onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit, onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
Column( // The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
modifier = // space, so the card's size is still the Column's.
Modifier Box {
.fillMaxWidth() Column(
// Depth, not the boundary — the edge below is that. 1dp: enough to modifier =
// separate a white card from a #fafafa board, and the web's own Modifier
// `shadow-sm` is the value it is matching. .fillMaxWidth()
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS)) // Depth, not the boundary — the edge below is that. 1dp: enough to
// Clipped BEFORE clickable, so the ripple is bounded by the card's // separate a white card from a #fafafa board, and the web's own
// rounded corners instead of a rectangle overhanging them. // `shadow-sm` is the value it is matching.
.clip(RoundedCornerShape(CARD_RADIUS)) .shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen) // Clipped BEFORE clickable, so the ripple is bounded by the card's
.background(noteCardColor(note, dark)) // rounded corners instead of a rectangle overhanging them.
// ONE grey edge on every card, regardless of its colour — the tint is .clip(RoundedCornerShape(CARD_RADIUS))
// deliberately not consulted here. See CARD_EDGE_DARK. .combinedClickable(
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS)) onClickLabel = stringResource(R.string.board_open_note),
.padding(12.dp), onLongClickLabel = stringResource(R.string.board_note_actions),
) { onLongClick = {
// TAGS FIRST. They used to sit under everything else, which on a tall note put // Fired HERE rather than when the menu appears. A long press
// the one thing that says what a note IS below the fold of a glance. A board is // is confirmed by the system before the popup has laid out,
// scanned, not read, and the answer to "which of these is about the thing I am // and the whole point of the buzz is to say "that registered"
// looking for" should be the first thing the eye lands on rather than the last. // at the moment your finger has been still long enough — a
// // menu that arrives with no tick under it reads as a phone
// Above the body rather than beside it, because the body's first line is the // that missed the gesture and then changed its mind.
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete haptics.performHapticFeedback(HapticFeedbackType.LongPress)
// with the thing that identifies the note. A row of its own costs one line and menuOpen = true
// only on notes that have tags at all. },
// onClick = onOpen,
// 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 .background(noteCardColor(note, dark))
// twice — once where it was typed, once up here — and the card was carrying // ONE grey edge on every card, regardless of its colour — the tint is
// furniture for information it was already showing. A tag left in prose is // deliberately not consulted here. See CARD_EDGE_DARK.
// tinted in place instead; see [tintTags]. What reaches this row is what the .border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
// body cannot say: a tag lifted off its own line, and a label added by hand. .padding(12.dp),
val chips = note.labels.filterNot { it.viaTag } ) {
if (chips.isNotEmpty()) { // TAGS FIRST. They used to sit under everything else, which on a tall note put
LabelChips(labels = chips) // the one thing that says what a note IS below the fold of a glance. A board is
Spacer(Modifier.height(8.dp)) // 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 NoteMenu(
// first line of the body IS the note's name, at the same weight as the rest of note = note,
// it (M13 steps 3 and 4). expanded = menuOpen,
if (note.body.isNotBlank()) { onDismiss = { menuOpen = false },
NoteBody(note = note, onToggleItem = onToggleItem) onAction = onAction,
} onConfirmDelete = onConfirmDelete,
)
}
}
// A note with nothing in it still has to occupy the board legibly — otherwise /**
// it reads as a rendering bug. * What you can do to a note without opening it.
if (note.body.isBlank()) { *
Text( * The board used to have none of this, and the operator's read of that was not "the
text = stringResource(R.string.board_empty_note), * actions are in the editor" — it was *"there are no long hold context menus in the
style = MaterialTheme.typography.bodyMedium, * app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
fontStyle = FontStyle.Italic, * Move to trash), and on a phone that is far enough from the gesture people reach
color = MaterialTheme.colorScheme.onSurfaceVariant, * 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
note.remindAt?.let { at -> * surfaces that named them differently would be describing two different apps. It
Spacer(Modifier.height(8.dp)) * dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
ReminderChip(instant = at, recurrence = note.recurrence) * 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) }
} }
} }
} }
@@ -12,13 +12,11 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -264,25 +262,12 @@ fun NoteEditorScreen(
) )
if (confirmingDelete) { if (confirmingDelete) {
// The only irreversible action in the app earns the only confirmation in ConfirmDeleteDialog(
// it. Everything else — archive, trash, even unlinking a server — undoes. onConfirm = {
AlertDialog( confirmingDelete = false
onDismissRequest = { confirmingDelete = false }, onAction(EditorAction.DeleteForever)
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))
}
}, },
onDismiss = { confirmingDelete = false },
) )
} }
} }
@@ -1,5 +1,6 @@
package com.fabledsword.thoughtsync.ui package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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. */ /** The three tones a panel or notice can take, mapped onto the note palette. */
enum class Tone { NEUTRAL, WARN, ERROR } enum class Tone { NEUTRAL, WARN, ERROR }
@@ -14,6 +14,14 @@
<!-- Board --> <!-- Board -->
<string name="board_empty_note">Empty note</string> <string name="board_empty_note">Empty note</string>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said
"Delete" where the editor says "Move to trash" would be describing two
different apps. Only the wrapper and the undo need words of their own. -->
<string name="board_note_actions">Note actions</string>
<string name="board_trashed">Moved to trash</string>
<string name="board_undo">Undo</string>
<!-- Empty states. Each destination says something true of ITSELF; a single <!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. --> "nothing here" reads as encouragement on the board and as a fault in Trash. -->
<string name="board_empty_title">Nothing here yet</string> <string name="board_empty_title">Nothing here yet</string>