feat: keep mutual state across nav + overlay station-B track on main radar

- MutualViewModel is Activity-scoped, so returning from Radar keeps query results
- TrackSampleData gains time field for live cut-off
- Removed standalone mutual card from radar page
- RadarViewCompose draws optional dashed station-B track + current dot
- RadarScreen builds trackB from mutual data up to current time
This commit is contained in:
atsunatsu committed 2026-08-03 00:55:05 +08:00
1 parent bd42e643ea
commit 4ca33c37ad
5 files changed
+51 -49

No files matched your search

@@ -63,7 +63,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberNavBackStack
@@ -133,6 +135,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
CompositionLocalProvider( CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds( LocalElevationThresholds provides ElevationThresholds(
@@ -205,9 +212,8 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
MapDestination() MapDestination()
} }
entry<Screen.Mutual> { entry<Screen.Mutual> {
val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo)
MutualScreen( MutualScreen(
viewModel = viewModel, viewModel = mutualViewModel,
navigateUp = navigateBack, navigateUp = navigateBack,
navigateToRadar = { catNum, aosTime, pass -> navigateToRadar = { catNum, aosTime, pass ->
pass?.let { container.setMutualPassData(it) } pass?.let { container.setMutualPassData(it) }
@@ -56,6 +56,7 @@ data class MutualPassData(
/** Minimal track sample for cross-module sharing (angles in degrees). */ /** Minimal track sample for cross-module sharing (angles in degrees). */
data class TrackSampleData( data class TrackSampleData(
val time: Long = 0L,
val azimuthA: Double, val azimuthA: Double,
val elevationA: Double, val elevationA: Double,
val azimuthB: Double, val azimuthB: Double,
@@ -329,6 +329,7 @@ private fun MutualContent(
samples = pass.elevationSamples, samples = pass.elevationSamples,
trackSamples = pass.trackSamples.map { trackSamples = pass.trackSamples.map {
TrackSampleData( TrackSampleData(
time = it.time,
azimuthA = it.azimuthA, azimuthA = it.azimuthA,
elevationA = it.elevationA, elevationA = it.elevationA,
azimuthB = it.azimuthB, azimuthB = it.azimuthB,
@@ -32,10 +32,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -73,10 +71,8 @@ import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.feature.mutual.ElevationCurveChart
import com.rtbishop.look4sat.feature.mutual.MutualRadarView
import com.rtbishop.look4sat.feature.mutual.TrackSample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.PI
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
Transceivers("Transceivers"), Transceivers("Transceivers"),
@@ -121,6 +117,17 @@ private fun RadarScreen(
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) } uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
} }
// Live station-B track overlay: samples up to "now" (recomposed each second tick)
val timeNow = System.currentTimeMillis()
val trackB = mutualData.trackSamples
.filter { it.time <= timeNow }
.map {
OrbitalPos(
azimuth = it.azimuthB * PI / 180.0,
elevation = it.elevationB * PI / 180.0,
time = it.time
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
@@ -144,17 +151,11 @@ private fun RadarScreen(
} }
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, Modifier.weight(1f)) RadarCard(uiState, trackB, Modifier.weight(1f))
if (mutualData.samples.isNotEmpty()) {
MutualElevationCard(mutualData, Modifier.weight(1f))
}
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, Modifier.weight(1f)) RadarCard(uiState, trackB, Modifier.weight(1f))
if (mutualData.samples.isNotEmpty()) {
MutualElevationCard(mutualData, Modifier.weight(1f))
}
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f)) PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
} }
} }
@@ -210,7 +211,11 @@ private fun PagerCard(
} }
@Composable @Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) { private fun RadarCard(
uiState: RadarState,
trackB: List<OrbitalPos> = emptyList(),
modifier: Modifier = Modifier
) {
val satellitePos = uiState.orbitalPos val satellitePos = uiState.orbitalPos
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
// Always call these composables unconditionally — conditional composable calls violate // Always call these composables unconditionally — conditional composable calls violate
@@ -243,6 +248,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
RadarViewCompose( RadarViewCompose(
item = position, item = position,
items = uiState.satTrack, items = uiState.satTrack,
trackB = trackB.takeIf { it.isNotEmpty() },
azimElev = uiState.orientationValues, azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep, shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass, shouldUseCompass = uiState.shouldUseCompass,
@@ -318,35 +324,3 @@ private fun RadarLabel(
} }
} }
} }
@Composable
private fun MutualElevationCard(mutualData: MutualPassData, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
if (mutualData.trackSamples.isNotEmpty()) {
MutualRadarView(
trackSamples = mutualData.trackSamples.map {
TrackSample(
time = 0L,
azimuthA = it.azimuthA,
elevationA = it.elevationA,
azimuthB = it.azimuthB,
elevationB = it.elevationB
)
},
labelA = mutualData.labelA,
labelB = mutualData.labelB
)
Spacer(Modifier.height(4.dp))
}
if (mutualData.samples.isNotEmpty()) {
ElevationCurveChart(
samples = mutualData.samples,
startTime = mutualData.startTime,
endTime = mutualData.endTime,
maxElev = mutualData.maxElev
)
}
}
}
}
@@ -71,6 +71,8 @@ private const val SWEEP_DURATION_MS = 8_000
fun RadarViewCompose( fun RadarViewCompose(
item: OrbitalPos, item: OrbitalPos,
items: List<OrbitalPos>, items: List<OrbitalPos>,
trackB: List<OrbitalPos>? = null,
trackBColor: Color = MaterialTheme.colorScheme.tertiary,
azimElev: Pair<Float, Float>, azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean, shouldShowSweep: Boolean,
shouldUseCompass: Boolean, shouldUseCompass: Boolean,
@@ -107,18 +109,23 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) } var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) } var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// Station-B overlay track (dashed)
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
var trackBPath by remember { mutableStateOf(Path()) }
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame // ShaderBrush is cached to avoid allocating a new GPU shader object every frame
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) } var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
Canvas(modifier = modifier.aspectRatio(1f)) { Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f val radius = size.minDimension / 2f * 0.95f
// Rebuild track path and sweep brush when canvas size or track data changes // Rebuild track path and sweep brush when canvas size or track data changes
if (radius != cachedRadius || items !== cachedItemsRef) { if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
trackPath = createTrackPath(items, radius) trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath) trackEffect = createTrackEffect(trackPath)
trackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
cachedSweepBrush = makeSweepBrush(center, primaryColor) cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius cachedRadius = radius
cachedItemsRef = items cachedItemsRef = items
cachedTrackBRef = trackB
cachedSweepColor = primaryColor cachedSweepColor = primaryColor
} else if (primaryColor != cachedSweepColor) { } else if (primaryColor != cachedSweepColor) {
// Rebuild brush on theme change without waiting for a size change // Rebuild brush on theme change without waiting for a size change
@@ -131,6 +138,19 @@ fun RadarViewCompose(
drawElevationLabels(radius, primaryColor, measurer) drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) { translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, primaryColor) drawTrack(trackPath, trackEffect, aimColor, primaryColor)
// Station-B overlay: dashed track + current position dot
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
drawPath(
trackBPath, trackBColor,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
)
val lastB = trackB.last()
if (lastB.elevation > 0) {
val posB = sph2Cart(lastB.azimuth, lastB.elevation, radius.toDouble())
drawCircle(trackBColor, 26f, posB, style = Stroke(2f))
drawCircle(trackBColor, 12f, posB)
}
}
if (item.elevation > 0) { if (item.elevation > 0) {
drawPosition(item, radius, animScale, primaryColor) drawPosition(item, radius, animScale, primaryColor)
} }