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
This commit is contained in:
atsunatsu committed 2026-08-02 23:07:28 +08:00
1 parent 5de370983d
commit d7e1ce11c9
9 files changed
+437 -221

No files matched your search

@@ -206,7 +206,15 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
entry<Screen.Mutual> { entry<Screen.Mutual> {
val viewModel = MutualViewModel(container.satelliteRepo, container.settingsRepo) 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<Screen.Settings> { entry<Screen.Settings> {
SettingsDestination() SettingsDestination()
@@ -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.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo 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.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -61,6 +62,9 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer { class MainContainer(private val context: Context) : IMainContainer {
@@ -77,6 +81,13 @@ class MainContainer(private val context: Context) : IMainContainer {
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
} }
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context) override fun provideShowToast(): IShowToast = ShowToast(context)
@@ -16,12 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.rtbishop.look4sat.core.domain.repository 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.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
interface IMainContainer { interface IMainContainer {
val appScope: CoroutineScope val appScope: CoroutineScope
@@ -30,6 +31,8 @@ interface IMainContainer {
val satelliteRepo: ISatelliteRepo val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo val databaseRepo: IDatabaseRepo
val radioTrackingService: IRadioTrackingService val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter fun provideBluetoothReporter(): IReporter
@@ -41,6 +44,15 @@ interface IMainContainer {
fun provideSaveImage(): ISaveImage fun provideSaveImage(): ISaveImage
} }
data class MutualPassData(
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val startTime: Long = 0L,
val endTime: Long = 0L,
val maxElev: Double = 10.0,
val labelA: String = "你",
val labelB: String = "友台"
)
interface IContainerProvider { interface IContainerProvider {
fun getMainContainer(): IMainContainer fun getMainContainer(): IMainContainer
} }
@@ -158,20 +158,21 @@ fun ElevationCurveChart(
pathA.moveTo(ptsA[0].x, ptsA[0].y) pathA.moveTo(ptsA[0].x, ptsA[0].y)
pathB.moveTo(ptsB[0].x, ptsB[0].y) pathB.moveTo(ptsB[0].x, ptsB[0].y)
for (i in 1 until ptsA.size) { for (i in 1 until ptsA.size) {
val prev = ptsA.getOrNull(i - 2) ?: ptsA[i - 1] val p0a = ptsA.getOrNull(i - 2) ?: ptsA[i - 1]
val curr = ptsA[i - 1] val p1a = ptsA[i - 1]
val next = ptsA.getOrNull(i + 1) ?: ptsA[i] val p2a = ptsA[i]
val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f) val p3a = ptsA.getOrNull(i + 1) ?: ptsA[i]
val cp2 = Offset(ptsA[i].x - (ptsA[i].x - curr.x) / 6f, ptsA[i].y - (ptsA[i].y - curr.y) / 6f) val cp1a = Offset(p1a.x + (p2a.x - p0a.x) / 6f, p1a.y + (p2a.y - p0a.y) / 6f)
pathA.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsA[i].x, ptsA[i].y) 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)
for (i in 1 until ptsB.size) {
val prev = ptsB.getOrNull(i - 2) ?: ptsB[i - 1] val p0b = ptsB.getOrNull(i - 2) ?: ptsB[i - 1]
val curr = ptsB[i - 1] val p1b = ptsB[i - 1]
val next = ptsB.getOrNull(i + 1) ?: ptsB[i] val p2b = ptsB[i]
val cp1 = Offset(curr.x + (next.x - prev.x) / 6f, curr.y + (next.y - prev.y) / 6f) val p3b = ptsB.getOrNull(i + 1) ?: ptsB[i]
val cp2 = Offset(ptsB[i].x - (ptsB[i].x - curr.x) / 6f, ptsB[i].y - (ptsB[i].y - curr.y) / 6f) val cp1b = Offset(p1b.x + (p2b.x - p0b.x) / 6f, p1b.y + (p2b.y - p0b.y) / 6f)
pathB.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, ptsB[i].x, ptsB[i].y) 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)
} }
} }
@@ -17,6 +17,10 @@
*/ */
package com.rtbishop.look4sat.feature.mutual 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -42,9 +49,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp 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.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -52,183 +70,275 @@ import java.util.Locale
@Composable @Composable
fun MutualScreen( fun MutualScreen(
viewModel: MutualViewModel, viewModel: MutualViewModel,
navigateUp: () -> Unit = {},
navigateToRadar: (Int, Long, MutualPassData?) -> Unit = { _, _, _ -> },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val state by viewModel.uiState.collectAsState() 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()) } val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
LazyColumn( LazyColumn(
modifier = modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize() verticalArrangement = Arrangement.spacedBy(6.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.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 // Error message
val errorMsg = state.errorMessage val errorMsg = state.errorMessage
if (errorMsg != null) { if (errorMsg != null) {
item { item {
Text( ElevatedCard(
text = errorMsg, modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.error, colors = CardDefaults.elevatedCardColors(
style = MaterialTheme.typography.bodyMedium containerColor = MaterialTheme.colorScheme.errorContainer
) )
} ) {
} Text(
text = errorMsg,
// Station A input color = MaterialTheme.colorScheme.onErrorContainer,
item { modifier = Modifier.padding(12.dp),
Text("你的位置", style = MaterialTheme.typography.titleSmall) style = MaterialTheme.typography.bodyMedium
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") }
) )
} }
} }
} }
// Query button // Input form
item { item {
Button( ElevatedCard(modifier = Modifier.fillMaxWidth()) {
onClick = { viewModel.queryMutualPasses() }, Column(
enabled = !state.isCalculating, modifier = Modifier
modifier = Modifier.fillMaxWidth() .fillMaxWidth()
) { .padding(12.dp),
if (state.isCalculating) { verticalArrangement = Arrangement.spacedBy(8.dp)
CircularProgressIndicator( ) {
modifier = Modifier.height(20.dp).width(20.dp), Text(
strokeWidth = 2.dp 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 // Results
if (state.mutualPasses.isNotEmpty()) { itemsIndexed(state.mutualPasses) { index, pass ->
item { val mutualData = MutualPassData(
Text( samples = pass.elevationSamples,
text = "找到 ${state.mutualPasses.size} 个共同过境", startTime = pass.startTime,
style = MaterialTheme.typography.titleMedium, endTime = pass.endTime,
modifier = Modifier.padding(top = 8.dp) maxElev = maxOf(pass.maxElevationA, pass.maxElevationB, 10.0),
) labelA = "你",
} labelB = "友台"
itemsIndexed(state.mutualPasses) { index, pass -> )
MutualPassCard( MutualPassCard(
pass = pass, pass = pass,
isExpanded = state.selectedPassIndex == index, isExpanded = state.selectedPassIndex == index,
timeFormat = timeFormat, timeFormat = timeFormat,
onToggle = { viewModel.onSelectPass(if (state.selectedPassIndex == index) -1 else index) } 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, pass: MutualPass,
isExpanded: Boolean, isExpanded: Boolean,
timeFormat: SimpleDateFormat, timeFormat: SimpleDateFormat,
onToggle: () -> Unit onClick: () -> Unit,
onNavigateToRadar: () -> Unit
) { ) {
Card( ElevatedCard(
onClick = onToggle, modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
colors = CardDefaults.cardColors( .clickable(onClick = onClick)
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text( Row(
text = pass.name, modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge, horizontalArrangement = Arrangement.SpaceBetween,
color = MaterialTheme.colorScheme.primary verticalAlignment = Alignment.CenterVertically
) ) {
Text( Text(
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}", text = pass.name,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant fontWeight = FontWeight.Medium,
) color = MaterialTheme.colorScheme.primary,
Text( modifier = Modifier.weight(1f)
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)
) )
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("查看雷达")
}
}
} }
} }
} }
@@ -263,36 +263,34 @@ class MutualViewModel(
minElevADeg: Double, minElevBDeg: Double, minElevADeg: Double, minElevBDeg: Double,
approxTime: Long, step: Long, goingUp: Boolean approxTime: Long, step: Long, goingUp: Boolean
): Long { ): Long {
var t = approxTime
val limit = 30_000L // search up to 30 seconds
if (goingUp) { if (goingUp) {
// Find the point where both stations just go above minElev // AOS: walk backward from approxTime to find the last sample where both are below,
var best = t // then AOS is the next step after that.
t -= limit var t = approxTime
while (t <= approxTime + limit) { while (t > approxTime - 70_000L) {
val eA = elevationDeg(sat, posA, t) val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t) val eB = elevationDeg(sat, posB, t)
if (eA > minElevADeg && eB > minElevBDeg) { if (eA > minElevADeg && eB > minElevBDeg) {
best = t t -= step
break } else {
return t + step
} }
t += step
} }
return best return approxTime - 70_000L + step
} else { } else {
// Find the point where either station just drops below minElev // LOS: walk forward from approxTime to find the first sample where either drops below,
var best = t // then LOS is the step before that.
t -= limit var t = approxTime
while (t <= approxTime + limit) { while (t < approxTime + 70_000L) {
val eA = elevationDeg(sat, posA, t) val eA = elevationDeg(sat, posA, t)
val eB = elevationDeg(sat, posB, t) val eB = elevationDeg(sat, posB, t)
if (eA < minElevADeg || eB < minElevBDeg) { if (eA > minElevADeg && eB > minElevBDeg) {
best = t t += step
break } else {
return t - step
} }
t += step
} }
return best return approxTime + 70_000L - step
} }
} }
+4
View File
@@ -5,3 +5,7 @@ plugins {
android { android {
namespace = "com.rtbishop.look4sat.feature.radar" namespace = "com.rtbishop.look4sat.feature.radar"
} }
dependencies {
implementation(project(":feature:mutual"))
}
@@ -59,6 +59,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider 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.domain.utility.toDegrees
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard 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.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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
@@ -83,6 +85,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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 // Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission( val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO context, Manifest.permission.RECORD_AUDIO
@@ -97,7 +100,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(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) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) })
} }
@@ -107,6 +110,7 @@ private fun RadarScreen(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
mutualData: MutualPassData,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
@@ -137,10 +141,16 @@ private fun RadarScreen(
} }
if (isVertical) { if (isVertical) {
RadarCard(uiState, Modifier.weight(1f)) RadarCard(uiState, 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, 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))
} }
} }
@@ -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
)
}
}
}
}
@@ -63,7 +63,13 @@ data class RadarState(
val transceivers: TransceiverSubState = TransceiverSubState(), val transceivers: TransceiverSubState = TransceiverSubState(),
val radioControl: RadioControlSubState = RadioControlSubState(), val radioControl: RadioControlSubState = RadioControlSubState(),
val sstv: SstvSubState = SstvSubState(), val sstv: SstvSubState = SstvSubState(),
val cw: CwSubState = CwSubState() val cw: CwSubState = CwSubState(),
val mutualSamples: List<Pair<Long, Pair<Double, Double>>> = 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 } enum class SstvStatus { Idle, Recording }