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() {