diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 018fca4a..c549f558 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -72,6 +72,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.repository.MutualPassData import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds @@ -200,6 +201,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { } entry { PassesDestination { catNum, aosTime -> + container.setMutualPassData(MutualPassData()) container.satelliteRepo.selectPass(catNum, aosTime) backStack.add(Screen.Radar) // navigateToRadar() @@ -216,7 +218,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { viewModel = mutualViewModel, navigateUp = navigateBack, navigateToRadar = { catNum, aosTime, pass -> - pass?.let { container.setMutualPassData(it) } + container.setMutualPassData(pass ?: MutualPassData()) container.satelliteRepo.selectPass(catNum, aosTime) backStack.add(Screen.Radar) } @@ -245,6 +247,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { .clickable { val pass = trackingState.currentPass if (pass != null) { + container.setMutualPassData(MutualPassData()) container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) backStack.add(Screen.Radar) } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 32669d59..3c7672df 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -36,7 +36,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { data object Map : Screen(R.drawable.ic_map, R.string.nav_map) @Serializable - data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual) + data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual) @Serializable data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs) diff --git a/core/presentation/src/main/res/drawable/ic_match.xml b/core/presentation/src/main/res/drawable/ic_match.xml new file mode 100644 index 00000000..df1d3a92 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_match.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 50436bde..220613c1 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -9,7 +9,7 @@ 卫星 过境 雷达 - 对台 + 匹配 地图 设置 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index b91911ba..d476eb45 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -10,7 +10,7 @@ Satellites Passes Radar - Mutual + Match Map Settings 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 98630322..f359edf3 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 @@ -88,7 +88,7 @@ fun MutualScreen( }, topInfo = { Text( - text = "对台过境", + text = "过境匹配", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -96,7 +96,7 @@ fun MutualScreen( bottomInfo = { if (state.mutualPasses.isNotEmpty()) { Text( - text = "找到 ${state.mutualPasses.size} 个过境", + text = "找到 ${state.mutualPasses.size} 个匹配", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -183,153 +183,91 @@ private fun MutualContent( // Input form item { - 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 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + MatchIntroCard() + + if (isVertical) { + StationInputCard( + title = "你的位置", + titleColor = MaterialTheme.colorScheme.primary, + lat = state.stationALat, + lon = state.stationALon, + grid = state.stationAGrid, + minElev = state.stationAMinElev, + latPlaceholder = "39.9042", + lonPlaceholder = "116.4074", + gridPlaceholder = "ON79uj", + onLatChange = onStationALat, + onLonChange = onStationALon, + onGridChange = onStationAGrid, + onMinElevChange = onStationAMinElev, + currentPositionAction = onUseCurrentPosition ) + StationInputCard( + title = "友台位置", + titleColor = MaterialTheme.colorScheme.tertiary, + lat = state.stationBLat, + lon = state.stationBLon, + grid = state.stationBGrid, + minElev = state.stationBMinElev, + latPlaceholder = "34.0522", + lonPlaceholder = "-118.2437", + gridPlaceholder = "PM01tv", + onLatChange = onStationBLat, + onLonChange = onStationBLon, + onGridChange = onStationBGrid, + onMinElevChange = onStationBMinElev + ) + } else { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - OutlinedTextField( - value = state.stationALat, - onValueChange = onStationALat, - label = { Text("纬度") }, - placeholder = { Text("39.9042") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, + StationInputCard( + title = "你的位置", + titleColor = MaterialTheme.colorScheme.primary, + lat = state.stationALat, + lon = state.stationALon, + grid = state.stationAGrid, + minElev = state.stationAMinElev, + latPlaceholder = "39.9042", + lonPlaceholder = "116.4074", + gridPlaceholder = "ON79uj", + onLatChange = onStationALat, + onLonChange = onStationALon, + onGridChange = onStationAGrid, + onMinElevChange = onStationAMinElev, + currentPositionAction = onUseCurrentPosition, modifier = Modifier.weight(1f) ) - OutlinedTextField( - value = state.stationALon, - onValueChange = onStationALon, - label = { Text("经度") }, - placeholder = { Text("116.4074") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - singleLine = true, + StationInputCard( + title = "友台位置", + titleColor = MaterialTheme.colorScheme.tertiary, + lat = state.stationBLat, + lon = state.stationBLon, + grid = state.stationBGrid, + minElev = state.stationBMinElev, + latPlaceholder = "34.0522", + lonPlaceholder = "-118.2437", + gridPlaceholder = "PM01tv", + onLatChange = onStationBLat, + onLonChange = onStationBLon, + onGridChange = onStationBGrid, + onMinElevChange = onStationBMinElev, modifier = Modifier.weight(1f) ) } - OutlinedTextField( - value = state.stationAGrid, - onValueChange = onStationAGrid, - label = { Text("网格(4/6/8位,填此可省略经纬度)") }, - placeholder = { Text("ON79uj") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedButton( - onClick = onUseCurrentPosition, - modifier = Modifier.align(Alignment.End) - ) { - Text("当前精确位置", style = MaterialTheme.typography.bodySmall) - } - 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 "查询过境") - } } + + MatchSearchCard( + hoursAhead = state.hoursAhead, + isCalculating = state.isCalculating, + onHoursAhead = onHoursAhead, + onQuery = onQuery + ) } } @@ -365,6 +303,169 @@ private fun MutualContent( } } +@Composable +private fun MatchIntroCard(modifier: Modifier = Modifier) { + ElevatedCard(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "位置匹配", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "输入双方位置,查找共同可见的卫星过境窗口", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun StationInputCard( + title: String, + titleColor: androidx.compose.ui.graphics.Color, + lat: String, + lon: String, + grid: String, + minElev: Double, + latPlaceholder: String, + lonPlaceholder: String, + gridPlaceholder: String, + onLatChange: (String) -> Unit, + onLonChange: (String) -> Unit, + onGridChange: (String) -> Unit, + onMinElevChange: (Double) -> Unit, + modifier: Modifier = Modifier, + currentPositionAction: (() -> Unit)? = null +) { + ElevatedCard(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + color = titleColor + ) + if (currentPositionAction != null) { + OutlinedButton(onClick = currentPositionAction) { + Text("填入当前位置", style = MaterialTheme.typography.bodySmall) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = lat, + onValueChange = onLatChange, + label = { Text("纬度") }, + placeholder = { Text(latPlaceholder) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = lon, + onValueChange = onLonChange, + label = { Text("经度") }, + placeholder = { Text(lonPlaceholder) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + + OutlinedTextField( + value = grid, + onValueChange = onGridChange, + label = { Text("网格") }, + placeholder = { Text(gridPlaceholder) }, + supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Text( + text = "最小仰角:${minElev.toInt()}°", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = minElev.toFloat(), + onValueChange = { onMinElevChange(it.toDouble()) }, + valueRange = 0f..90f, + steps = 17 + ) + } + } +} + +@Composable +private fun MatchSearchCard( + hoursAhead: Int, + isCalculating: Boolean, + onHoursAhead: (Int) -> Unit, + onQuery: () -> Unit, + modifier: Modifier = Modifier +) { + ElevatedCard(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + 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 = hoursAhead == hours, + onClick = { onHoursAhead(hours) }, + label = { Text("${hours}h") } + ) + } + } + Button( + onClick = onQuery, + enabled = !isCalculating, + modifier = Modifier.fillMaxWidth() + ) { + if (isCalculating) { + CircularProgressIndicator( + modifier = Modifier + .height(18.dp) + .width(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (isCalculating) "计算中..." else "查询匹配") + } + } + } +} + @Composable private fun MutualPassCard( pass: MutualPass, @@ -380,7 +481,6 @@ private fun MutualPassCard( var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) } // Filter to only show the portion where both stations are above their minElev - // (satlover.de style — only the usable common window) val visibleSamples = remember(pass, minElevA, minElevB) { pass.elevationSamples.filter { (_, elev) -> elev.first >= minElevA && elev.second >= minElevB @@ -447,7 +547,7 @@ private fun MutualPassCard( // Dual-station radar track (polar plot) if (visibleTracks.isNotEmpty()) { Text( - text = "双方轨迹(雷达图)", + text = "双方轨迹", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant 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 33a92849..378dff5f 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 @@ -352,20 +352,23 @@ class MutualViewModel( tSample += sampleInterval } - // The pass list already filters by maxElev > minElevation, so we trust - // the pass is valid. The mutual pass just needs both stations' curves. - results.add( - MutualPass( - catNum = pass.catNum, - name = pass.orbitalObject.data.name, - startTime = refinedAos, - endTime = refinedLos, - maxElevationA = (maxElevA * 10).roundToInt() / 10.0, - maxElevationB = (maxElevB * 10).roundToInt() / 10.0, - elevationSamples = samples, - trackSamples = tracks + // Search uses the 0° horizon as the pass boundary, but the user sliders are + // a final visibility filter. Both stations must exceed their own threshold; + // otherwise the result would expand to an empty filtered chart. + if (maxElevA > minElevADeg && maxElevB > minElevBDeg) { + results.add( + MutualPass( + catNum = pass.catNum, + name = pass.orbitalObject.data.name, + startTime = refinedAos, + endTime = refinedLos, + maxElevationA = (maxElevA * 10).roundToInt() / 10.0, + maxElevationB = (maxElevB * 10).roundToInt() / 10.0, + elevationSamples = samples, + trackSamples = tracks + ) ) - ) + } } return results } @@ -413,10 +416,12 @@ class MutualViewModel( var tSample = refinedAos while (tSample <= refinedLos) { + // Use getElevation for elevation, matching the main pass predictor; use + // getFullPosition only for azimuth needed by the radar plot. + val elevA = elevationDeg(sat, posA, tSample) + val elevB = elevationDeg(sat, posB, tSample) val fullA = sat.getFullPosition(posA, tSample) val fullB = sat.getFullPosition(posB, tSample) - val elevA = fullA.elevation * 180.0 / PI - val elevB = fullB.elevation * 180.0 / PI if (elevA > maxElevA) maxElevA = elevA if (elevB > maxElevB) maxElevB = elevB samples.add(tSample to (elevA to elevB)) 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 7cebf1bc..617412b0 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 @@ -73,6 +73,7 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.layoutPadding import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import kotlin.math.PI private enum class RadarPage(val title: String) { @@ -87,6 +88,23 @@ fun RadarDestination(navigateUp: () -> Unit) { val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() val mutualData by container.mutualPassData.collectAsStateWithLifecycle() + val navigateUpAndClearMutual = { + if (container.mutualPassData.value.endTime > 0L) { + container.setMutualPassData(MutualPassData()) + } + navigateUp() + } + LaunchedEffect(mutualData.endTime) { + if (mutualData.endTime <= 0L) return@LaunchedEffect + while (true) { + val remainingMs = mutualData.endTime - System.currentTimeMillis() + if (remainingMs <= 0L) { + navigateUpAndClearMutual() + return@LaunchedEffect + } + delay(remainingMs.coerceAtMost(1000L)) + } + } // Sync actual permission state on every recomposition so it survives screen re-entry val hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO @@ -101,7 +119,7 @@ fun RadarDestination(navigateUp: () -> Unit) { viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted)) } - RadarScreen(uiState, viewModel::onAction, navigateUp, mutualData, requestMicPermission = { + RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 1b2a08d2..c3a84532 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -139,6 +139,8 @@ class RadarViewModel( val passes = satelliteRepo.passes.value val (catNum, aosTime) = satelliteRepo.selectedPass.value return passes.find { it.catNum == catNum && it.aosTime == aosTime } + ?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime } + ?: passes.find { it.catNum == catNum } ?: passes.firstOrNull() }