mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added SSTV image decoding functionality to RadarScreen
This commit is contained in:
1 parent
f7f5a73c4c
commit
79809aaec0
24 files changed
+1991
-470
No files matched your search
@@ -17,6 +17,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -39,6 +43,7 @@ import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -49,6 +54,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -76,14 +82,27 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUp)
|
||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
LaunchedEffect(hasPermission) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(hasPermission))
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> viewModel.onAction(RadarAction.SstvPermissionResult(granted)) }
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUp, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarScreen(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
navigateUp: () -> Unit
|
||||
navigateUp: () -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
val addToCalendar: () -> Unit = {
|
||||
@@ -113,11 +132,11 @@ private fun RadarScreen(
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +146,7 @@ private fun RadarScreen(
|
||||
private fun PagerCard(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val pages = RadarPage.entries
|
||||
@@ -155,38 +175,17 @@ private fun PagerCard(
|
||||
radioControl = uiState.radioControl,
|
||||
onAction = onAction
|
||||
)
|
||||
RadarPage.Sstv -> SstvPlaceholderPage()
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
onAction = onAction,
|
||||
requestMicPermission = requestMicPermission
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SstvPlaceholderPage() {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(text = "🚧", fontSize = 48.sp)
|
||||
Text(
|
||||
text = "SSTV Decoder",
|
||||
fontSize = 20.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Coming soon",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadarCard(uiState: RadarState, modifier: Modifier = Modifier) {
|
||||
val satellitePos = uiState.orbitalPos
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.sstv.SstvFrame
|
||||
|
||||
data class RadioPanelState(
|
||||
val label: String = "",
|
||||
@@ -57,7 +58,18 @@ data class RadarState(
|
||||
val transmitters: List<SatRadio> = emptyList(),
|
||||
val selectedTransmitterUuid: String? = null,
|
||||
val selectedFrequency: Long? = null,
|
||||
val radioControl: RadioControlSubState = RadioControlSubState()
|
||||
val radioControl: RadioControlSubState = RadioControlSubState(),
|
||||
val sstv: SstvSubState = SstvSubState()
|
||||
)
|
||||
|
||||
enum class SstvStatus { Idle, Recording, Saving }
|
||||
|
||||
data class SstvSubState(
|
||||
val status: SstvStatus = SstvStatus.Idle,
|
||||
val hasPermission: Boolean = false,
|
||||
val selectedMode: String = "Auto",
|
||||
val supportedModes: List<String> = emptyList(),
|
||||
val currentFrame: SstvFrame? = null
|
||||
)
|
||||
|
||||
sealed interface RadarAction {
|
||||
@@ -72,4 +84,12 @@ sealed interface RadarAction {
|
||||
data object ToggleTracking : RadarAction
|
||||
data object ConnectRadios : RadarAction
|
||||
data object DisconnectRadios : RadarAction
|
||||
|
||||
// SSTV actions
|
||||
data object SstvStartRecording : RadarAction
|
||||
data object SstvStopRecording : RadarAction
|
||||
data object SstvSaveImage : RadarAction
|
||||
data object SstvReset : RadarAction
|
||||
data class SstvSelectMode(val modeName: String) : RadarAction
|
||||
data class SstvPermissionResult(val granted: Boolean) : RadarAction
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.PathMeasure
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.StampedPathEffectStyle
|
||||
import androidx.compose.ui.graphics.SweepGradientShader
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
@@ -220,7 +221,7 @@ private fun DrawScope.drawBodyIcon(
|
||||
translate(pos.x - half, pos.y - half)
|
||||
}) {
|
||||
with(painter) {
|
||||
draw(Size(iconSize, iconSize), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(color))
|
||||
draw(Size(iconSize, iconSize), colorFilter = ColorFilter.tint(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-58
@@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.CelestialComputer
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
@@ -31,10 +32,15 @@ import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.sstv.SstvDecoder
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.round
|
||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -51,91 +57,100 @@ class RadarViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val addToCalendar: IAddToCalendar,
|
||||
private val trackingService: IRadioTrackingService
|
||||
private val trackingService: IRadioTrackingService,
|
||||
private val audioCapture: IAudioCapture,
|
||||
private val saveImage: ISaveImage,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
private val stationPos = settingsRepo.stationPosition.value
|
||||
private val magDeclination = sensorsRepo.getMagDeclination(stationPos)
|
||||
private var transponders: List<SatRadio> = emptyList()
|
||||
private var sstvDecoder: SstvDecoder? = null
|
||||
private var sstvRecordingJob: Job? = null
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
RadarState(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
orientationValues = sensorsRepo.orientation.value,
|
||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||
)
|
||||
)
|
||||
val uiState: StateFlow<RadarState> = _uiState
|
||||
|
||||
init {
|
||||
// Compass sensor collection
|
||||
if (settingsRepo.otherSettings.value.stateOfSensors) {
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.orientation.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
collectCompassSensor()
|
||||
collectSettingsChanges()
|
||||
collectPassAndStartTickLoop()
|
||||
collectRadioTrackingState()
|
||||
}
|
||||
|
||||
private fun collectCompassSensor() {
|
||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.orientation.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
}
|
||||
// React to UTC setting changes
|
||||
}
|
||||
|
||||
private fun collectSettingsChanges() {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
// Resolve which pass we're tracking and start the tick loop
|
||||
}
|
||||
|
||||
private fun collectPassAndStartTickLoop() {
|
||||
viewModelScope.launch {
|
||||
val passes = satelliteRepo.passes.value
|
||||
val (catNum, aosTime) = satelliteRepo.selectedPass.value
|
||||
val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
|
||||
satPass?.let { pass ->
|
||||
_uiState.update { it.copy(currentPass = pass) }
|
||||
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||
// Load transponders for radio control (ones that have downlink)
|
||||
transponders = transmittersList.filter { it.downlinkLow != null }
|
||||
_uiState.update { state ->
|
||||
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
|
||||
}
|
||||
// Auto-expand the first item so users discover the expandable UI
|
||||
if (transmittersList.isNotEmpty()) {
|
||||
val firstUuid = transmittersList.first().uuid
|
||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
||||
transponders.find { it.uuid == firstUuid }?.let {
|
||||
trackingService.setTransponder(it)
|
||||
}
|
||||
}
|
||||
// Compute track once (it doesn't change for a given pass)
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
// Tick loop — position, timer, and radio updates every second
|
||||
while (isActive) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentTime = time,
|
||||
isTimeAos = isAos,
|
||||
isLos = isLos,
|
||||
orbitalPos = pos,
|
||||
sunPosition = sunPos,
|
||||
moonPosition = moonPos
|
||||
)
|
||||
}
|
||||
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
||||
sendPassData(pos)
|
||||
delay(1000)
|
||||
}
|
||||
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull() ?: return@launch
|
||||
_uiState.update { it.copy(currentPass = pass) }
|
||||
val transmittersList = satelliteRepo.getRadiosWithId(pass.catNum)
|
||||
transponders = transmittersList.filter { it.downlinkLow != null }
|
||||
_uiState.update { state ->
|
||||
state.copy(radioControl = state.radioControl.copy(transponders = transponders))
|
||||
}
|
||||
if (transmittersList.isNotEmpty()) {
|
||||
val firstUuid = transmittersList.first().uuid
|
||||
_uiState.update { it.copy(selectedTransmitterUuid = firstUuid) }
|
||||
transponders.find { it.uuid == firstUuid }?.let { trackingService.setTransponder(it) }
|
||||
}
|
||||
if (!pass.isDeepSpace) {
|
||||
val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
|
||||
_uiState.update { it.copy(satTrack = track) }
|
||||
}
|
||||
while (isActive) {
|
||||
tickPass(pass, transmittersList)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
// Observe radio tracking service state
|
||||
}
|
||||
|
||||
private suspend fun tickPass(pass: OrbitalPass, transmittersList: List<SatRadio>) {
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
|
||||
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
|
||||
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
|
||||
val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
|
||||
val isLos = !pass.isDeepSpace && timeNow > pass.losTime
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentTime = time, isTimeAos = isAos, isLos = isLos,
|
||||
orbitalPos = pos, sunPosition = sunPos, moonPosition = moonPos
|
||||
)
|
||||
}
|
||||
processRadios(transmittersList, pass.orbitalObject, timeNow)
|
||||
sendPassData(pos)
|
||||
}
|
||||
|
||||
private fun collectRadioTrackingState() {
|
||||
viewModelScope.launch {
|
||||
trackingService.state.collect { svc ->
|
||||
_uiState.update { state ->
|
||||
@@ -210,6 +225,33 @@ class RadarViewModel(
|
||||
}
|
||||
RadarAction.ConnectRadios -> viewModelScope.launch { trackingService.connectRadios() }
|
||||
RadarAction.DisconnectRadios -> viewModelScope.launch { trackingService.disconnectRadios() }
|
||||
|
||||
// SSTV actions
|
||||
is RadarAction.SstvPermissionResult -> {
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(hasPermission = action.granted)) }
|
||||
if (action.granted) initSstvDecoder()
|
||||
}
|
||||
RadarAction.SstvStartRecording -> startSstvRecording()
|
||||
RadarAction.SstvStopRecording -> stopSstvRecording()
|
||||
RadarAction.SstvSaveImage -> {
|
||||
val frame = _uiState.value.sstv.currentFrame ?: return
|
||||
val pixels = frame.imagePixels ?: return
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Saving)) }
|
||||
viewModelScope.launch {
|
||||
saveImage(pixels, frame.imageWidth, frame.imageHeight, frame.modeName)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||
showToast("Image saved")
|
||||
}
|
||||
}
|
||||
is RadarAction.SstvSelectMode -> {
|
||||
sstvDecoder?.lockMode(action.modeName)
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(selectedMode = action.modeName)) }
|
||||
settingsRepo.updateOtherSettings { it.copy(sstvMode = action.modeName) }
|
||||
}
|
||||
RadarAction.SstvReset -> {
|
||||
sstvDecoder?.clearPixels() // Keep decoder alive, just reset pixels
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = null)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +323,36 @@ class RadarViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun initSstvDecoder() {
|
||||
if (sstvDecoder == null) {
|
||||
val decoder = SstvDecoder(sampleRate = audioCapture.sampleRate)
|
||||
sstvDecoder = decoder
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(supportedModes = decoder.supportedModes)) }
|
||||
viewModelScope.launch {
|
||||
decoder.frames.collect { frame ->
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(currentFrame = frame)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSstvRecording() {
|
||||
if (sstvRecordingJob?.isActive == true) return
|
||||
initSstvDecoder()
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Recording)) }
|
||||
sstvRecordingJob = viewModelScope.launch {
|
||||
audioCapture.audioFlow().collect { buffer ->
|
||||
sstvDecoder?.feedSamples(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSstvRecording() {
|
||||
sstvRecordingJob?.cancel()
|
||||
sstvRecordingJob = null
|
||||
_uiState.update { it.copy(sstv = it.sstv.copy(status = SstvStatus.Idle)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val CTCSS_TONES = listOf(
|
||||
@@ -307,7 +379,10 @@ class RadarViewModel(
|
||||
settingsRepo = container.settingsRepo,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
addToCalendar = container.provideAddToCalendar(),
|
||||
trackingService = container.radioTrackingService
|
||||
trackingService = container.radioTrackingService,
|
||||
audioCapture = container.provideAudioCapture(),
|
||||
saveImage = container.provideSaveImage(),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun RadioControlPage(
|
||||
radioState: RadioControlSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val selectedTransponder = radioState.transponders.find {
|
||||
it.uuid == radioState.selectedTransponderUuid
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Transponder selector (dropdown)
|
||||
TransponderDropdown(
|
||||
transponders = radioState.transponders,
|
||||
selectedTransponder = selectedTransponder,
|
||||
onAction = onAction
|
||||
)
|
||||
|
||||
// Active transponder status (TX/RX lines)
|
||||
if (selectedTransponder != null) {
|
||||
ActiveTransponderStatus(
|
||||
transponder = selectedTransponder,
|
||||
txPanel = radioState.txPanel,
|
||||
rxPanel = radioState.rxPanel
|
||||
)
|
||||
}
|
||||
|
||||
// Frequency tuner + CTCSS
|
||||
if (radioState.txBaseFrequencyHz != null) {
|
||||
FrequencyTunerCard(
|
||||
txBaseFrequencyHz = radioState.txBaseFrequencyHz,
|
||||
selectedTransponder = selectedTransponder,
|
||||
ctcssTone = radioState.ctcssTone,
|
||||
showCtcss = selectedTransponder?.uplinkMode?.uppercase() == "FM",
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Error message
|
||||
radioState.errorMessage?.let { msg ->
|
||||
Text(
|
||||
text = msg,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Control buttons
|
||||
ControlButtons(
|
||||
isConnected = radioState.txPanel.isConnected || radioState.rxPanel.isConnected,
|
||||
isTracking = radioState.isTracking,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TransponderDropdown(
|
||||
transponders: List<SatRadio>,
|
||||
selectedTransponder: SatRadio?,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedTransponder?.info ?: "No transponders available",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
transponders.forEach { radio ->
|
||||
val invLabel = if (radio.isInverted) " INV" else ""
|
||||
val modeLabel = "${radio.uplinkMode ?: "--"}/${radio.downlinkMode ?: "--"}$invLabel"
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = radio.info,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = modeLabel,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
onAction(RadarAction.SelectTransponder(radio.uuid))
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveTransponderStatus(
|
||||
transponder: SatRadio,
|
||||
txPanel: RadioPanelState,
|
||||
rxPanel: RadioPanelState
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// TX line (uplink — on top)
|
||||
FrequencyStatusRow(
|
||||
label = "TX",
|
||||
frequency = txPanel.frequencyHz ?: transponder.uplinkLow,
|
||||
isConnected = txPanel.isConnected,
|
||||
mode = txPanel.mode
|
||||
)
|
||||
// RX line (downlink — below)
|
||||
FrequencyStatusRow(
|
||||
label = "RX",
|
||||
frequency = rxPanel.frequencyHz ?: transponder.downlinkLow,
|
||||
isConnected = rxPanel.isConnected,
|
||||
mode = rxPanel.mode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FrequencyStatusRow(
|
||||
label: String,
|
||||
frequency: Long?,
|
||||
isConnected: Boolean,
|
||||
mode: String?
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "$label:",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(28.dp)
|
||||
)
|
||||
Text(
|
||||
text = frequency?.let { RadarViewModel.formatFrequency(it) } ?: "—",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (mode != null) {
|
||||
Text(
|
||||
text = mode,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isConnected) Color(0xFF4CAF50) else Color(0xFFE57373))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FrequencyTunerCard(
|
||||
txBaseFrequencyHz: Long,
|
||||
selectedTransponder: SatRadio?,
|
||||
ctcssTone: Double?,
|
||||
showCtcss: Boolean,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
) {
|
||||
// TX Base Frequency label + value on one line
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "TX Base: ",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "${RadarViewModel.formatFrequency(txBaseFrequencyHz)} MHz",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
// Show band range hint if it's a wide transponder
|
||||
if (selectedTransponder != null) {
|
||||
val upLow = selectedTransponder.uplinkLow
|
||||
val upHigh = selectedTransponder.uplinkHigh
|
||||
if (upLow != null && upHigh != null && upLow != upHigh) {
|
||||
Text(
|
||||
text = "(${RadarViewModel.formatFrequency(upLow)} – ${RadarViewModel.formatFrequency(upHigh)})",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// Adjustment buttons
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FREQ_ADJUSTMENTS.forEach { (delta, label) ->
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.AdjustTxFrequency(delta)) },
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
// CTCSS inline (only for FM)
|
||||
if (showCtcss) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "CTCSS",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
FilterChip(
|
||||
selected = ctcssTone == null,
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(null)) },
|
||||
label = { Text("Off", fontSize = 12.sp) }
|
||||
)
|
||||
RadarViewModel.CTCSS_TONES.forEach { tone ->
|
||||
FilterChip(
|
||||
selected = ctcssTone == tone,
|
||||
onClick = { onAction(RadarAction.SetCtcssTone(tone)) },
|
||||
label = { Text(String.format(Locale.ENGLISH, "%.1f", tone), fontSize = 12.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ControlButtons(
|
||||
isConnected: Boolean,
|
||||
isTracking: Boolean,
|
||||
onAction: (RadarAction) -> Unit
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (!isConnected) {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ConnectRadios) },
|
||||
text = "Connect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.DisconnectRadios) },
|
||||
text = "Disconnect",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
CardButton(
|
||||
onClick = { onAction(RadarAction.ToggleTracking) },
|
||||
text = if (isTracking) "Stop" else "Track",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val FREQ_ADJUSTMENTS =
|
||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxHeight
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||
|
||||
@Composable
|
||||
internal fun SstvPage(
|
||||
sstv: SstvSubState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
requestMicPermission: () -> Unit
|
||||
) {
|
||||
if (!sstv.hasPermission) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = "🎙️", fontSize = 48.sp)
|
||||
Text(
|
||||
text = "Microphone access needed",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Button(onClick = requestMicPermission) {
|
||||
Text("Grant permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode selection dialog
|
||||
val showModeDialog = remember { mutableStateOf(false) }
|
||||
|
||||
if (showModeDialog.value) {
|
||||
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
|
||||
Dialog(onDismissRequest = { showModeDialog.value = false }) {
|
||||
ElevatedCard {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "SSTV Mode",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(0.5f)
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
items(allModes) { mode ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable {
|
||||
onAction(RadarAction.SstvSelectMode(mode))
|
||||
showModeDialog.value = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = mode,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
)
|
||||
RadioButton(
|
||||
selected = mode == sstv.selectedMode,
|
||||
onClick = null,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main SSTV view — image fills entire area, controls overlay at bottom
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
// Decoded image or placeholder — centered in available space
|
||||
val frame = sstv.currentFrame
|
||||
val pixels = frame?.imagePixels
|
||||
if (pixels != null && frame.imageWidth > 0 && frame.imageHeight > 0) {
|
||||
val bitmap = remember(pixels, frame.imageWidth, frame.imageHeight) {
|
||||
Bitmap.createBitmap(
|
||||
pixels,
|
||||
frame.imageWidth,
|
||||
frame.imageHeight,
|
||||
Bitmap.Config.ARGB_8888
|
||||
).asImageBitmap()
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "Decoded SSTV image",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
// Bottom control bar — dark, blends with the black canvas
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
// Mode button — opens dialog
|
||||
ElevatedCard(
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
onClick = { showModeDialog.value = true },
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Mode: ${sstv.selectedMode}",
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.infiniteMarquee()
|
||||
)
|
||||
}
|
||||
}
|
||||
// Reset button — clears decoder state and image
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvReset) },
|
||||
resId = R.drawable.ic_delete
|
||||
)
|
||||
// Save button — always visible, enabled when there are pixels
|
||||
IconCard(
|
||||
action = { onAction(RadarAction.SstvSaveImage) },
|
||||
resId = R.drawable.ic_save,
|
||||
enabled = sstv.currentFrame?.imagePixels != null
|
||||
)
|
||||
// Record / Stop button
|
||||
val playAction = {
|
||||
when (sstv.status) {
|
||||
SstvStatus.Idle -> onAction(RadarAction.SstvStartRecording)
|
||||
SstvStatus.Recording -> onAction(RadarAction.SstvStopRecording)
|
||||
SstvStatus.Saving -> {}
|
||||
}
|
||||
}
|
||||
val playColors = CardDefaults.elevatedCardColors(
|
||||
containerColor = if (sstv.status == SstvStatus.Recording) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
val playIcon = if (sstv.status == SstvStatus.Recording) R.drawable.ic_pause else R.drawable.ic_play
|
||||
ElevatedCard(modifier = Modifier.size(48.dp), onClick = playAction, colors = playColors) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(painter = painterResource(playIcon), contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user