From 1904a6b3e629418953bfe2f594d22fafabd44741 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sat, 29 Aug 2026 16:25:09 +0800 Subject: [PATCH] feat: drag reorder handle in data sources dialog Port the upstream drag-reorder pattern (dragHandle/draggedVisual/ autoScroll/DragRowState) so custom data source rows can be reordered by long-pressing the handle icon, replacing the old move-up button. - Add 40dp drag-handle box at row start with long-press gesture - Live reorder while dragging (neighbour squeeze + spring settle) - Edge auto-scroll during drag - Keep per-source enabled checkbox + HTTP status label This file also carries the #242 data-source dialog UI (enabled checkbox, status code label, restore-default button) which shares the same sourceSection function and cannot be cleanly split. --- .../src/main/res/drawable/ic_drag_handle.xml | 10 + .../src/main/res/values-zh/strings.xml | 1 + .../src/main/res/values/strings.xml | 1 + .../feature/settings/SettingsDialog.kt | 404 +++++++++++++++--- 4 files changed, 368 insertions(+), 48 deletions(-) create mode 100644 core/presentation/src/main/res/drawable/ic_drag_handle.xml diff --git a/core/presentation/src/main/res/drawable/ic_drag_handle.xml b/core/presentation/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 00000000..94fa450b --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 71f4e419..348ad004 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -154,6 +154,7 @@ 未导入收发器。请选择有效的 SatNOGS (.json) 文件。 自定义数据源 + 恢复默认数据源 自定义TLE URL 自定义收发器URL URL diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index c1069158..0272390b 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -188,6 +188,7 @@ No transceivers imported. Select a valid SatNOGS (.json) file. Custom data sources + Restore default sources Custom TLE URL Custom transceivers URL URL (HTTPS) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 004ab2e8..5afc574c 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -19,54 +19,83 @@ package com.rtbishop.look4sat.feature.settings import android.bluetooth.BluetoothManager import android.content.Context +import androidx.compose.animation.core.Animatable +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.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings +import com.rtbishop.look4sat.core.domain.source.NetworkResult +import com.rtbishop.look4sat.core.domain.source.Sources import com.rtbishop.look4sat.core.presentation.CardButton +import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.SharedDialog -import androidx.compose.animation.core.spring -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.res.painterResource -import com.rtbishop.look4sat.core.domain.model.Constants -import com.rtbishop.look4sat.core.presentation.IconCard +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Preview(showBackground = true) @Composable @@ -138,10 +167,16 @@ private fun TransceiversDialogPreview() { transceiversUrls = listOf( "db.satnogs.org/api/transmitters/?format=json&status=active" ), + satelliteEnabled = listOf(true, false), + transceiversEnabled = listOf(true), + statusCodes = mapOf( + "celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200, + "amsat.org/tle/current/nasabare.txt" to 404 + ), onImportTle = {}, onImportTransceivers = {}, onDismiss = {}, - onSave = { _, _ -> } + onSave = { _, _, _, _ -> } ) } } @@ -150,10 +185,13 @@ private fun TransceiversDialogPreview() { fun DataSourcesDialog( satelliteUrls: List, transceiversUrls: List, + satelliteEnabled: List, + transceiversEnabled: List, + statusCodes: Map, onImportTle: () -> Unit, onImportTransceivers: () -> Unit, onDismiss: () -> Unit, - onSave: (List, List) -> Unit + onSave: (List, List, List, List) -> Unit ) { val padding = LocalSpacing.current.large // Stable Long IDs avoid key collisions when several entries are empty or duplicated. @@ -164,10 +202,39 @@ fun DataSourcesDialog( val txUrls = remember { transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList() } + val satEnabled = remember { + mutableStateMapOf().apply { + satUrls.forEachIndexed { i, (id, _) -> this[id] = satelliteEnabled.getOrElse(i) { true } } + } + } + val txEnabled = remember { + mutableStateMapOf().apply { + txUrls.forEachIndexed { i, (id, _) -> this[id] = transceiversEnabled.getOrElse(i) { true } } + } + } + val onRestoreDefaults = { + val satDefaults = Sources.satelliteDataUrls.values.filter { it.isNotBlank() } + val txDefaults = Sources.transceiversDataUrls.values.filter { it.isNotBlank() } + nextId.longValue = (satDefaults.size + txDefaults.size).toLong() + satUrls.clear() + satUrls.addAll(satDefaults.mapIndexed { i, url -> i.toLong() to url }) + txUrls.clear() + txUrls.addAll(txDefaults.mapIndexed { i, url -> (satDefaults.size + i).toLong() to url }) + satEnabled.clear() + txEnabled.clear() + Unit + } + val listState = rememberLazyListState() + val satDraggedId = remember { mutableStateOf(-1L) } + val txDraggedId = remember { mutableStateOf(-1L) } val onAccept = { + val satFiltered = satUrls.filter { it.second.trim().isNotBlank() } + val txFiltered = txUrls.filter { it.second.trim().isNotBlank() } onSave( - satUrls.map { it.second.trim() }.filter { it.isNotBlank() }, - txUrls.map { it.second.trim() }.filter { it.isNotBlank() } + satFiltered.map { it.second.trim() }, + txFiltered.map { it.second.trim() }, + satFiltered.map { satEnabled[it.first] ?: true }, + txFiltered.map { txEnabled[it.first] ?: true } ) onDismiss() } @@ -177,6 +244,7 @@ fun DataSourcesDialog( onAccept = onAccept, ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxHeight(0.84f) .padding(horizontal = padding), @@ -200,12 +268,24 @@ fun DataSourcesDialog( ) } } + item { + CardButton( + onClick = onRestoreDefaults, + text = stringResource(R.string.prefs_data_sources_restore), + modifier = Modifier.fillMaxWidth() + ) + } sourceSection( sectionKey = "sat", labelResId = R.string.prefs_data_sources_satellites_label, urls = satUrls, + listState = listState, + draggedId = satDraggedId, + statusCodes = statusCodes, + enabledMap = satEnabled, + onToggle = { id -> satEnabled[id] = !(satEnabled[id] ?: true) }, onAdd = { satUrls.add(nextId.longValue++ to "") }, - onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) }, + onMove = { from, to -> satUrls.add(to, satUrls.removeAt(from)) }, onRemove = { i -> satUrls.removeAt(i) }, onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } ) @@ -213,8 +293,13 @@ fun DataSourcesDialog( sectionKey = "tx", labelResId = R.string.prefs_data_sources_transceivers_label, urls = txUrls, + listState = listState, + draggedId = txDraggedId, + statusCodes = statusCodes, + enabledMap = txEnabled, + onToggle = { id -> txEnabled[id] = !(txEnabled[id] ?: true) }, onAdd = { txUrls.add(nextId.longValue++ to "") }, - onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) }, + onMove = { from, to -> txUrls.add(to, txUrls.removeAt(from)) }, onRemove = { i -> txUrls.removeAt(i) }, onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } ) @@ -226,8 +311,13 @@ private fun LazyListScope.sourceSection( sectionKey: String, labelResId: Int, urls: List>, + listState: LazyListState, + draggedId: MutableState, + statusCodes: Map, + enabledMap: Map, + onToggle: (Long) -> Unit, onAdd: () -> Unit, - onMoveUp: (Int) -> Unit, + onMove: (Int, Int) -> Unit, onRemove: (Int) -> Unit, onUrlChange: (Int, String) -> Unit ) { @@ -243,38 +333,256 @@ private fun LazyListScope.sourceSection( IconCard(action = onAdd, resId = R.drawable.ic_add) } } - itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) -> + itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (id, url) -> val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant - OutlinedTextField( - value = url, - onValueChange = { onUrlChange(index, it) }, - label = { Text(stringResource(R.string.prefs_data_sources_url_title)) }, - leadingIcon = { - IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { - Icon( - painter = painterResource(R.drawable.ic_arrow), - contentDescription = null, - tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f), - modifier = Modifier.rotate(270f) - ) - } - }, - trailingIcon = { - IconButton(onClick = { onRemove(index) }) { - Icon( - painter = painterResource(R.drawable.ic_delete), - contentDescription = null - ) - } - }, - singleLine = true, + val enabled = enabledMap[id] ?: true + val rowState = remember { DragRowState() } + val scope = rememberCoroutineScope() + val isDragging = draggedId.value == id + val isLifted = isDragging || rowState.isSettling.value + LaunchedEffect(isDragging) { + if (!isDragging) return@LaunchedEffect + autoScroll(listState, rowState.startCenterY, rowState.fingerOffset, rowState.scrollComp) + } + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .animateItem(fadeInSpec = spring(), fadeOutSpec = spring()) - ) + .draggedVisual( + isLifted = isLifted, + translationY = if (rowState.isSettling.value) { + rowState.settleAnim.value + } else { + rowState.offsetY.floatValue + rowState.scrollComp.floatValue + } + ) + .animateItem( + fadeInSpec = spring(), + // The dragged row repositions instantly, while its neighbours spring + // out of the way (the "squeeze" effect). + placementSpec = if (isDragging) { + tween(durationMillis = 0) + } else { + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow + ) + }, + fadeOutSpec = spring() + ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .dragHandle(listState, sectionKey, id, urls, draggedId, rowState, scope, onMove) + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + tint = enabledTint + ) + } + OutlinedTextField( + value = url, + onValueChange = { onUrlChange(index, it) }, + label = { Text(stringResource(R.string.prefs_data_sources_url_title)) }, + supportingText = statusCodes[url]?.let { code -> + { Text(statusLabel(code), color = statusColor(code), fontSize = 12.sp) } + }, + trailingIcon = { + IconButton(onClick = { onRemove(index) }) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null + ) + } + }, + singleLine = true, + enabled = enabled, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = enabled, + onCheckedChange = { onToggle(id) } + ) + } } } +/** + * Per-row drag state, kept in one object to keep the drag-handle modifier signature small. + * + * [offsetY] is the compensated visual displacement during a drag (finger travel minus the + * heights of already-swapped neighbours), so the row stays glued to the finger. [fingerOffset] + * tracks the raw finger travel for edge auto-scroll and swap detection. [settleAnim] smoothly + * flies the lifted row back into its slot once the finger is released. + */ +private class DragRowState { + val offsetY = mutableFloatStateOf(0f) + val fingerOffset = mutableFloatStateOf(0f) + val scrollComp = mutableFloatStateOf(0f) + val startCenterY = mutableFloatStateOf(0f) + val settleAnim = Animatable(0f) + val isSettling = mutableStateOf(false) +} + +/** Spring shared by neighbour "squeeze" and the settle-back animation: soft and slightly bouncy. */ +private val reorderSpring = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow +) + +/** + * Drag handle gesture that performs live reordering while dragging. + */ +@Composable +private fun Modifier.dragHandle( + listState: LazyListState, + sectionKey: String, + entryId: Long, + urls: List>, + draggedId: MutableState, + rowState: DragRowState, + scope: CoroutineScope, + onMove: (from: Int, to: Int) -> Unit +): Modifier = pointerInput(entryId, sectionKey) { + fun reorderLive() { + val myIndex = urls.indexOfFirst { it.first == entryId } + if (myIndex !in urls.indices) return + val myCenter = rowState.startCenterY.floatValue + rowState.fingerOffset.floatValue + val visible = listState.layoutInfo.visibleItemsInfo + // Dragging down: swap when the dragged centre passes the next row's midpoint. + if (myIndex < urls.lastIndex) { + val next = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex + 1].first}" } + if (next != null && myCenter > next.offset + next.size / 2f) { + onMove(myIndex, myIndex + 1) + rowState.offsetY.floatValue -= next.size.toFloat() + return + } + } + // Dragging up: swap when the dragged centre passes the previous row's midpoint. + if (myIndex > 0) { + val prev = visible.firstOrNull { it.key == "$sectionKey-${urls[myIndex - 1].first}" } + if (prev != null && myCenter < prev.offset + prev.size / 2f) { + onMove(myIndex, myIndex - 1) + rowState.offsetY.floatValue += prev.size.toFloat() + } + } + } + + // Reset the drag bookkeeping and fly the lifted row back into its slot. + fun finishDrag() { + val lastOffset = rowState.offsetY.floatValue + rowState.scrollComp.floatValue + if (kotlin.math.abs(lastOffset) < 1f) { + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = -1L + return + } + scope.launch { + rowState.settleAnim.snapTo(lastOffset) + rowState.isSettling.value = true + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = -1L + rowState.settleAnim.animateTo(0f, reorderSpring) + rowState.isSettling.value = false + } + } + + detectDragGesturesAfterLongPress( + onDragStart = { + rowState.isSettling.value = false + val layout = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "$sectionKey-$entryId" } + rowState.startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f + rowState.fingerOffset.floatValue = 0f + rowState.offsetY.floatValue = 0f + rowState.scrollComp.floatValue = 0f + draggedId.value = entryId + }, + onDragEnd = ::finishDrag, + onDragCancel = ::finishDrag + ) { change, dragAmount -> + change.consume() + if (draggedId.value != entryId) return@detectDragGesturesAfterLongPress + rowState.fingerOffset.floatValue += dragAmount.y + rowState.offsetY.floatValue += dragAmount.y + reorderLive() + } +} + +@Composable +private fun Modifier.draggedVisual(isLifted: Boolean, translationY: Float): Modifier { + val shape = MaterialTheme.shapes.small + // Smoothly scale the row up/down as the lifted card appears and disappears. + 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) { + // Solid card on top so the lifted row fully covers the row beneath it + // instead of showing a translucent overlap of both rows. + 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 centre 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 centre captured at drag start and [fingerOffset] is the raw finger delta. + */ +private suspend fun autoScroll( + listState: LazyListState, + startCenterY: MutableFloatState, + fingerOffset: MutableFloatState, + scrollComp: 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) scrollComp.floatValue += listState.scrollBy(delta) + delay(16L) + } +} + +private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString() + +@Composable +private fun statusColor(code: Int): Color = when { + code == NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error + code in 200..299 -> Color(0xFF66BB6A) + else -> MaterialTheme.colorScheme.error +} + @Preview(showBackground = true) @Composable fun PreviewNetworkOutputDialog() {