Simplified RadarScreen/View, added sensors smoothing

This commit is contained in:
Arty Bishop committed 2026-03-28 20:04:03 +00:00
1 parent 8c1df334d8
commit 279594c425
3 files changed
+266 -362

No files matched your search

@@ -35,78 +35,109 @@ class SensorsRepo(
private val sensorManager: SensorManager, private val sensorManager: SensorManager,
private val sensor: Sensor?, private val sensor: Sensor?,
private val windowManager: WindowManager private val windowManager: WindowManager
) : ) : SensorEventListener, ISensorsRepo {
SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f)) private val _orientation = MutableStateFlow(Pair(0f, 0f))
private val rotationMatrix = FloatArray(9) private val rotationMatrix = FloatArray(9)
private val tempMatrix = FloatArray(9)
private val orientationValues = FloatArray(3) private val orientationValues = FloatArray(3)
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE private var smoothAzimuth = 0f
private var smoothPitch = 0f
private var hasInitialReading = false
companion object {
private const val SMOOTHING_FACTOR = 0.15f
}
override val orientation: StateFlow<Pair<Float, Float>> = _orientation override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float { override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
val latitude = geoPos.latitude.toFloat() return GeomagneticField(
val longitude = geoPos.longitude.toFloat() geoPos.latitude.toFloat(),
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination geoPos.longitude.toFloat(),
geoPos.altitude.toFloat(),
time
).declination
} }
override fun enableSensor() { override fun enableSensor() {
hasInitialReading = false
sensor?.let { sensorManager.registerListener(this, it, 8000) } sensor?.let { sensorManager.registerListener(this, it, 8000) }
} }
override fun disableSensor() = sensorManager.unregisterListener(this) override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
sensorAccuracy = accuracy
}
override fun onSensorChanged(event: SensorEvent) = when { override fun onSensorChanged(event: SensorEvent) {
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {} if (event.sensor == sensor) updateOrientation(event.values)
event.sensor == sensor -> updateOrientation(event.values)
else -> {}
} }
private fun getDisplayRotation(): Int { private fun getDisplayRotation(): Int {
return try { return try {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
windowManager.defaultDisplay.rotation windowManager.defaultDisplay.rotation
} catch (e: Exception) { } catch (_: Exception) {
Surface.ROTATION_0 Surface.ROTATION_0
} }
} }
private fun transformRotationMatrix(rotationMatrix: FloatArray, rotation: Int) { private fun remapForRotation(rotation: Int) {
val tempMatrix = FloatArray(9) val remapped = when (rotation) {
when (rotation) { Surface.ROTATION_90 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_0 -> { rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_X, SensorManager.AXIS_Y, tempMatrix) )
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} Surface.ROTATION_180 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_90 -> { rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_Y, SensorManager.AXIS_MINUS_X, tempMatrix) )
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
} Surface.ROTATION_270 -> SensorManager.remapCoordinateSystem(
Surface.ROTATION_180 -> { rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_MINUS_Y, tempMatrix) )
System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} else -> false
Surface.ROTATION_270 -> {
SensorManager.remapCoordinateSystem(rotationMatrix, SensorManager.AXIS_MINUS_Y, SensorManager.AXIS_X, tempMatrix)
SensorManager.remapCoordinateSystem(tempMatrix, SensorManager.AXIS_MINUS_X, SensorManager.AXIS_Y, rotationMatrix)
}
} }
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
} }
private fun updateOrientation(rotationVector: FloatArray) { private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector) SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
transformRotationMatrix(rotationMatrix, getDisplayRotation()) remapForRotation(getDisplayRotation())
SensorManager.getOrientation(rotationMatrix, orientationValues) SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat() val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2] val pitch = (orientationValues[1] * RAD2DEG).toFloat()
val magneticAzimuth = (azimuth + 360f) % 360f val magneticAzimuth = (azimuth + 360f) % 360f
val roundedAzimuth = round(magneticAzimuth * 10) / 10
val roundedPitch = round(pitch * 10) / 10 if (!hasInitialReading) {
_orientation.value = Pair(roundedAzimuth, roundedPitch) smoothAzimuth = magneticAzimuth
smoothPitch = pitch
hasInitialReading = true
} else {
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
smoothPitch = lowPass(smoothPitch, pitch)
}
_orientation.value = Pair(
round(smoothAzimuth * 10) / 10,
round(smoothPitch * 10) / 10
)
}
/** Standard exponential low-pass filter. */
private fun lowPass(previous: Float, current: Float): Float {
return previous + SMOOTHING_FACTOR * (current - previous)
}
/**
* Low-pass filter that accounts for the 0°/360° wraparound.
* Always takes the shortest angular path between the two values.
*/
private fun lowPassAngle(previous: Float, current: Float): Float {
var delta = current - previous
// Normalise delta into the range (-180, 180]
while (delta > 180f) delta -= 360f
while (delta <= -180f) delta += 360f
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
} }
} }
@@ -42,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@@ -62,6 +63,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
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
@@ -84,14 +86,13 @@ fun NavGraphBuilder.radarDestination(navigateUp: () -> Unit) {
) )
composable(radarRoute, radarArgs) { composable(radarRoute, radarArgs) {
val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory) val viewModel = viewModel(RadarViewModel::class.java, factory = RadarViewModel.Factory)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, navigateUp) RadarScreen(uiState, navigateUp)
} }
} }
@Composable @Composable
private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) { private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
// BluetoothCIV.init(LocalContext.current)
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass -> uiState.currentPass?.let { pass ->
uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime)) uiState.sendAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
@@ -102,9 +103,11 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
.keepScreenOn(), verticalArrangement = Arrangement.spacedBy(6.dp) .keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (isVerticalLayout()) { val isVertical = isVerticalLayout()
if (isVertical) {
TopBar { TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back) IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isCurrentTimeAos)
@@ -119,111 +122,28 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
} }
} }
if(isVerticalLayout()) { if (isVertical) {
ElevatedCard(modifier = Modifier.weight(1f)) { RadarCard(uiState, Modifier.weight(1f))
Box(contentAlignment = Alignment.Center) { TransmittersCard(uiState, Modifier.weight(1f))
if (uiState.orbitalPos == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "")
}
}
uiState.orbitalPos?.let { position ->
RadarViewCompose(
item = position,
items = uiState.satTrack,
azimElev = uiState.orientationValues,
shouldShowSweep = uiState.shouldShowSweep,
shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center)
)
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextTop(
position.azimuth,
stringResource(R.string.radar_az_text),
true
)
RadarTextTop(
position.elevation,
stringResource(R.string.radar_el_text),
false
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
RadarTextBottom(
position.altitude,
stringResource(R.string.radar_alt_text),
true
)
RadarTextBottom(
position.distance,
stringResource(R.string.radar_dist_text),
false
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
if (uiState.transmitters.isEmpty()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(
text = stringResource(R.string.empty_list_message),
fontSize = 21.sp,
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.radar_no_data),
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ElevatedCard(modifier = Modifier.weight(1f)) { RadarCard(uiState, Modifier.weight(1f))
TransmittersCard(uiState, Modifier.weight(1f))
}
}
}
}
@Composable
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
if (uiState.orbitalPos == null) { val position = uiState.orbitalPos
if (position == null) {
ElevatedCard(modifier = Modifier.fillMaxSize()) { ElevatedCard(modifier = Modifier.fillMaxSize()) {
EmptyListCard(message = "") EmptyListCard(message = "")
} }
} } else {
uiState.orbitalPos?.let { position ->
RadarViewCompose( RadarViewCompose(
item = position, item = position,
items = uiState.satTrack, items = uiState.satTrack,
@@ -232,6 +152,14 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
shouldUseCompass = uiState.shouldUseCompass, shouldUseCompass = uiState.shouldUseCompass,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
PositionOverlay(position)
}
}
}
}
@Composable
private fun PositionOverlay(position: OrbitalPos) {
Column( Column(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
@@ -242,38 +170,83 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
RadarTextTop( RadarLabel(
position.azimuth, value = stringResource(R.string.radar_az_value, position.azimuth.toDegrees()),
stringResource(R.string.radar_az_text), label = stringResource(R.string.radar_az_text),
true alignment = Alignment.Start,
labelFirst = false
) )
RadarTextTop( RadarLabel(
position.elevation, value = stringResource(R.string.radar_az_value, position.elevation.toDegrees()),
stringResource(R.string.radar_el_text), label = stringResource(R.string.radar_el_text),
false alignment = Alignment.End,
labelFirst = false
) )
} }
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
RadarTextBottom( RadarLabel(
position.altitude, value = stringResource(R.string.radar_alt_value, position.altitude),
stringResource(R.string.radar_alt_text), label = stringResource(R.string.radar_alt_text),
true alignment = Alignment.Start,
labelFirst = true
) )
RadarTextBottom( RadarLabel(
position.distance, value = stringResource(R.string.radar_alt_value, position.distance),
stringResource(R.string.radar_dist_text), label = stringResource(R.string.radar_dist_text),
false alignment = Alignment.End,
labelFirst = true
) )
} }
} }
}
@Composable
private fun RadarLabel(
value: String,
label: String,
alignment: Alignment.Horizontal,
labelFirst: Boolean
) {
Column(horizontalAlignment = alignment) {
if (labelFirst) {
Text(text = label, fontSize = 15.sp)
Text(text = value, fontSize = 18.sp)
} else {
Text(text = value, fontSize = 18.sp)
Text(text = label, fontSize = 15.sp)
} }
} }
} }
ElevatedCard(modifier = Modifier.weight(1f)) {
@Composable
private fun TransmittersCard(uiState: RadarState, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
if (uiState.transmitters.isEmpty()) { if (uiState.transmitters.isEmpty()) {
EmptyTransmittersContent()
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
}
@Composable
private fun EmptyTransmittersContent() {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -293,46 +266,6 @@ private fun RadarScreen(uiState: RadarState, navigateUp: () -> Unit) {
) )
} }
} }
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
TransmittersList(
transmitters = uiState.transmitters,
selectedUuid = uiState.selectedTransmitterUuid,
onSelect = { uuid ->
if (uiState.selectedTransmitterUuid != null) {
uiState.sendAction(RadarAction.SelectTransmitter(uuid))
}
}
)
if (uiState.orbitalPos?.eclipsed == true) {
EclipsedIndicator()
}
}
}
}
}
}
}
}
@Composable
private fun RadarTextTop(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_az_value, value.toDegrees())
Column(horizontalAlignment = alignment) {
Text(text = degValue, fontSize = 18.sp)
Text(text = text, fontSize = 15.sp)
}
}
@Composable
private fun RadarTextBottom(value: Double, text: String, isLeft: Boolean) {
val alignment = if (isLeft) Alignment.Start else Alignment.End
val degValue = stringResource(R.string.radar_alt_value, value)
Column(horizontalAlignment = alignment) {
Text(text = text, fontSize = 15.sp)
Text(text = degValue, fontSize = 18.sp)
}
} }
@Composable @Composable
@@ -342,12 +275,13 @@ private fun TransmittersList(
onSelect: (String) -> Unit onSelect: (String) -> Unit
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = transmitters, key = { item -> item.uuid }) { radio -> items(items = transmitters, key = { it.uuid }) { radio ->
TransmitterItem( TransmitterItem(
radio, radio = radio,
(selectedUuid != null), isClickable = selectedUuid != null,
(radio.uuid == selectedUuid) isSelected = radio.uuid == selectedUuid,
) { onSelect(radio.uuid) } onClick = { onSelect(radio.uuid) }
)
} }
} }
} }
@@ -366,23 +300,20 @@ private fun TransmitterItemPreview() {
private fun EclipsedIndicator() { private fun EclipsedIndicator() {
val bgColor = MaterialTheme.colorScheme.primary val bgColor = MaterialTheme.colorScheme.primary
val bgShape = MaterialTheme.shapes.medium val bgShape = MaterialTheme.shapes.medium
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition(label = "eclipsed")
val textAlpha = infiniteTransition.animateFloat( val textAlpha by infiniteTransition.animateFloat(
initialValue = 1.0f, initialValue = 1.0f,
targetValue = 0.0f, targetValue = 0.0f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween( animation = tween(durationMillis = 1000, delayMillis = 25, easing = LinearEasing),
durationMillis = 1000,
delayMillis = 25,
easing = LinearEasing
),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
) ),
label = "eclipsedAlpha"
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.alpha(textAlpha.value) .alpha(textAlpha)
.border(width = 2.dp, color = bgColor, shape = bgShape), .border(width = 2.dp, color = bgColor, shape = bgShape),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -447,45 +378,52 @@ private fun TransmitterItem(
} }
@Composable @Composable
private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean, modifier: Modifier = Modifier) { private fun FrequencyRow(radio: SatRadio, isDownlink: Boolean) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val rotateMod = Modifier.rotate(if (isDownlink) 90f else -90f) val desc = if (isDownlink) stringResource(R.string.radar_downlink)
val weightMod = Modifier.weight(1f) else stringResource(R.string.radar_uplink)
val desc = if (isDownlink) stringResource(R.string.radar_downlink) else stringResource(R.string.radar_uplink)
Text( Text(
text = if (isDownlink) "D:" else "U:", text = if (isDownlink) "D:" else "U:",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 18.sp, fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = modifier modifier = Modifier
.size(width = 24.dp, height = 24.dp) .size(24.dp)
.semantics { contentDescription = desc } .semantics { contentDescription = desc }
) )
FrequencyText(if (isDownlink) radio.downlinkLow else radio.uplinkLow, weightMod) FrequencyText(
frequency = if (isDownlink) radio.downlinkLow else radio.uplinkLow,
modifier = Modifier.weight(1f)
)
Text( Text(
text = "-", text = "-",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 21.sp, fontSize = 21.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary
modifier = modifier )
FrequencyText(
frequency = if (isDownlink) radio.downlinkHigh else radio.uplinkHigh,
modifier = Modifier.weight(1f)
) )
FrequencyText(if (isDownlink) radio.downlinkHigh else radio.uplinkHigh, weightMod)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_arrow), painter = painterResource(id = R.drawable.ic_arrow),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null, contentDescription = null,
modifier = rotateMod.size(width = 24.dp, height = 24.dp) modifier = Modifier
.rotate(if (isDownlink) 90f else -90f)
.size(24.dp)
) )
} }
} }
@Composable @Composable
private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) { private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
val noLinkText = stringResource(R.string.radar_no_link) val text = frequency?.let {
val freqValue = frequency?.let { it / 1000000f } stringResource(id = R.string.radar_link_low, it / 1000000f)
} ?: stringResource(R.string.radar_no_link)
Text( Text(
text = freqValue?.let { stringResource(id = R.string.radar_link_low, it) } ?: noLinkText, text = text,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 21.sp, fontSize = 21.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -25,9 +25,11 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -53,6 +55,10 @@ import com.rtbishop.look4sat.core.domain.utility.toRadians
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
private const val CIRCLES = 3
private const val STROKE_WIDTH = 6f
private const val SWEEP_INCREMENT = 360f / 12f / 60f
@Composable @Composable
fun RadarViewCompose( fun RadarViewCompose(
item: OrbitalPos, item: OrbitalPos,
@@ -62,149 +68,81 @@ fun RadarViewCompose(
shouldUseCompass: Boolean, shouldUseCompass: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// val context = LocalContext.current
// val view = LocalView.current
val radarColor = MaterialTheme.colorScheme.secondary val radarColor = MaterialTheme.colorScheme.secondary
val trackColor = MaterialTheme.colorScheme.primary val trackColor = MaterialTheme.colorScheme.primary
val aimColor = MaterialTheme.colorScheme.error val aimColor = MaterialTheme.colorScheme.error
val strokeWidth = 6f
val animTransition = rememberInfiniteTransition(label = "animScale") val animTransition = rememberInfiniteTransition(label = "animScale")
val animSpec = infiniteRepeatable<Float>(tween(1000)) val animScale by animTransition.animateFloat(
val animScale = animTransition.animateFloat(16f, 64f, animSpec, label = "animScale") initialValue = 16f,
// val aimThreshold = 0.05f targetValue = 64f,
animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale"
)
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val sweepDegrees = remember { mutableFloatStateOf(0f) } var sweepDegrees by remember { mutableFloatStateOf(0f) }
val trackLastRadius = remember { mutableStateOf(0f) } var cachedRadius by remember { mutableFloatStateOf(0f) }
val trackPath = remember { mutableStateOf(Path()) } var trackPath by remember { mutableStateOf(Path()) }
val trackEffect = remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// val soundPool = remember { mutableStateOf<SoundPool?>(null) }
// val beepSoundId = remember { mutableIntStateOf(0) }
// val aimTargetDifference = remember { mutableFloatStateOf(0f) }
// LaunchedEffect(item.azimuth, item.elevation, azimElev.first, azimElev.second) {
// val aimAzimuthRadians = azimElev.first.toDouble().toRadians()
// val aimElevationRadians = abs(min(azimElev.second, 0f)).toDouble().toRadians()
// // radius of 0.5 makes the aimTargetDifference range 0.0 to 1.0
// val radius = 0.5
// val aimX = sph2CartX(aimAzimuthRadians, aimElevationRadians, radius)
// val aimY = sph2CartY(aimAzimuthRadians, aimElevationRadians, radius)
// val satX = sph2CartX(item.azimuth, item.elevation, radius)
// val satY = sph2CartY(item.azimuth, item.elevation, radius)
// aimTargetDifference.floatValue = sqrt((satX - aimX).pow(2) + (satY - aimY).pow(2))
// val minPlaybackRate = 0.5f
// val maxPlaybackRate = 2.0f
// val playbackRate =
// maxPlaybackRate - (aimTargetDifference.floatValue / (maxPlaybackRate - minPlaybackRate))
// soundPool.value?.setRate(beepSoundId.intValue, playbackRate)
// }
//
// LaunchedEffect(aimTargetDifference.floatValue < aimThreshold) {
// if (aimTargetDifference.floatValue < aimThreshold) {
// view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
// soundPool.value?.pause(beepSoundId.intValue)
// } else {
// soundPool.value?.resume(beepSoundId.intValue)
// }
// }
//
// LaunchedEffect(item.elevation > 0) {
// if(item.elevation > 0) {
// soundPool.value?.resume(beepSoundId.intValue)
// } else {
// soundPool.value?.pause(beepSoundId.intValue)
// }
// }
//
// DisposableEffect(Unit) {
// val audioAttributes = AudioAttributes.Builder()
// .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
// .build()
// soundPool.value = SoundPool.Builder()
// .setMaxStreams(1)
// .setAudioAttributes(audioAttributes)
// .build()
// beepSoundId.intValue = soundPool.value?.load(context, R.raw.beep, 1) ?: 0
// soundPool.value?.setOnLoadCompleteListener { soundPool, _, status ->
// if (status == 0) {
// soundPool.play(beepSoundId.intValue, 0.5f, 0.5f, 0, -1, 1f)
// }
// }
// onDispose {
// soundPool.value?.release()
// }
// }
Canvas(modifier = modifier.aspectRatio(1f)) { Canvas(modifier = modifier.aspectRatio(1f)) {
val radius = (size.minDimension / 2f) * 0.95f val radius = size.minDimension / 2f * 0.95f
if(radius != trackLastRadius.value) { if (radius != cachedRadius) {
trackPath.value = createTrackPath(items, radius) trackPath = createTrackPath(items, radius)
trackEffect.value = createTrackEffect(trackPath.value) trackEffect = createTrackEffect(trackPath)
trackLastRadius.value = radius cachedRadius = radius
} }
rotate(if (shouldUseCompass) -azimElev.first else 0f) { rotate(if (shouldUseCompass) -azimElev.first else 0f) {
if (shouldShowSweep) { if (shouldShowSweep) drawSweep(center, sweepDegrees, radius, trackColor)
drawSweep(center, sweepDegrees.floatValue, radius, trackColor) drawRadar(radius, radarColor)
} drawElevationLabels(radius, trackColor, measurer)
drawRadar(radius, radarColor, strokeWidth, 3)
drawInfo(radius, trackColor, measurer, 3)
translate(center.x, center.y) { translate(center.x, center.y) {
drawTrack(trackPath.value, trackEffect.value, aimColor, trackColor) drawTrack(trackPath, trackEffect, aimColor, trackColor)
if (item.elevation > 0) { if (item.elevation > 0) drawPosition(item, radius, animScale, trackColor)
drawPosition(item, radius, animScale.value, trackColor) if (shouldUseCompass) drawAim(azimElev.first, azimElev.second, radius, aimColor)
} }
if (shouldUseCompass) { sweepDegrees = (sweepDegrees + SWEEP_INCREMENT) % 360f
drawAim(azimElev.first, azimElev.second, radius, strokeWidth, aimColor)
}
}
sweepDegrees.floatValue = (sweepDegrees.floatValue + 360 / 12.0f / 60) % 360
} }
} }
} }
private fun DrawScope.drawRadar(radius: Float, color: Color, width: Float, circles: Int) { private fun DrawScope.drawRadar(radius: Float, color: Color) {
for (i in 0 until circles) { val step = radius / CIRCLES
val circleRadius = radius - radius / circles.toFloat() * i.toFloat() for (i in 0 until CIRCLES) {
drawCircle(color, circleRadius, style = Stroke(width)) drawCircle(color, radius - step * i, style = Stroke(STROKE_WIDTH))
} }
drawLine(color, center.copy(x = center.x - radius), center.copy(x = center.x + radius), width) drawLine(color, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), STROKE_WIDTH)
drawLine(color, center.copy(y = center.y - radius), center.copy(y = center.y + radius), width) drawLine(color, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), STROKE_WIDTH)
} }
private fun DrawScope.drawInfo(radius: Float, color: Color, measurer: TextMeasurer, circles: Int) { private fun DrawScope.drawElevationLabels(radius: Float, color: Color, measurer: TextMeasurer) {
for (i in 0 until circles) { val step = radius / CIRCLES
val textY = (radius - radius / circles * i) - 32f val degStep = 90 / CIRCLES
val textDeg = " ${(90 / circles) * (circles - i)}°" val style = TextStyle(color, 15.sp)
drawText(measurer, textDeg, center.copy(y = textY), style = TextStyle(color, 15.sp)) for (i in 0 until CIRCLES) {
val textY = (radius - step * i) - 32f
drawText(measurer, " ${degStep * (CIRCLES - i)}°", Offset(center.x, textY), style = style)
} }
} }
private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) { private fun DrawScope.drawTrack(path: Path, effect: PathEffect, color: Color, effectColor: Color) {
drawPath(path, color, style = Stroke(6f)) drawPath(path, color, style = Stroke(STROKE_WIDTH))
drawPath(path, effectColor, style = Stroke(pathEffect = effect)) drawPath(path, effectColor, style = Stroke(pathEffect = effect))
} }
private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) { private fun DrawScope.drawPosition(item: OrbitalPos, radius: Float, posRadius: Float, color: Color) {
val satX = sph2CartX(item.azimuth, item.elevation, radius.toDouble()) val pos = sph2Cart(item.azimuth, item.elevation, radius.toDouble())
val satY = sph2CartY(item.azimuth, item.elevation, radius.toDouble()) drawCircle(color, 16f, pos)
drawCircle(color, 16f, center.copy(satX, -satY)) drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, pos)
drawCircle(color.copy(alpha = 1 - (posRadius / 64f)), posRadius, center.copy(satX, -satY))
} }
private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, width: Float, color: Color) { private fun DrawScope.drawAim(azim: Float, elev: Float, radius: Float, color: Color) {
val size = 36f val size = 36f
val azimRadians = azim.toDouble().toRadians() val azimRad = azim.toDouble().toRadians()
val tempElevRadians = elev.toDouble().toRadians() val elevRad = elev.toDouble().toRadians().coerceAtMost(0.0)
val elevRadians = if (tempElevRadians > 0.0) 0.0 else tempElevRadians val pos = sph2Cart(azimRad, -elevRad, radius.toDouble())
val aimX = sph2CartX(azimRadians, -elevRadians, radius.toDouble()) drawLine(color, Offset(pos.x - size, pos.y), Offset(pos.x + size, pos.y), STROKE_WIDTH)
val aimY = sph2CartY(azimRadians, -elevRadians, radius.toDouble()) drawLine(color, Offset(pos.x, pos.y - size), Offset(pos.x, pos.y + size), STROKE_WIDTH)
try { drawCircle(color, size / 2, pos, style = Stroke(STROKE_WIDTH))
drawLine(color, center.copy(aimX - size, -aimY), center.copy(aimX + size, -aimY), width)
drawLine(color, center.copy(aimX, -aimY - size), center.copy(aimX, -aimY + size), width)
drawCircle(color, size / 2, center.copy(aimX, -aimY), style = Stroke(width))
} catch (exception: Exception) {
println(exception)
}
} }
private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) { private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, color: Color) {
@@ -216,37 +154,34 @@ private fun DrawScope.drawSweep(center: Offset, degrees: Float, radius: Float, c
private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path { private fun createTrackPath(positions: List<OrbitalPos>, radius: Float): Path {
val trackPath = Path() val trackPath = Path()
positions.forEachIndexed { index, satPos -> positions.forEachIndexed { index, pos ->
val passX = sph2CartX(satPos.azimuth, satPos.elevation, radius.toDouble()) val offset = sph2Cart(pos.azimuth, pos.elevation, radius.toDouble())
val passY = sph2CartY(satPos.azimuth, satPos.elevation, radius.toDouble()) if (index == 0) trackPath.moveTo(offset.x, offset.y) else trackPath.lineTo(offset.x, offset.y)
if (index == 0) trackPath.moveTo(passX, -passY) else trackPath.lineTo(passX, -passY)
} }
return trackPath return trackPath
} }
private fun createTrackEffect(trackPath: Path): PathEffect { private fun createTrackEffect(trackPath: Path): PathEffect {
val shape = Path()
val shapeRadius = 24f val shapeRadius = 24f
val angle = 120.0.toRadians() val angle = 120.0.toRadians()
shape.moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat()) val shape = Path().apply {
moveTo((shapeRadius * cos(angle)).toFloat(), (shapeRadius * sin(angle)).toFloat())
for (i in 1 until 3) { for (i in 1 until 3) {
val x = (shapeRadius * cos(angle - angle * i)).toFloat() lineTo(
val y = (shapeRadius * sin(angle - angle * i)).toFloat() x = (shapeRadius * cos(angle - angle * i)).toFloat(),
shape.lineTo(x, y) y = (shapeRadius * sin(angle - angle * i)).toFloat()
)
}
close()
} }
shape.close()
val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length val trackLength = PathMeasure().apply { setPath(trackPath, false) }.length
val advance = trackLength / 2f return PathEffect.stampedPathEffect(shape, trackLength / 2f, trackLength / 4f, StampedPathEffectStyle.Rotate)
val phase = trackLength / 4f
return PathEffect.stampedPathEffect(shape, advance, phase, StampedPathEffectStyle.Rotate)
} }
private fun sph2CartX(azim: Double, elev: Double, r: Double): Float { private fun sph2Cart(azim: Double, elev: Double, r: Double): Offset {
val radius = r * (PI_2 - elev) / PI_2 val radius = r * (PI_2 - elev) / PI_2
return (radius * cos(PI_2 - azim)).toFloat() return Offset(
} x = (radius * cos(PI_2 - azim)).toFloat(),
y = -(radius * sin(PI_2 - azim)).toFloat()
private fun sph2CartY(azim: Double, elev: Double, r: Double): Float { )
val radius = r * (PI_2 - elev) / PI_2
return (radius * sin(PI_2 - azim)).toFloat()
} }