Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -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 =
|
||||
|
||||
@@ -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<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(
|
||||
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<Note>,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
<!-- Board -->
|
||||
<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
|
||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||
<string name="board_empty_title">Nothing here yet</string>
|
||||
|
||||
Reference in New Issue
Block a user