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
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user