board: a long press on a card does what the editor's overflow does
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:
@@ -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,9 +50,16 @@ 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) }
|
||||||
|
|
||||||
|
// 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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -56,7 +71,21 @@ fun NoteCard(
|
|||||||
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
||||||
// rounded corners instead of a rectangle overhanging them.
|
// rounded corners instead of a rectangle overhanging them.
|
||||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||||
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
.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))
|
.background(noteCardColor(note, dark))
|
||||||
// ONE grey edge on every card, regardless of its colour — the tint is
|
// ONE grey edge on every card, regardless of its colour — the tint is
|
||||||
// deliberately not consulted here. See CARD_EDGE_DARK.
|
// deliberately not consulted here. See CARD_EDGE_DARK.
|
||||||
@@ -108,6 +137,71 @@ fun NoteCard(
|
|||||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NoteMenu(
|
||||||
|
note = note,
|
||||||
|
expanded = menuOpen,
|
||||||
|
onDismiss = { menuOpen = false },
|
||||||
|
onAction = onAction,
|
||||||
|
onConfirmDelete = onConfirmDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(
|
|
||||||
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
|
confirmingDelete = false
|
||||||
onAction(EditorAction.DeleteForever)
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user