From d7e1ce11c9a890e1252fb7d919cba356e066a142 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Sun, 2 Aug 2026 23:07:28 +0800 Subject: [PATCH] fix: smooth dual-station elevation curve - Fix cubic Bezier control points (proper Catmull-Rom to Bezier conversion) - Refine AOS/LOS more robustly by walking from edge into the pass window - Still 5s sampling for smooth curves --- .../java/com/rtbishop/look4sat/MainScreen.kt | 10 +- .../core/data/injection/MainContainer.kt | 11 + .../core/domain/repository/IMainContainer.kt | 14 +- .../feature/mutual/ElevationCurveChart.kt | 29 +- .../look4sat/feature/mutual/MutualScreen.kt | 518 +++++++++++------- .../feature/mutual/MutualViewModel.kt | 36 +- feature/radar/build.gradle.kts | 4 + .../look4sat/feature/radar/RadarScreen.kt | 28 +- .../look4sat/feature/radar/RadarState.kt | 8 +- 9 files changed, 437 insertions(+), 221 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index d82a3360..3c48290a 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -206,7 +206,15 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { } entry { val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo) - MutualScreen(viewModel = viewModel) + MutualScreen( + viewModel = viewModel, + navigateUp = navigateBack, + navigateToRadar = { catNum, aosTime, pass -> + pass?.let { container.setMutualPassData(it) } + container.satelliteRepo.selectPass(catNum, aosTime) + backStack.add(Screen.Radar) + } + ) } entry { SettingsDestination() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index a45e3481..ab4e95cf 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -50,6 +50,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar @@ -61,6 +62,9 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient class MainContainer(private val context: Context) : IMainContainer { @@ -77,6 +81,13 @@ class MainContainer(private val context: Context) : IMainContainer { RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) } + private val _mutualPassData = MutableStateFlow(MutualPassData()) + override val mutualPassData: StateFlow = _mutualPassData.asStateFlow() + + override fun setMutualPassData(data: MutualPassData) { + _mutualPassData.value = data + } + override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) override fun provideShowToast(): IShowToast = ShowToast(context) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index a46bbaeb..bdb454ef 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -16,12 +16,13 @@ * along with this program. If not, see . */ package com.rtbishop.look4sat.core.domain.repository - +import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture import com.rtbishop.look4sat.core.domain.usecase.ISaveImage import com.rtbishop.look4sat.core.domain.usecase.IShowToast import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow interface IMainContainer { val appScope: CoroutineScope @@ -30,6 +31,8 @@ interface IMainContainer { val satelliteRepo: ISatelliteRepo val databaseRepo: IDatabaseRepo val radioTrackingService: IRadioTrackingService + val mutualPassData: StateFlow + fun setMutualPassData(data: MutualPassData) fun provideAddToCalendar(): IAddToCalendar fun provideShowToast(): IShowToast fun provideBluetoothReporter(): IReporter @@ -41,6 +44,15 @@ interface IMainContainer { fun provideSaveImage(): ISaveImage } +data class MutualPassData( + val samples: List>> = emptyList(), + val startTime: Long = 0L, + val endTime: Long = 0L, + val maxElev: Double = 10.0, + val labelA: String = "你", + val labelB: String = "友台" +) + interface IContainerProvider { fun getMainContainer(): IMainContainer } diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt index dd249d64..e13a20dd 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/ElevationCurveChart.kt @@ -158,20 +158,21 @@ fun ElevationCurveChart( pathA.moveTo(ptsA[0].x, ptsA[0].y) pathB.moveTo(ptsB[0].x, ptsB[0].y) for (i in 1 until ptsA.size) { - val prev = ptsA.getOrNull(i - 2) ?: ptsA[i - 1] - val curr = ptsA[i - 1] - val next = ptsA.getOrNull(i + 1) ?: ptsA[i] - val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f) - val cp2 = Offset(ptsA[i].x - (ptsA[i].x - curr.x) / 6f, ptsA[i].y - (ptsA[i].y - curr.y) / 6f) - pathA.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsA[i].x, ptsA[i].y) - } - for (i in 1 until ptsB.size) { - val prev = ptsB.getOrNull(i - 2) ?: ptsB[i - 1] - val curr = ptsB[i - 1] - val next = ptsB.getOrNull(i + 1) ?: ptsB[i] - val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f) - val cp2 = Offset(ptsB[i].x - (ptsB[i].x - curr.x) / 6f, ptsB[i].y - (ptsB[i].y - curr.y) / 6f) - pathB.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsB[i].x, ptsB[i].y) + val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1] + val p1a = ptsA[i - 1] + val p2a = ptsA[i] + val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i] + val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f) + val cp2a = Offset(p2a.x - (p3a.x - p1a.x) / 6f, p2a.y - (p3a.y - p1a.y) / 6f) + pathA.cubicTo(cp1a.x, cp1a.y, cp2a.x, cp2a.y, p2a.x, p2a.y) + + val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1] + val p1b = ptsB[i - 1] + val p2b = ptsB[i] + val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i] + val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f) + val cp2b = Offset(p2b.x - (p3b.x - p1b.x) / 6f, p2b.y - (p3b.y - p1b.y) / 6f) + pathB.cubicTo(cp1b.x, cp1b.y, cp2b.x, cp2b.y, p2b.x, p2b.y) } } diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index 07274cc9..430252e6 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -17,6 +17,10 @@ */ package com.rtbishop.look4sat.feature.mutual +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,11 +34,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text @@ -42,9 +49,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rtbishop.look4sat.core.domain.repository.MutualPassData +import com.rtbishop.look4sat.core.presentation.IconCard +import com.rtbishop.look4sat.core.presentation.R +import com.rtbishop.look4sat.core.presentation.ScreenColumn +import com.rtbishop.look4sat.core.presentation.TopBar +import com.rtbishop.look4sat.core.presentation.isVerticalLayout import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -52,183 +70,275 @@ import java.util.Locale @Composable fun MutualScreen( viewModel: MutualViewModel, + navigateUp: () -> Unit = {}, + navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> }, modifier: Modifier = Modifier ) { val state by viewModel.uiState.collectAsState() + + ScreenColumn( + topBar = { isVertical -> + TopBar( + isVerticalLayout = isVertical, + startAction = { + IconCard(action = navigateUp, resId = R.drawable.ic_back) + }, + topInfo = { + Text( + text = "对台过境", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + }, + bottomInfo = { + if (state.mutualPasses.isNotEmpty()) { + Text( + text = "找到 ${state.mutualPasses.size} 个过境", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + endAction = { + if (state.isCalculating) { + CircularProgressIndicator( + modifier = Modifier + .height(24.dp) + .width(24.dp), + strokeWidth = 2.dp + ) + } + } + ) + } + ) { isVertical -> + MutualContent( + state = state, + isVertical = isVertical, + onQuery = viewModel::queryMutualPasses, + onSelectPass = viewModel::onSelectPass, + onNavigateToRadar = navigateToRadar, + onStationALat = viewModel::onStationALat, + onStationALon = viewModel::onStationALon, + onStationAGrid = viewModel::onStationAGrid, + onStationAMinElev = viewModel::onStationAMinElev, + onStationBLat = viewModel::onStationBLat, + onStationBLon = viewModel::onStationBLon, + onStationBGrid = viewModel::onStationBGrid, + onStationBMinElev = viewModel::onStationBMinElev, + onHoursAhead = viewModel::onHoursAhead, + onClearError = viewModel::clearError + ) + } +} + +@Composable +private fun MutualContent( + state: MutualUiState, + isVertical: Boolean, + onQuery: () -> Unit, + onSelectPass: (Int) -> Unit, + onNavigateToRadar: (Int, Long, MutualPassData?) -> Unit, + onStationALat: (String) -> Unit, + onStationALon: (String) -> Unit, + onStationAGrid: (String) -> Unit, + onStationAMinElev: (Double) -> Unit, + onStationBLat: (String) -> Unit, + onStationBLon: (String) -> Unit, + onStationBGrid: (String) -> Unit, + onStationBMinElev: (Double) -> Unit, + onHoursAhead: (Int) -> Unit, + onClearError: () -> Unit +) { val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) } LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // Title - item { - Text( - text = "对台过境查询", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(bottom = 4.dp) - ) - Text( - text = "查看双方同时过境的卫星,然后相约 QSO 吧!", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - // Error message val errorMsg = state.errorMessage if (errorMsg != null) { item { - Text( - text = errorMsg, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium - ) - } - } - - // Station A input - item { - Text("你的位置", style = MaterialTheme.typography.titleSmall) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = state.stationALat, - onValueChange = viewModel::onStationALat, - label = { Text("纬度") }, - placeholder = { Text("39.9042") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = state.stationALon, - onValueChange = viewModel::onStationALon, - label = { Text("经度") }, - placeholder = { Text("116.4074") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - OutlinedTextField( - value = state.stationAGrid, - onValueChange = viewModel::onStationAGrid, - label = { Text("网格(4/6/8位,填此可省略经纬度)") }, - placeholder = { Text("ON79uj") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Text("最小仰角:${state.stationAMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall) - Slider( - value = state.stationAMinElev.toFloat(), - onValueChange = { viewModel.onStationAMinElev(it.toDouble()) }, - valueRange = 0f..90f, - steps = 17, - modifier = Modifier.fillMaxWidth() - ) - } - - // Station B input - item { - Text("友台位置", style = MaterialTheme.typography.titleSmall) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = state.stationBLat, - onValueChange = viewModel::onStationBLat, - label = { Text("纬度") }, - placeholder = { Text("34.0522") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = state.stationBLon, - onValueChange = viewModel::onStationBLon, - label = { Text("经度") }, - placeholder = { Text("-118.2437") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - OutlinedTextField( - value = state.stationBGrid, - onValueChange = viewModel::onStationBGrid, - label = { Text("网格(4/6/8位,填此可省略经纬度)") }, - placeholder = { Text("PM01tv") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Text("最小仰角:${state.stationBMinElev.toInt()}°", style = MaterialTheme.typography.bodySmall) - Slider( - value = state.stationBMinElev.toFloat(), - onValueChange = { viewModel.onStationBMinElev(it.toDouble()) }, - valueRange = 0f..90f, - steps = 17, - modifier = Modifier.fillMaxWidth() - ) - } - - // Time range - item { - Text("时间范围", style = MaterialTheme.typography.titleSmall) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - listOf(6, 12, 24, 48, 72).forEach { hours -> - FilterChip( - selected = state.hoursAhead == hours, - onClick = { viewModel.onHoursAhead(hours) }, - label = { Text("${hours}h") } + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = errorMsg, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium ) } } } - // Query button + // Input form item { - Button( - onClick = { viewModel.queryMutualPasses() }, - enabled = !state.isCalculating, - modifier = Modifier.fillMaxWidth() - ) { - if (state.isCalculating) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp).width(20.dp), - strokeWidth = 2.dp + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "你的位置", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium ) - Spacer(Modifier.width(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = state.stationALat, + onValueChange = onStationALat, + label = { Text("纬度") }, + placeholder = { Text("39.9042") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.stationALon, + onValueChange = onStationALon, + label = { Text("经度") }, + placeholder = { Text("116.4074") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = state.stationAGrid, + onValueChange = onStationAGrid, + label = { Text("网格(4/6/8位,填此可省略经纬度)") }, + placeholder = { Text("ON79uj") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "最小仰角:${state.stationAMinElev.toInt()}°", + style = MaterialTheme.typography.bodySmall + ) + Slider( + value = state.stationAMinElev.toFloat(), + onValueChange = { onStationAMinElev(it.toDouble()) }, + valueRange = 0f..90f, + steps = 17 + ) + + HorizontalDivider() + + Text( + text = "友台位置", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = state.stationBLat, + onValueChange = onStationBLat, + label = { Text("纬度") }, + placeholder = { Text("34.0522") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = state.stationBLon, + onValueChange = onStationBLon, + label = { Text("经度") }, + placeholder = { Text("-118.2437") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = state.stationBGrid, + onValueChange = onStationBGrid, + label = { Text("网格(4/6/8位,填此可省略经纬度)") }, + placeholder = { Text("PM01tv") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "最小仰角:${state.stationBMinElev.toInt()}°", + style = MaterialTheme.typography.bodySmall + ) + Slider( + value = state.stationBMinElev.toFloat(), + onValueChange = { onStationBMinElev(it.toDouble()) }, + valueRange = 0f..90f, + steps = 17 + ) + + HorizontalDivider() + + Text( + text = "时间范围", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(6, 12, 24, 48, 72).forEach { hours -> + FilterChip( + selected = state.hoursAhead == hours, + onClick = { onHoursAhead(hours) }, + label = { Text("${hours}h") } + ) + } + } + + Button( + onClick = onQuery, + enabled = !state.isCalculating, + modifier = Modifier.fillMaxWidth() + ) { + if (state.isCalculating) { + CircularProgressIndicator( + modifier = Modifier + .height(18.dp) + .width(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (state.isCalculating) "计算中..." else "查询过境") + } } - Text(if (state.isCalculating) "计算中..." else "查询过境") } } // Results - if (state.mutualPasses.isNotEmpty()) { - item { - Text( - text = "找到 ${state.mutualPasses.size} 个共同过境", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } - itemsIndexed(state.mutualPasses) { index, pass -> - MutualPassCard( - pass = pass, - isExpanded = state.selectedPassIndex == index, - timeFormat = timeFormat, - onToggle = { viewModel.onSelectPass(if (state.selectedPassIndex == index) -1 else index) } - ) - } + itemsIndexed(state.mutualPasses) { index, pass -> + val mutualData = MutualPassData( + samples = pass.elevationSamples, + startTime = pass.startTime, + endTime = pass.endTime, + maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0), + labelA = "你", + labelB = "友台" + ) + MutualPassCard( + pass = pass, + isExpanded = state.selectedPassIndex == index, + timeFormat = timeFormat, + onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) }, + onNavigateToRadar = { onNavigateToRadar(pass.catNum, pass.startTime, mutualData) } + ) } } } @@ -238,39 +348,79 @@ private fun MutualPassCard( pass: MutualPass, isExpanded: Boolean, timeFormat: SimpleDateFormat, - onToggle: () -> Unit + onClick: () -> Unit, + onNavigateToRadar: () -> Unit ) { - Card( - onClick = onToggle, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ) + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) ) { Column(modifier = Modifier.padding(12.dp)) { - Text( - text = pass.name, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "你: ${pass.maxElevationA}° 友台: ${pass.maxElevationB}°", - style = MaterialTheme.typography.bodySmall - ) - - if (isExpanded && pass.elevationSamples.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - ElevationCurveChart( - samples = pass.elevationSamples, - startTime = pass.startTime, - endTime = pass.endTime, - maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pass.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) ) + Text( + text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "你: ${pass.maxElevationA}°", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "友台: ${pass.maxElevationB}°", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + if (pass.elevationSamples.isNotEmpty()) { + ElevationCurveChart( + samples = pass.elevationSamples, + startTime = pass.startTime, + endTime = pass.endTime, + maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0) + ) + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onNavigateToRadar, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.ic_radar), + contentDescription = null, + modifier = Modifier.height(16.dp).width(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text("查看雷达") + } + } } } } diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index cce34c39..b1377e40 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -263,36 +263,34 @@ class MutualViewModel( minElevADeg: Double, minElevBDeg: Double, approxTime: Long, step: Long, goingUp: Boolean ): Long { - var t = approxTime - val limit = 30_000L // search up to 30 seconds if (goingUp) { - // Find the point where both stations just go above minElev - var best = t - t -= limit - while (t <= approxTime + limit) { + // AOS: walk backward from approxTime to find the last sample where both are below, + // then AOS is the next step after that. + var t = approxTime + while (t > approxTime - 70_000L) { val eA = elevationDeg(sat, posA, t) val eB = elevationDeg(sat, posB, t) if (eA > minElevADeg && eB > minElevBDeg) { - best = t - break + t -= step + } else { + return t + step } - t += step } - return best + return approxTime - 70_000L + step } else { - // Find the point where either station just drops below minElev - var best = t - t -= limit - while (t <= approxTime + limit) { + // LOS: walk forward from approxTime to find the first sample where either drops below, + // then LOS is the step before that. + var t = approxTime + while (t < approxTime + 70_000L) { val eA = elevationDeg(sat, posA, t) val eB = elevationDeg(sat, posB, t) - if (eA < minElevADeg || eB < minElevBDeg) { - best = t - break + if (eA > minElevADeg && eB > minElevBDeg) { + t += step + } else { + return t - step } - t += step } - return best + return approxTime + 70_000L - step } } diff --git a/feature/radar/build.gradle.kts b/feature/radar/build.gradle.kts index 5e837094..723edbae 100644 --- a/feature/radar/build.gradle.kts +++ b/feature/radar/build.gradle.kts @@ -5,3 +5,7 @@ plugins { android { namespace = "com.rtbishop.look4sat.feature.radar" } + +dependencies { + implementation(project(":feature:mutual")) +} \ No newline at end of file diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index dde297db..26234417 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -59,6 +59,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard @@ -70,6 +71,7 @@ import com.rtbishop.look4sat.core.presentation.formatFrequency import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.layoutPadding +import com.rtbishop.look4sat.feature.mutual.ElevationCurveChart import kotlinx.coroutines.launch private enum class RadarPage(val title: String) { @@ -83,6 +85,7 @@ fun RadarDestination(navigateUp: () -> Unit) { val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val mutualData by container.mutualPassData.collectAsStateWithLifecycle() // Sync actual permission state on every recomposition so it survives screen re-entry val hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO @@ -97,7 +100,7 @@ fun RadarDestination(navigateUp: () -> Unit) { viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted)) } - RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = { + RadarScreen(uiState, viewModel::onAction, navigateUp, mutualData, requestMicPermission = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) } @@ -107,6 +110,7 @@ private fun RadarScreen( uiState: RadarState, onAction: (RadarAction) -> Unit, navigateUp: () -> Unit, + mutualData: MutualPassData, requestMicPermission: () -> Unit ) { val upcomingPass = uiState.currentPass ?: getDefaultPass() @@ -137,10 +141,16 @@ private fun RadarScreen( } if (isVertical) { RadarCard(uiState, Modifier.weight(1f)) + if (mutualData.samples.isNotEmpty()) { + MutualElevationCard(mutualData, Modifier.weight(1f)) + } PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { RadarCard(uiState, Modifier.weight(1f)) + if (mutualData.samples.isNotEmpty()) { + MutualElevationCard(mutualData, Modifier.weight(1f)) + } PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) } } @@ -304,3 +314,19 @@ private fun RadarLabel( } } } + +@Composable +private fun MutualElevationCard(mutualData: MutualPassData, modifier: Modifier = Modifier) { + ElevatedCard(modifier = modifier) { + Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { + if (mutualData.samples.isNotEmpty()) { + ElevationCurveChart( + samples = mutualData.samples, + startTime = mutualData.startTime, + endTime = mutualData.endTime, + maxElev = mutualData.maxElev + ) + } + } + } +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt index d668670f..5f747026 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarState.kt @@ -63,7 +63,13 @@ data class RadarState( val transceivers: TransceiverSubState = TransceiverSubState(), val radioControl: RadioControlSubState = RadioControlSubState(), val sstv: SstvSubState = SstvSubState(), - val cw: CwSubState = CwSubState() + val cw: CwSubState = CwSubState(), + val mutualSamples: List>> = emptyList(), + val mutualStartTime: Long = 0L, + val mutualEndTime: Long = 0L, + val mutualMaxElev: Double = 10.0, + val mutualLabelA: String = "你", + val mutualLabelB: String = "友台" ) enum class SstvStatus { Idle, Recording }