mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Tweaked data source management, migrated DB and Settings
This commit is contained in:
1 parent
d4a2fae0cd
commit
6f3e3351c9
34 files changed
+1290
-437
No files matched your search
+3
-3
@@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -430,11 +430,11 @@ private fun DialogShell(
|
||||
|
||||
@Composable
|
||||
fun hasEnoughHeight(): Boolean =
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
|
||||
|
||||
@Composable
|
||||
fun hasEnoughWidth(): Boolean =
|
||||
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
|
||||
|
||||
@Composable
|
||||
fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2021 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.draw.innerShadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
//region DragDrop Region
|
||||
@Composable
|
||||
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(lazyListState) {
|
||||
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
lazyListState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class DragDropState internal constructor(
|
||||
private val state: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||
internal val draggingItemOffset: Float
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
} ?: 0f
|
||||
|
||||
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
internal var previousItemOffset = Animatable(0f)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||
offset.y.toInt() in item.offset..(item.offset + item.size)
|
||||
}?.also {
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
|
||||
}
|
||||
if (targetItem != null) {
|
||||
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
|
||||
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
|
||||
}
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll = when {
|
||||
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.DraggableItem(
|
||||
dragDropState: DragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||
) {
|
||||
val zIndexMod = Modifier.zIndex(1f)
|
||||
val dragging = index == dragDropState.draggingItemIndex
|
||||
val draggingModifier = when {
|
||||
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||
index == dragDropState.previousIndexOfDraggedItem -> {
|
||||
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
|
||||
}
|
||||
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||
}
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Swipe Region
|
||||
@Composable
|
||||
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
|
||||
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
|
||||
val isVisible = remember { mutableStateOf(true) }
|
||||
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
LaunchedEffect(willTrigger) {
|
||||
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
|
||||
hapticFeedback.performHapticFeedback(feedbackType)
|
||||
}
|
||||
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
enableDismissFromStartToEnd = true,
|
||||
enableDismissFromEndToStart = false,
|
||||
backgroundContent = { DismissBackground(willTrigger) },
|
||||
content = { content() },
|
||||
onDismiss = {
|
||||
isVisible.value = false
|
||||
coroutineScope.launch { dismissState.reset() }
|
||||
}
|
||||
)
|
||||
}
|
||||
LaunchedEffect(isVisible.value) {
|
||||
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
|
||||
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
|
||||
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
|
||||
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||
color = bgColor
|
||||
radius = 40f
|
||||
alpha = if (willTrigger) .2f else 0f
|
||||
}
|
||||
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
|
||||
color = bgColor
|
||||
radius = 40f
|
||||
alpha = if (willTrigger) 1f else .2f
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer {
|
||||
scaleX = iconScale
|
||||
scaleY = iconScale
|
||||
translationX = slide.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.presentation
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
|
||||
* a settle-back animation when the finger is released.
|
||||
*
|
||||
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
|
||||
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
|
||||
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
|
||||
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
|
||||
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
|
||||
* `key = { ... }` lambda of `items`/`itemsIndexed`.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val dragState = rememberDragReorderState(listState)
|
||||
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
|
||||
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
|
||||
* entries.add(to, entries.removeAt(from))
|
||||
* }
|
||||
* Row(
|
||||
* modifier = Modifier
|
||||
* .dragLift(row.isLifted, row.translationY)
|
||||
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
|
||||
* ) {
|
||||
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
|
||||
* Text(entry.label)
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Stable
|
||||
class DragReorderState internal constructor(internal val listState: LazyListState) {
|
||||
internal var draggedKey by mutableStateOf<Any?>(null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
|
||||
remember(listState) { DragReorderState(listState) }
|
||||
|
||||
/** Used for the settle-back animation once the finger is released: quick and snappy. */
|
||||
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
|
||||
|
||||
/**
|
||||
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
|
||||
*
|
||||
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
|
||||
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
|
||||
* is derived every frame from those two plus the row's *current* live layout offset, so it
|
||||
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
|
||||
* offset-compensation math.
|
||||
*/
|
||||
@Stable
|
||||
class DragRowState<T> internal constructor(
|
||||
private val dragState: DragReorderState,
|
||||
private val itemsState: State<List<T>>,
|
||||
private val itemKey: Any,
|
||||
private val key: (T) -> Any,
|
||||
private val onMoveState: State<(Int, Int) -> Unit>,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
|
||||
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
|
||||
internal val settleAnim = Animatable(0f)
|
||||
|
||||
var isSettling: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val isDragging: Boolean get() = dragState.draggedKey == itemKey
|
||||
val isLifted: Boolean get() = isDragging || isSettling
|
||||
|
||||
val translationY: Float
|
||||
get() = when {
|
||||
isSettling -> settleAnim.value
|
||||
isDragging -> currentTranslation()
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* How far this row must be pushed away from its *current* layout slot so it stays glued to
|
||||
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
|
||||
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
|
||||
* absorbs both effects with no extra bookkeeping.
|
||||
*/
|
||||
private fun currentTranslation(): Float {
|
||||
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
|
||||
?: dragStartOffset.floatValue
|
||||
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
|
||||
}
|
||||
|
||||
internal fun onDragStart() {
|
||||
isSettling = false
|
||||
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
|
||||
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
|
||||
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
|
||||
fingerOffset.floatValue = 0f
|
||||
dragState.draggedKey = itemKey
|
||||
}
|
||||
|
||||
internal fun onDrag(deltaY: Float) {
|
||||
if (dragState.draggedKey != itemKey) return
|
||||
fingerOffset.floatValue += deltaY
|
||||
reorderLive()
|
||||
}
|
||||
|
||||
private fun reorderLive() {
|
||||
val items = itemsState.value
|
||||
val myIndex = items.indexOfFirst { key(it) == itemKey }
|
||||
if (myIndex !in items.indices) return
|
||||
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
|
||||
val visible = dragState.listState.layoutInfo.visibleItemsInfo
|
||||
val onMove = onMoveState.value
|
||||
// Dragging down: swap when the dragged center passes the next row's midpoint.
|
||||
if (myIndex < items.lastIndex) {
|
||||
val nextKey = key(items[myIndex + 1])
|
||||
val next = visible.firstOrNull { it.key == nextKey }
|
||||
if (next != null && myCenter > next.offset + next.size / 2f) {
|
||||
onMove(myIndex, myIndex + 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Dragging up: swap when the dragged center passes the previous row's midpoint.
|
||||
if (myIndex > 0) {
|
||||
val prevKey = key(items[myIndex - 1])
|
||||
val prev = visible.firstOrNull { it.key == prevKey }
|
||||
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
|
||||
onMove(myIndex, myIndex - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the drag bookkeeping and fly the lifted row back into its slot.
|
||||
// All state resets happen inside the launched block so the settle animation takes over from
|
||||
// the current visual position without a one-frame jump: isSettling is flipped to true
|
||||
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
|
||||
// flags are cleared.
|
||||
internal fun onDragEnd() {
|
||||
val lastOffset = currentTranslation()
|
||||
if (kotlin.math.abs(lastOffset) < 1f) {
|
||||
fingerOffset.floatValue = 0f
|
||||
dragState.draggedKey = null
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
settleAnim.snapTo(lastOffset)
|
||||
isSettling = true
|
||||
fingerOffset.floatValue = 0f
|
||||
dragState.draggedKey = null
|
||||
settleAnim.animateTo(0f, reorderSettleSpec)
|
||||
isSettling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers a [DragRowState] for one row of a drag-reorderable list.
|
||||
*
|
||||
* [items] must be the exact (optionally section-scoped) list backing the enclosing
|
||||
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
|
||||
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
|
||||
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> rememberDragRowState(
|
||||
dragState: DragReorderState,
|
||||
items: List<T>,
|
||||
item: T,
|
||||
key: (T) -> Any,
|
||||
onMove: (from: Int, to: Int) -> Unit
|
||||
): DragRowState<T> {
|
||||
val scope = rememberCoroutineScope()
|
||||
val itemsState = rememberUpdatedState(items)
|
||||
val onMoveState = rememberUpdatedState(onMove)
|
||||
val itemKey = key(item)
|
||||
val rowState = remember(dragState, itemKey) {
|
||||
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
|
||||
}
|
||||
LaunchedEffect(rowState.isDragging) {
|
||||
if (!rowState.isDragging) return@LaunchedEffect
|
||||
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
|
||||
}
|
||||
return rowState
|
||||
}
|
||||
|
||||
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
|
||||
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { rowState.onDragStart() },
|
||||
onDragEnd = { rowState.onDragEnd() },
|
||||
onDragCancel = { rowState.onDragEnd() }
|
||||
) { change, dragAmount ->
|
||||
change.consume()
|
||||
rowState.onDrag(dragAmount.y)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
|
||||
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
|
||||
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
|
||||
val shape = MaterialTheme.shapes.small
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isLifted) 1.02f else 1f,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
label = "dragScale"
|
||||
)
|
||||
return this
|
||||
.graphicsLayer {
|
||||
if (isLifted) {
|
||||
this.translationY = translationY
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.then(
|
||||
if (isLifted) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.shadow(8.dp, shape, clip = false)
|
||||
.background(MaterialTheme.colorScheme.surface, shape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
|
||||
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
|
||||
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
|
||||
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
|
||||
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
|
||||
* so no separate scroll-compensation bookkeeping is required here.
|
||||
*/
|
||||
private suspend fun autoScrollWhileDragging(
|
||||
listState: LazyListState,
|
||||
startCenterY: MutableFloatState,
|
||||
fingerOffset: MutableFloatState
|
||||
) {
|
||||
val threshold = 48f
|
||||
val maxSpeed = 24f
|
||||
while (true) {
|
||||
val info = listState.layoutInfo
|
||||
val center = startCenterY.floatValue + fingerOffset.floatValue
|
||||
val top = info.viewportStartOffset + threshold
|
||||
val bottom = info.viewportEndOffset - threshold
|
||||
val delta = when {
|
||||
center < top -> -(top - center).coerceAtMost(maxSpeed)
|
||||
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
|
||||
else -> 0f
|
||||
}
|
||||
if (delta != 0f) listState.scrollBy(delta)
|
||||
delay(16L.milliseconds)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ data object Spacing {
|
||||
val extraSmall = 6.dp
|
||||
val small = 8.dp
|
||||
val medium = 12.dp
|
||||
val large = 16.dp
|
||||
val large = 12.dp
|
||||
val extraLarge = 24.dp
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,320q-33,0 -56.5,-23.5T160,240q0,-33 23.5,-56.5T240,160q33,0 56.5,23.5T320,240q0,33 -23.5,56.5T240,320zM240,560q-33,0 -56.5,-23.5T160,480q0,-33 23.5,-56.5T240,400q33,0 56.5,23.5T320,480q0,33 -23.5,56.5T240,560zM240,800q-33,0 -56.5,-23.5T160,720q0,-33 23.5,-56.5T240,640q33,0 56.5,23.5T320,720q0,33 -23.5,56.5T240,800zM720,320q-33,0 -56.5,-23.5T640,240q0,-33 23.5,-56.5T720,160q33,0 56.5,23.5T800,240q0,33 -23.5,56.5T720,320zM720,560q-33,0 -56.5,-23.5T640,480q0,-33 23.5,-56.5T720,400q33,0 56.5,23.5T800,480q0,33 -23.5,56.5T720,560zM720,800q-33,0 -56.5,-23.5T640,720q0,-33 23.5,-56.5T720,640q33,0 56.5,23.5T800,720q0,33 -23.5,56.5T720,800z" />
|
||||
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
|
||||
</vector>
|
||||
@@ -121,6 +121,8 @@
|
||||
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
|
||||
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
|
||||
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
|
||||
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
|
||||
<string name="prefs_data_sources_sat_title">Данные спутников</string>
|
||||
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
|
||||
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -121,6 +121,8 @@
|
||||
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්ර ප්රතිස්ථාපනය කරන්න</string>
|
||||
<string name="prefs_data_sources_sat_title">චන්ද්රිකා දත්ත</string>
|
||||
<string name="prefs_data_sources_transceivers_title">සම්ප්රේෂක දත්ත</string>
|
||||
<string name="prefs_data_sources_sat_hint">ඉහළ ප්රමුඛතාව: චන්ද්රිකා නම්</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්රමුඛතාව: සම්ප්රේෂක දත්ත</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
|
||||
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
|
||||
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
|
||||
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -121,6 +121,8 @@
|
||||
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
|
||||
<string name="prefs_data_sources_sat_title">Дані супутників</string>
|
||||
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
|
||||
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
|
||||
<string name="prefs_data_sources_tle_switch">URL TLE</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -179,6 +179,8 @@
|
||||
<string name="prefs_data_sources_restore">恢复默认源</string>
|
||||
<string name="prefs_data_sources_sat_title">卫星数据</string>
|
||||
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
|
||||
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
|
||||
<string name="prefs_data_sources_tle_switch">TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
|
||||
@@ -221,6 +221,8 @@
|
||||
<string name="prefs_data_sources_restore">Restore default sources</string>
|
||||
<string name="prefs_data_sources_sat_title">Satellites data</string>
|
||||
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
|
||||
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
|
||||
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
|
||||
Reference in new issue
Block a user