android: the board's Filters and drag-to-reorder, as on the web
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
Android / Core and FFI clippy and tests (push) Successful in 58s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m26s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Android / Kotlin + Rust (APK) (push) Failing after 5m6s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m1s
Desktop (Tauri) / Update manifest (push) Successful in 6s

Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.

Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.

#5313.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:28:22 -04:00
co-authored by Claude Opus 5.5
parent 4149229988
commit 3b4fe310b8
9 changed files with 594 additions and 15 deletions
@@ -359,6 +359,8 @@ private fun App(
onOpenSync = { showingSync = true },
onManageTags = { showingTags = true },
onSearch = board::search,
onFilter = board::filter,
onReorder = board::reorder,
onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
@@ -0,0 +1,190 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import com.fabledsword.inkwell.core.Note
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then
// move it. The hold is the same long press that opens the card's menu, so the menu
// appears first and closes as the card starts to move. Lifting without moving
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll.
//
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
// click handling. Nothing is consumed until the hold has completed and the finger
// has moved, so taps, long presses and scrolls all reach the grid and the card
// untouched. After that point every change is consumed, so the grid cannot scroll
// under a card that is being carried.
/** A card being carried. Every offset is in root coordinates. */
internal data class CardDrag(
val id: String,
/** Where the finger went down. */
val from: Offset,
/** Where the finger is now. */
val at: Offset,
/** Where the card's corner was when the drag began. */
val startTopLeft: Offset,
) {
/** Where the card's corner should be drawn, so it stays under the finger. */
val topLeft: Offset get() = startTopLeft + (at - from)
}
/**
* Where each card on screen was last laid out, in root coordinates.
*
* A plain map rather than snapshot state: only the gesture reads it, so a write on
* every layout pass must not invalidate any composition.
*/
internal class CardBounds {
private val bounds = HashMap<String, Rect>()
fun record(
id: String,
rect: Rect,
) {
bounds[id] = rect
}
fun forget(id: String) {
bounds.remove(id)
}
operator fun get(id: String): Rect? = bounds[id]
/** The card under [point], other than [except]. */
fun idAt(
point: Offset,
except: String? = null,
): String? = bounds.entries.firstOrNull { (id, rect) -> id != except && rect.contains(point) }?.key
}
/** What the board does as a card is carried. Every offset is in root coordinates. */
internal class CardCarrier(
val onStart: (id: String, at: Offset) -> Unit,
val onMove: (at: Offset) -> Unit,
/** Runs however the gesture finishes, cancelled included, so no card is left held. */
val onEnd: () -> Unit,
)
/**
* Carry a card: hold, then move.
*
* [toRoot] maps a position in this element to root coordinates, which is where
* [bounds] keeps the cards.
*/
internal fun Modifier.dragToReorder(
enabled: Boolean,
bounds: CardBounds,
toRoot: (Offset) -> Offset,
carrier: CardCarrier,
): Modifier =
if (enabled) {
pointerInput(bounds) { awaitEachGesture { carry(bounds, toRoot, carrier) } }
} else {
this
}
private suspend fun AwaitPointerEventScope.carry(
bounds: CardBounds,
toRoot: (Offset) -> Offset,
carrier: CardCarrier,
) {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val id = bounds.idAt(toRoot(down.position))
if (id == null || !holdsStill(down) || !movesAfterHold(down)) return
carrier.onStart(id, toRoot(down.position))
try {
followUntilUp(down.id) { carrier.onMove(toRoot(it)) }
} finally {
carrier.onEnd()
}
}
/** True when the finger stayed down and inside the touch slop for a long press. */
private suspend fun AwaitPointerEventScope.holdsStill(down: PointerInputChange): Boolean {
val endedEarly =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
var ended = false
while (!ended) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
ended = change == null || !change.pressed || change.movedPast(down, viewConfiguration.touchSlop)
}
}
return endedEarly == null
}
/** After the hold: true once the finger moves past the slop, false if it lifts first. */
private suspend fun AwaitPointerEventScope.movesAfterHold(down: PointerInputChange): Boolean {
var moved: Boolean? = null
while (moved == null) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) {
moved = false
} else if (change.movedPast(down, viewConfiguration.touchSlop)) {
// Consumed so the grid's scrolling, which has not claimed this gesture
// yet, never does.
change.consume()
moved = true
}
}
return moved == true
}
/** Report every position until the finger lifts, consuming each change. */
private suspend fun AwaitPointerEventScope.followUntilUp(
pointer: PointerId,
onMove: (Offset) -> Unit,
) {
var pressed = true
while (pressed) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == pointer }
pressed = change?.pressed == true
change?.consume()
if (change != null && pressed) onMove(change.position)
}
}
private fun PointerInputChange.movedPast(
down: PointerInputChange,
slop: Float,
): Boolean = (position - down.position).getDistance() > slop
/** The grid's own layout, so a position in it can be taken to root coordinates. */
internal class GridOrigin {
var coords: LayoutCoordinates? = null
}
/**
* [order] with the carried card moved to the place of the card under the finger, the
* way the web's drop splices it. Unchanged when there is no card there, or when it is
* on the other side of the pinned line: the store sorts pinned notes first, so a
* card dropped across the line would only snap back.
*/
internal fun moved(
order: List<String>?,
drag: CardDrag,
bounds: CardBounds,
notes: List<Note>,
): List<String>? {
val target = bounds.idAt(drag.at, except = drag.id)
val pinned = notes.associate { it.id to it.pinned }
val from = order?.indexOf(drag.id) ?: -1
val to = if (target != null && pinned[target] == pinned[drag.id]) order?.indexOf(target) ?: -1 else -1
return if (order == null || from < 0 || to < 0) {
order
} else {
order.toMutableList().apply {
removeAt(from)
add(to, drag.id)
}
}
}
@@ -0,0 +1,172 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.NoteFacets
/**
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue,
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and
* shared with me. Reminders and dates have their own lenses, here as on the web.
*
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
* the drawer starts that view unfiltered, and search runs over everything.
*/
data class BoardFilters(
val labelIds: Set<String> = emptySet(),
val hasAttachment: Boolean = false,
val sharedWithMe: Boolean = false,
) {
/** How many are on, counted the way the web's badge counts them: each tag is one. */
val count: Int
get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0)
/** The core's facets for these, or null when none are on. */
fun facets(): NoteFacets? =
if (count == 0) {
null
} else {
NoteFacets(
q = null,
label = labelIds.toList().ifEmpty { null },
hasAttachment = if (hasAttachment) true else null,
createdAfter = null,
createdBefore = null,
shared = if (sharedWithMe) SHARED_WITH_ME else null,
)
}
fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id)
}
/** The facet value the core reads as "only notes someone else shared with me". */
private const val SHARED_WITH_ME = "with_me"
/**
* The row under the search bar: a Filters chip that opens the sheet, showing how many
* are on, and a Clear beside it once any are.
*/
@Composable
internal fun FilterRow(
filters: BoardFilters,
onOpen: () -> Unit,
onClear: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = GUTTER),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = filters.count > 0,
onClick = onOpen,
label = {
Text(
if (filters.count > 0) {
stringResource(R.string.filters_count, filters.count)
} else {
stringResource(R.string.filters)
},
)
},
)
if (filters.count > 0) {
TextButton(onClick = onClear) { Text(stringResource(R.string.filters_clear)) }
}
}
}
/**
* Pick the filters. Each tap applies at once, as on the web, so the board behind the
* sheet is already showing the answer when it is dismissed.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
internal fun FilterSheet(
filters: BoardFilters,
labels: List<Label>,
onChange: (BoardFilters) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.verticalScroll(rememberScrollState()),
) {
SheetTitle(R.string.filters)
FilterHeading(R.string.filters_show)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = filters.hasAttachment,
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
label = { Text(stringResource(R.string.filters_has_attachment)) },
)
FilterChip(
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
// No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
labels.forEach { label ->
FilterChip(
selected = label.id in filters.labelIds,
onClick = { onChange(filters.toggleLabel(label.id)) },
label = { Text(label.name) },
)
}
}
}
if (filters.count > 0) {
TextButton(
onClick = { onChange(BoardFilters()) },
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.filters_clear))
}
}
}
}
}
@Composable
private fun FilterHeading(labelRes: Int) {
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
)
}
@@ -53,17 +53,25 @@ 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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.fabledsword.inkwell.R
import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
@@ -78,6 +86,8 @@ fun BoardScreen(
onOpenSync: () -> Unit,
onManageTags: () -> Unit,
onSearch: (String) -> Unit,
onFilter: (BoardFilters) -> Unit,
onReorder: (List<String>) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
@@ -92,6 +102,7 @@ fun BoardScreen(
// 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) }
var filtering by remember { mutableStateOf(false) }
// Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block.
@@ -184,6 +195,17 @@ fun BoardScreen(
onMenu = { scope.launch { drawerState.open() } },
)
// The main board only, and not while searching, as on the web: a
// search spans everything, so a filter row over its hits would be a
// control that does nothing.
if (state.destination == Destination.Notes && !state.searching) {
FilterRow(
filters = state.filters,
onOpen = { filtering = true },
onClear = { onFilter(BoardFilters()) },
)
}
state.error?.let { message ->
ErrorBanner(message = message, onDismiss = onDismissError)
}
@@ -235,6 +257,14 @@ fun BoardScreen(
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
// The whole board in one order, as on the web: not a
// search, another view, or a filtered subset, whose
// order would be renumbered against notes it can't see.
reorderable =
state.destination == Destination.Notes &&
!state.searching &&
state.filters.count == 0,
onReorder = onReorder,
)
}
// `PullToRefreshBox` would be less code, but it takes no
@@ -249,6 +279,15 @@ fun BoardScreen(
}
}
if (filtering) {
FilterSheet(
filters = state.filters,
labels = state.labels,
onChange = onFilter,
onDismiss = { filtering = false },
)
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
@@ -469,6 +508,9 @@ private fun NoteBoard(
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
/** Whether cards can be dragged into a new order: the plain main board only. */
reorderable: Boolean,
onReorder: (List<String>) -> Unit,
) {
// The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from
// `xl`, 1280px), on the window's width, so a tablet lays out like a browser of the
@@ -480,9 +522,52 @@ private fun NoteBoard(
windowWidth >= 1024 -> 3
else -> 2
}
// The drag (BoardDrag.kt). `order` is the board as the drag has rearranged it, and
// it stays until the store's next list arrives, so a drop does not flash back to
// the old order while the write is in flight.
val bounds = remember { CardBounds() }
val grid = remember { GridOrigin() }
var drag by remember { mutableStateOf<CardDrag?>(null) }
var order by remember { mutableStateOf<List<String>?>(null) }
val current by rememberUpdatedState(notes)
val reorder by rememberUpdatedState(onReorder)
LaunchedEffect(notes) { order = null }
// Which card is carried, read by every item; where it is, read only when its layer
// is drawn. A move then redraws one card instead of recomposing them all.
val carriedId by remember { derivedStateOf { drag?.id } }
val byId = notes.associateBy { it.id }
val shown = order?.mapNotNull { byId[it] } ?: notes
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(columns),
modifier = Modifier.fillMaxSize(),
modifier =
Modifier
.fillMaxSize()
.onGloballyPositioned { grid.coords = it }
.dragToReorder(
enabled = reorderable,
bounds = bounds,
toRoot = { grid.coords?.localToRoot(it) ?: it },
carrier =
CardCarrier(
onStart = { id, at ->
val corner = bounds[id]?.topLeft ?: at
drag = CardDrag(id = id, from = at, at = at, startTopLeft = corner)
order = current.map { it.id }
},
onMove = { at ->
drag = drag?.copy(at = at)
order = drag?.let { moved(order, it, bounds, current) } ?: order
},
onEnd = {
val final = order
drag = null
if (final != null && final != current.map { it.id }) reorder(final)
},
),
),
// Bottom padding clears the FAB, so the last note is never trapped under it.
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
verticalItemSpacing = 8.dp,
@@ -491,14 +576,38 @@ private fun NoteBoard(
// Keyed by id so Compose reuses cards across a refresh rather than
// rebuilding them — and so a newly captured note slides in instead of
// making every card below it flicker.
items(items = notes, key = { it.id }) { note ->
NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
)
items(items = shown, key = { it.id }) { note ->
val carried = carriedId == note.id
// Bounds recorded BEFORE the carried card's translation in the chain, so
// they are where the layout put it, not where the finger has taken it.
val follow =
if (carried) {
Modifier.zIndex(1f).graphicsLayer {
drag?.let { d ->
val laid = bounds[note.id]?.topLeft ?: d.startTopLeft
translationX = d.topLeft.x - laid.x
translationY = d.topLeft.y - laid.y
}
}
} else {
Modifier.animateItem()
}
Box(
modifier =
Modifier
.onGloballyPositioned { bounds.record(note.id, it.boundsInRoot()) }
.then(follow),
) {
NoteCard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
dragging = carried,
)
}
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
}
}
}
@@ -517,6 +626,8 @@ private fun EmptyBoard(state: BoardState) {
state.searching ->
stringResource(R.string.empty_search_title) to
stringResource(R.string.empty_search_body, state.query)
state.destination == Destination.Notes && state.filters.count > 0 ->
stringResource(R.string.empty_filtered_title) to stringResource(R.string.empty_filtered_body)
state.destination == Destination.Trash ->
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
state.destination == Destination.Archive ->
@@ -12,6 +12,7 @@ import com.fabledsword.inkwell.core.Label
import com.fabledsword.inkwell.core.Note
import com.fabledsword.inkwell.core.NoteDraft
import com.fabledsword.inkwell.core.NoteEdit
import com.fabledsword.inkwell.core.NoteFacets
import com.fabledsword.inkwell.core.NoteQuery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -57,6 +58,8 @@ data class BoardState(
val notes: List<Note> = emptyList(),
val labels: List<Label> = emptyList(),
val query: String = "",
/** The main board's filters. Only [Destination.Notes] applies them. */
val filters: BoardFilters = BoardFilters(),
val loading: Boolean = true,
val saving: Boolean = false,
val error: String? = null,
@@ -137,7 +140,13 @@ class BoardViewModel(
// running should show the archive, not search results filtered by a box
// the user has visually moved on from.
searchJob?.cancel()
state = state.copy(destination = destination, query = "")
state = state.copy(destination = destination, query = "", filters = BoardFilters())
refresh()
}
/** Apply the main board's filters. */
fun filter(filters: BoardFilters) {
state = state.copy(filters = filters)
refresh()
}
@@ -146,7 +155,9 @@ class BoardViewModel(
state = state.copy(loading = true)
state =
try {
val notes = withContext(Dispatchers.IO) { load(state.destination) }
val destination = state.destination
val filters = state.filters
val notes = withContext(Dispatchers.IO) { load(destination, filters) }
state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) {
// Broad by intent: the board must render something for any
@@ -157,9 +168,12 @@ class BoardViewModel(
}
}
private fun load(destination: Destination): List<Note> =
private fun load(
destination: Destination,
filters: BoardFilters,
): List<Note> =
when (destination) {
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets()))
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
// Not a board view: the core models reminders as its own query, since
@@ -186,6 +200,10 @@ class BoardViewModel(
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
.onSuccess { labels ->
state = state.copy(labels = labels)
// A deleted tag is dropped from the filters as it is from the lens
// below: a filter on an id nothing carries matches nothing forever.
val kept = state.filters.labelIds intersect labels.map { it.id }.toSet()
if (kept != state.filters.labelIds) filter(state.filters.copy(labelIds = kept))
val lens = state.destination
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
open(Destination.Notes)
@@ -540,7 +558,11 @@ class BoardViewModel(
val updated = withContext(Dispatchers.IO) { block(core) }
val notes =
withContext(Dispatchers.IO) {
if (state.searching) core.searchNotes(state.query) else load(state.destination)
if (state.searching) {
core.searchNotes(state.query)
} else {
load(state.destination, state.filters)
}
}
// On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main
@@ -581,6 +603,19 @@ class BoardViewModel(
checked: Boolean,
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
/**
* Put the main board in this order, first on top: the end of a card drag.
*
* Through [mutate] so the board is re-read afterwards, which is what settles a
* drop the store did not take as shown. A card dragged across the pinned line
* snaps back to its own side, because the store sorts by pinned first.
*/
fun reorder(orderedIds: List<String>) =
mutate {
it.reorderNotes(orderedIds)
null
}
fun dismissError() {
state = state.copy(error = null)
}
@@ -617,7 +652,8 @@ class BoardViewModel(
private fun query(
view: String,
labelId: String? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
facets: NoteFacets? = null,
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = facets)
private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,11 +53,17 @@ fun NoteCard(
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
/** The board is carrying this card (BoardDrag.kt). */
dragging: Boolean = false,
) {
val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
// A drag starts from the same long press that opens the menu, so the menu is
// already up when the card begins to move. It goes as the card does.
LaunchedEffect(dragging) { if (dragging) menuOpen = false }
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier
@@ -28,6 +28,15 @@
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
<string name="empty_search_title">No matches</string>
<string name="empty_search_body">Nothing matched “%1$s”.</string>
<string name="empty_filtered_title">No matches</string>
<string name="empty_filtered_body">No notes match these filters.</string>
<string name="filters">Filters</string>
<string name="filters_count">Filters · %1$d</string>
<string name="filters_clear">Clear</string>
<string name="filters_show">Show only</string>
<string name="filters_has_attachment">Has attachment</string>
<string name="filters_shared_with_me">Shared with me</string>
<string name="filters_tags">Tagged with all of</string>
<string name="empty_trash_title">Trash is empty</string>
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
<string name="empty_archive_title">Nothing archived</string>
@@ -0,0 +1,42 @@
package com.fabledsword.inkwell.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class BoardFiltersTest {
@Test
fun `no filters asks the core for no facets at all`() {
assertNull(BoardFilters().facets())
assertEquals(0, BoardFilters().count)
}
@Test
fun `each tag counts as one, as on the web's badge`() {
val filters = BoardFilters(labelIds = setOf("a", "b"), hasAttachment = true, sharedWithMe = true)
assertEquals(4, filters.count)
val facets = requireNotNull(filters.facets())
assertEquals(setOf("a", "b"), facets.label?.toSet())
assertEquals(true, facets.hasAttachment)
assertEquals("with_me", facets.shared)
// Dates are Timeline's and text is the search bar's; the filters never set them.
assertNull(facets.q)
assertNull(facets.createdAfter)
assertNull(facets.createdBefore)
}
@Test
fun `a switch that is off is left out rather than sent as false`() {
val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets())
assertNull(facets.hasAttachment)
assertNull(facets.shared)
}
@Test
fun `tapping a tag twice takes it back off`() {
val once = BoardFilters().toggleLabel("a")
assertEquals(setOf("a"), once.labelIds)
assertEquals(BoardFilters(), once.toggleLabel("a"))
}
}