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

@@ -32,10 +32,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
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.isVerticalLayout
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 kotlin.math.PI
private enum class RadarPage(val title: String) {
Transceivers("Transceivers"),
@@ -121,6 +117,17 @@ private fun RadarScreen(
val addToCalendar: () -> Unit = {
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(
modifier = Modifier
.layoutPadding()
@@ -144,17 +151,11 @@ private fun RadarScreen(
}
}
if (isVertical) {
RadarCard(uiState, Modifier.weight(1f))
if (mutualData.samples.isNotEmpty()) {
MutualElevationCard(mutualData, Modifier.weight(1f))
}
RadarCard(uiState, trackB, 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))
}
RadarCard(uiState, trackB, Modifier.weight(1f))
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
}
}
@@ -210,7 +211,11 @@ private fun PagerCard(
}
@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 shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
// Always call these composables unconditionally — conditional composable calls violate
@@ -243,6 +248,7 @@ private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
RadarViewCompose(
item = position,
items = uiState.satTrack,
trackB = trackB.takeIf { it.isNotEmpty() },
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
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(
item: OrbitalPos,
items: List<OrbitalPos>,
trackB: List<OrbitalPos>? = null,
trackBColor: Color = MaterialTheme.colorScheme.tertiary,
azimElev: Pair<Float, Float>,
shouldShowSweep: Boolean,
shouldUseCompass: Boolean,
@@ -107,18 +109,23 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) }
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
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = size.minDimension / 2f * 0.95f
// 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)
trackEffect = createTrackEffect(trackPath)
trackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius
cachedItemsRef = items
cachedTrackBRef = trackB
cachedSweepColor = primaryColor
} else if (primaryColor != cachedSweepColor) {
// Rebuild brush on theme change without waiting for a size change
@@ -131,6 +138,19 @@ fun RadarViewCompose(
drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) {
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) {
drawPosition(item, radius, animScale, primaryColor)
}