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 },
|
onOpenSync = { showingSync = true },
|
||||||
onManageTags = { showingTags = true },
|
onManageTags = { showingTags = true },
|
||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
|
onFilter = board::filter,
|
||||||
|
onReorder = board::reorder,
|
||||||
onCompose = board::compose,
|
onCompose = board::compose,
|
||||||
onToggleItem = board::toggleItem,
|
onToggleItem = board::toggleItem,
|
||||||
// The SAME seam the editor uses. `onEditorAction` is already the
|
// 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.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import com.fabledsword.inkwell.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.inkwell.core.Label
|
import com.fabledsword.inkwell.core.Label
|
||||||
import com.fabledsword.inkwell.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
@@ -78,6 +86,8 @@ fun BoardScreen(
|
|||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
onManageTags: () -> Unit,
|
onManageTags: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
|
onFilter: (BoardFilters) -> Unit,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
onNoteAction: (Note, EditorAction) -> 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 —
|
// the moment it scrolls out of view, which would take its dialog down with it —
|
||||||
// and the board can scroll under an open dialog.
|
// and the board can scroll under an open dialog.
|
||||||
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
||||||
|
var filtering by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
||||||
// composable read and cannot be called from a suspend block.
|
// composable read and cannot be called from a suspend block.
|
||||||
@@ -184,6 +195,17 @@ fun BoardScreen(
|
|||||||
onMenu = { scope.launch { drawerState.open() } },
|
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 ->
|
state.error?.let { message ->
|
||||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
}
|
}
|
||||||
@@ -235,6 +257,14 @@ fun BoardScreen(
|
|||||||
onToggleItem = onToggleItem,
|
onToggleItem = onToggleItem,
|
||||||
onNoteAction = onCardAction,
|
onNoteAction = onCardAction,
|
||||||
onConfirmDelete = { confirmingDelete = it },
|
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
|
// `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 ->
|
confirmingDelete?.let { note ->
|
||||||
ConfirmDeleteDialog(
|
ConfirmDeleteDialog(
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
@@ -469,6 +508,9 @@ private fun NoteBoard(
|
|||||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
onNoteAction: (Note, EditorAction) -> Unit,
|
onNoteAction: (Note, EditorAction) -> Unit,
|
||||||
onConfirmDelete: (Note) -> 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
|
// 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
|
// `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
|
windowWidth >= 1024 -> 3
|
||||||
else -> 2
|
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(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(columns),
|
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.
|
// 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),
|
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
|
||||||
verticalItemSpacing = 8.dp,
|
verticalItemSpacing = 8.dp,
|
||||||
@@ -491,14 +576,38 @@ private fun NoteBoard(
|
|||||||
// Keyed by id so Compose reuses cards across a refresh rather than
|
// Keyed by id so Compose reuses cards across a refresh rather than
|
||||||
// rebuilding them — and so a newly captured note slides in instead of
|
// rebuilding them — and so a newly captured note slides in instead of
|
||||||
// making every card below it flicker.
|
// making every card below it flicker.
|
||||||
items(items = notes, key = { it.id }) { note ->
|
items(items = shown, key = { it.id }) { note ->
|
||||||
NoteCard(
|
val carried = carriedId == note.id
|
||||||
note = note,
|
// Bounds recorded BEFORE the carried card's translation in the chain, so
|
||||||
onOpen = { onOpenNote(note) },
|
// they are where the layout put it, not where the finger has taken it.
|
||||||
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
val follow =
|
||||||
onAction = { onNoteAction(note, it) },
|
if (carried) {
|
||||||
onConfirmDelete = { onConfirmDelete(note) },
|
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 ->
|
state.searching ->
|
||||||
stringResource(R.string.empty_search_title) to
|
stringResource(R.string.empty_search_title) to
|
||||||
stringResource(R.string.empty_search_body, state.query)
|
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 ->
|
state.destination == Destination.Trash ->
|
||||||
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
||||||
state.destination == Destination.Archive ->
|
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.Note
|
||||||
import com.fabledsword.inkwell.core.NoteDraft
|
import com.fabledsword.inkwell.core.NoteDraft
|
||||||
import com.fabledsword.inkwell.core.NoteEdit
|
import com.fabledsword.inkwell.core.NoteEdit
|
||||||
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
import com.fabledsword.inkwell.core.NoteQuery
|
import com.fabledsword.inkwell.core.NoteQuery
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -57,6 +58,8 @@ data class BoardState(
|
|||||||
val notes: List<Note> = emptyList(),
|
val notes: List<Note> = emptyList(),
|
||||||
val labels: List<Label> = emptyList(),
|
val labels: List<Label> = emptyList(),
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
|
/** The main board's filters. Only [Destination.Notes] applies them. */
|
||||||
|
val filters: BoardFilters = BoardFilters(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -137,7 +140,13 @@ class BoardViewModel(
|
|||||||
// running should show the archive, not search results filtered by a box
|
// running should show the archive, not search results filtered by a box
|
||||||
// the user has visually moved on from.
|
// the user has visually moved on from.
|
||||||
searchJob?.cancel()
|
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()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +155,9 @@ class BoardViewModel(
|
|||||||
state = state.copy(loading = true)
|
state = state.copy(loading = true)
|
||||||
state =
|
state =
|
||||||
try {
|
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)
|
state.copy(notes = notes, loading = false, error = null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Broad by intent: the board must render something for any
|
// 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) {
|
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.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
||||||
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
||||||
// Not a board view: the core models reminders as its own query, since
|
// 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() } }
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
.onSuccess { labels ->
|
.onSuccess { labels ->
|
||||||
state = state.copy(labels = 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
|
val lens = state.destination
|
||||||
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||||
open(Destination.Notes)
|
open(Destination.Notes)
|
||||||
@@ -540,7 +558,11 @@ class BoardViewModel(
|
|||||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||||
val notes =
|
val notes =
|
||||||
withContext(Dispatchers.IO) {
|
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
|
// On IO, not here: re-deriving the alarm reads every note
|
||||||
// that carries a reminder, and this line runs on the main
|
// that carries a reminder, and this line runs on the main
|
||||||
@@ -581,6 +603,19 @@ class BoardViewModel(
|
|||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
|
) = 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() {
|
fun dismissError() {
|
||||||
state = state.copy(error = null)
|
state = state.copy(error = null)
|
||||||
}
|
}
|
||||||
@@ -617,7 +652,8 @@ class BoardViewModel(
|
|||||||
private fun query(
|
private fun query(
|
||||||
view: String,
|
view: String,
|
||||||
labelId: String? = null,
|
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 =
|
private fun draft(content: String): NoteDraft =
|
||||||
// The core names the note from the body's first line, so a captured thought is
|
// 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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -52,11 +53,17 @@ fun NoteCard(
|
|||||||
onToggleItem: (Int, Boolean) -> Unit,
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
|
/** The board is carrying this card (BoardDrag.kt). */
|
||||||
|
dragging: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
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
|
// 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
|
// chain-method-continuation wants the next `.` glued to the closing paren of a
|
||||||
// multiline element — `).background(…)` — which is worse to read than a modifier
|
// 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="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_title">No matches</string>
|
||||||
<string name="empty_search_body">Nothing matched “%1$s”.</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_title">Trash is empty</string>
|
||||||
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</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>
|
<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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,16 @@ impl Inkwell {
|
|||||||
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put the board in this order, first id on top: the desktop's `notes_reorder`.
|
||||||
|
///
|
||||||
|
/// Returns nothing, as the board reloads its list afterwards anyway. The core marks
|
||||||
|
/// each note dirty so the new positions sync, and a note shared with this account
|
||||||
|
/// moves on this board only (#5176).
|
||||||
|
pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────── checklist items ────────────────────────────
|
// ──────────────────────────── checklist items ────────────────────────────
|
||||||
|
|
||||||
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
||||||
|
|||||||
Reference in New Issue
Block a user