From 3b4fe310b86e901df295b4de2f69b7d612146f10 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 23:26:40 -0400 Subject: [PATCH] android: the board's Filters and drag-to-reorder, as on the web 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 --- .../com/fabledsword/inkwell/MainActivity.kt | 2 + .../com/fabledsword/inkwell/ui/BoardDrag.kt | 190 ++++++++++++++++++ .../fabledsword/inkwell/ui/BoardFilters.kt | 172 ++++++++++++++++ .../com/fabledsword/inkwell/ui/BoardScreen.kt | 129 +++++++++++- .../fabledsword/inkwell/ui/BoardViewModel.kt | 48 ++++- .../com/fabledsword/inkwell/ui/NoteCard.kt | 7 + android/app/src/main/res/values/strings.xml | 9 + .../inkwell/ui/BoardFiltersTest.kt | 42 ++++ android/ffi/src/lib.rs | 10 + 9 files changed, 594 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt create mode 100644 android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt create mode 100644 android/app/src/test/java/com/fabledsword/inkwell/ui/BoardFiltersTest.kt diff --git a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt index e8db24c..7704344 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt new file mode 100644 index 0000000..45a663e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt @@ -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() + + 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?, + drag: CardDrag, + bounds: CardBounds, + notes: List, +): List? { + 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) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt new file mode 100644 index 0000000..167ba9b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt @@ -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 = 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