Switched to MVI architecture for PassesScreen

This commit is contained in:
Arty Bishop committed 2025-12-27 15:01:45 +00:00
1 parent b9ade5f243
commit a088a7a0a2
20 files changed
+203 -236

No files matched your search

@@ -25,6 +25,7 @@ import com.rtbishop.look4sat.presentation.entries.EntriesViewModel
import com.rtbishop.look4sat.presentation.info.InfoScreen import com.rtbishop.look4sat.presentation.info.InfoScreen
import com.rtbishop.look4sat.presentation.map.MapScreen import com.rtbishop.look4sat.presentation.map.MapScreen
import com.rtbishop.look4sat.presentation.passes.PassesScreen import com.rtbishop.look4sat.presentation.passes.PassesScreen
import com.rtbishop.look4sat.presentation.passes.PassesViewModel
import com.rtbishop.look4sat.presentation.radar.RadarScreen import com.rtbishop.look4sat.presentation.radar.RadarScreen
import com.rtbishop.look4sat.presentation.settings.SettingsScreen import com.rtbishop.look4sat.presentation.settings.SettingsScreen
@@ -73,10 +74,11 @@ private fun MainNavGraph(navController: NavHostController) {
EntriesScreen(viewModel.uiState.value, navToPasses) EntriesScreen(viewModel.uiState.value, navToPasses)
} }
composable(Screen.Passes.route) { composable(Screen.Passes.route) {
val viewModel = viewModel(PassesViewModel::class.java, factory = PassesViewModel.Factory)
val navToRadar = { catNum: Int, aosTime: Long -> val navToRadar = { catNum: Int, aosTime: Long ->
navController.navigate("${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}") navController.navigate("${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}")
} }
PassesScreen(navToRadar) PassesScreen(viewModel.uiState.value, navToRadar)
} }
composable(radarRoute, radarArgs) { RadarScreen() } composable(radarRoute, radarArgs) { RadarScreen() }
composable(Screen.Map.route) { MapScreen() } composable(Screen.Map.route) { MapScreen() }
@@ -38,7 +38,7 @@ fun TypesDialog(items: List<String>, selected: String, dismiss: () -> Unit, sele
dismiss() dismiss()
} }
Dialog(onDismissRequest = { dismiss() }) { Dialog(onDismissRequest = { dismiss() }) {
ElevatedCard(modifier = Modifier.fillMaxHeight(0.75f)) { ElevatedCard(modifier = Modifier.fillMaxHeight(0.80f)) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp), columns = GridCells.Adaptive(160.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background), modifier = Modifier.background(MaterialTheme.colorScheme.background),
@@ -47,7 +47,8 @@ fun TypesDialog(items: List<String>, selected: String, dismiss: () -> Unit, sele
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
Surface { Surface {
Row(verticalAlignment = Alignment.CenterVertically, Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.clickable { clickAction(item) }) { .clickable { clickAction(item) }) {
@@ -64,7 +65,7 @@ fun TypesDialog(items: List<String>, selected: String, dismiss: () -> Unit, sele
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
RadioButton(selected = item == selected, onClick = { clickAction(item) }) RadioButton(selected = item == selected, onClick = null, modifier = Modifier.padding(8.dp))
} }
} }
} }
@@ -46,18 +46,17 @@ import com.rtbishop.look4sat.presentation.components.CardLoadingIndicator
@Composable @Composable
fun EntriesScreen(uiState: EntriesState, navToPasses: () -> Unit) { fun EntriesScreen(uiState: EntriesState, navToPasses: () -> Unit) {
val showDialog = rememberSaveable { mutableStateOf(false) } val toggleDialog = { uiState.takeAction(EntriesAction.ToggleTypesDialog) }
val toggleDialog = { showDialog.value = showDialog.value.not() } if (uiState.isDialogShown) {
if (showDialog.value) {
TypesDialog(items = uiState.typesList, selected = uiState.currentType, toggleDialog) { TypesDialog(items = uiState.typesList, selected = uiState.currentType, toggleDialog) {
uiState.takeAction(EntriesAction.SelectType(it)) uiState.takeAction(EntriesAction.SelectType(it))
} }
} }
val unselectAll = { uiState.takeAction(EntriesAction.UnselectAll) } val unselectAll = { uiState.takeAction(EntriesAction.UnselectAll) }
val selectAll = { uiState.takeAction(EntriesAction.SelectAll) } val selectAll = { uiState.takeAction(EntriesAction.SelectAll) }
Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
TopBar(setQuery = { newQuery: String -> uiState.takeAction(EntriesAction.SearchFor(newQuery)) }, TopBar(
setQuery = { newQuery: String -> uiState.takeAction(EntriesAction.SearchFor(newQuery)) },
saveSelection = { saveSelection = {
uiState.takeAction(EntriesAction.SaveSelection) uiState.takeAction(EntriesAction.SaveSelection)
navToPasses() navToPasses()
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.presentation.entries
import com.rtbishop.look4sat.domain.model.SatItem import com.rtbishop.look4sat.domain.model.SatItem
data class EntriesState( data class EntriesState(
val isDialogShown: Boolean,
val isLoading: Boolean, val isLoading: Boolean,
val itemsList: List<SatItem>, val itemsList: List<SatItem>,
val currentType: String, val currentType: String,
@@ -16,5 +17,6 @@ sealed class EntriesAction {
data object SelectAll : EntriesAction() data object SelectAll : EntriesAction()
data class SelectSingle(val id: Int, val isTicked: Boolean) : EntriesAction() data class SelectSingle(val id: Int, val isTicked: Boolean) : EntriesAction()
data class SelectType(val type: String) : EntriesAction() data class SelectType(val type: String) : EntriesAction()
data object ToggleTypesDialog : EntriesAction()
data object UnselectAll : EntriesAction() data object UnselectAll : EntriesAction()
} }
@@ -17,6 +17,7 @@
*/ */
package com.rtbishop.look4sat.presentation.entries package com.rtbishop.look4sat.presentation.entries
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@@ -30,20 +31,23 @@ import kotlinx.coroutines.launch
class EntriesViewModel(private val selectionRepo: ISelectionRepo) : ViewModel() { class EntriesViewModel(private val selectionRepo: ISelectionRepo) : ViewModel() {
private val defaultUiState = EntriesState( private val _uiState = mutableStateOf(
isLoading = true, EntriesState(
itemsList = emptyList(), isDialogShown = false,
currentType = selectionRepo.getCurrentType(), isLoading = true,
typesList = selectionRepo.getTypesList(), itemsList = emptyList(),
takeAction = ::handleAction currentType = selectionRepo.getCurrentType(),
typesList = selectionRepo.getTypesList(),
takeAction = ::handleAction
)
) )
val uiState = mutableStateOf(defaultUiState) val uiState: State<EntriesState> = _uiState
init { init {
viewModelScope.launch { viewModelScope.launch {
delay(1000) delay(1000)
selectionRepo.getEntriesFlow().collect { items -> selectionRepo.getEntriesFlow().collect { items ->
uiState.value = uiState.value.copy(isLoading = false, itemsList = items) _uiState.value = _uiState.value.copy(isLoading = false, itemsList = items)
} }
} }
} }
@@ -55,6 +59,7 @@ class EntriesViewModel(private val selectionRepo: ISelectionRepo) : ViewModel()
EntriesAction.SelectAll -> selectAll(true) EntriesAction.SelectAll -> selectAll(true)
is EntriesAction.SelectSingle -> selectSingle(action.id, action.isTicked) is EntriesAction.SelectSingle -> selectSingle(action.id, action.isTicked)
is EntriesAction.SelectType -> selectType(action.type) is EntriesAction.SelectType -> selectType(action.type)
EntriesAction.ToggleTypesDialog -> toggleTypesDialog()
EntriesAction.UnselectAll -> selectAll(false) EntriesAction.UnselectAll -> selectAll(false)
} }
} }
@@ -73,7 +78,12 @@ class EntriesViewModel(private val selectionRepo: ISelectionRepo) : ViewModel()
private fun selectType(type: String) = viewModelScope.launch { private fun selectType(type: String) = viewModelScope.launch {
selectionRepo.setType(type) selectionRepo.setType(type)
uiState.value = uiState.value.copy(currentType = type) _uiState.value = _uiState.value.copy(currentType = type)
}
private fun toggleTypesDialog() {
val currentDialogState = _uiState.value.isDialogShown
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
} }
companion object { companion object {
@@ -39,7 +39,8 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.painterResource
@@ -54,27 +55,43 @@ import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.presentation.MainTheme import com.rtbishop.look4sat.presentation.MainTheme
import com.rtbishop.look4sat.presentation.components.CardButton import com.rtbishop.look4sat.presentation.components.CardButton
private val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun FilterDialogPreview() { private fun FilterDialogPreview() {
MainTheme { FilterDialog(24, 16.0, {}) { _, _ -> } } MainTheme { FilterDialog(24, 16.0, emptyList(), {}) { _, _, _ -> } }
} }
@Composable @Composable
fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int, Double) -> Unit) { fun FilterDialog(
val hoursValue = rememberSaveable { mutableIntStateOf(hours) } hours: Int,
val elevationValue = rememberSaveable { mutableIntStateOf(elevation.toInt()) } elev: Double,
modes: List<String>,
dismiss: () -> Unit,
save: (Int, Double, List<String>) -> Unit
) {
val maxWidthModifier = Modifier.fillMaxWidth(1f) val maxWidthModifier = Modifier.fillMaxWidth(1f)
val hoursValue = remember { mutableIntStateOf(hours) }
val elevationValue = remember { mutableIntStateOf(elev.toInt()) }
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
val select = { mode: String -> if (selected.contains(mode)) selected.remove(mode) else selected.add(mode) }
Dialog(onDismissRequest = { dismiss() }) { Dialog(onDismissRequest = { dismiss() }) {
ElevatedCard { ElevatedCard(modifier = Modifier.fillMaxHeight(0.80f)) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = maxWidthModifier.padding(12.dp) modifier = maxWidthModifier.padding(12.dp)
) { ) {
Text(text = "Filter passes", fontSize = 20.sp, color = MaterialTheme.colorScheme.primary) Text(text = "Filter passes", fontSize = 18.sp, color = MaterialTheme.colorScheme.primary)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) {
Text(text = "Show passes within", fontSize = 18.sp, modifier = Modifier.weight(1f)) Text(text = "Show passes within", fontSize = 16.sp, modifier = Modifier.weight(1f))
Icon( Icon(
painter = painterResource(id = R.drawable.ic_time), painter = painterResource(id = R.drawable.ic_time),
contentDescription = null, contentDescription = null,
@@ -85,7 +102,7 @@ fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int,
) )
Text( Text(
text = "${hoursValue.intValue}h", text = "${hoursValue.intValue}h",
fontSize = 20.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -97,7 +114,7 @@ fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int,
colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant) colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant)
) )
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) {
Text(text = "Show passes above", fontSize = 18.sp, modifier = Modifier.weight(1f)) Text(text = "Show passes above", fontSize = 16.sp, modifier = Modifier.weight(1f))
Icon( Icon(
painter = painterResource(id = R.drawable.ic_elevation), painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null, contentDescription = null,
@@ -108,7 +125,7 @@ fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int,
) )
Text( Text(
text = "${elevationValue.intValue}°", text = "${elevationValue.intValue}°",
fontSize = 20.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -119,11 +136,47 @@ fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int,
valueRange = 0f..90f, valueRange = 0f..90f,
colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant) colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.onSurfaceVariant)
) )
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(allModes) { index, item ->
Surface {
Row(verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }) {
Text(
text = "$index).",
modifier = Modifier.padding(start = 8.dp, end = 6.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
onCheckedChange = null,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
CardButton(onClick = { dismiss() }, text = stringResource(id = R.string.btn_cancel)) CardButton(onClick = { dismiss() }, text = stringResource(id = R.string.btn_cancel))
CardButton( CardButton(
onClick = { onClick = {
save(hoursValue.intValue, elevationValue.intValue.toDouble()) save(hoursValue.intValue, elevationValue.intValue.toDouble(), selected)
dismiss() dismiss()
}, text = stringResource(id = R.string.btn_accept) }, text = stringResource(id = R.string.btn_accept)
) )
@@ -132,54 +185,3 @@ fun FilterDialog(hours: Int, elevation: Double, dismiss: () -> Unit, save: (Int,
} }
} }
} }
@Preview(showBackground = true)
@Composable
private fun ModesDialogPreview() {
val allModes = listOf(
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
"CERTO", "CW", "DQPSK", "DSTAR", "DUV", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5",
"FSK AX.100 Mode 6", "FSK AX.25 G3RUH", "GFSK", "GFSK Rktr", "GMSK", "HRPT", "LoRa",
"LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5", "MSK AX.100 Mode 6", "OFDM", "OQPSK",
"PSK", "PSK31", "PSK63", "QPSK", "QPSK31", "QPSK63", "SSTV", "USB", "WSJT"
)
MainTheme { ModesDialog(allModes, listOf("AFSK", "AFSK S-Net"), {}) {} }
}
@Composable
fun ModesDialog(items: List<String>, selected: List<String>, dismiss: () -> Unit, select: (String) -> Unit) {
Dialog(onDismissRequest = { dismiss() }) {
ElevatedCard(modifier = Modifier.fillMaxHeight(0.75f)) {
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background),
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp)
) {
itemsIndexed(items) { index, item ->
Surface {
Row(verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }) {
Text(
text = "$index).",
modifier = Modifier.padding(start = 12.dp, end = 6.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(checked = selected.contains(item), onCheckedChange = { select(item) })
}
}
}
}
}
}
}
@@ -23,9 +23,6 @@ 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.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment 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.painterResource
@@ -36,9 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.R import com.rtbishop.look4sat.R
import com.rtbishop.look4sat.domain.model.DataState
import com.rtbishop.look4sat.domain.predict.NearEarthSatellite import com.rtbishop.look4sat.domain.predict.NearEarthSatellite
import com.rtbishop.look4sat.domain.predict.OrbitalData import com.rtbishop.look4sat.domain.predict.OrbitalData
import com.rtbishop.look4sat.domain.predict.SatPass import com.rtbishop.look4sat.domain.predict.SatPass
@@ -53,27 +48,16 @@ import java.util.*
private val sdf = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH) private val sdf = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
@Composable @Composable
fun PassesScreen(navToRadar: (Int, Long) -> Unit) { fun PassesScreen(uiState: PassesState, navToRadar: (Int, Long) -> Unit) {
val viewModel = viewModel(PassesViewModel::class.java, factory = PassesViewModel.Factory) val refreshState = rememberPullRefreshState(refreshing = uiState.isRefreshing, onRefresh = {
val state = viewModel.passes.collectAsState(initial = null) uiState.takeAction(PassesAction.RefreshPasses)
val timerText = viewModel.timerText.collectAsState(initial = null) })
val isRefreshing = state.value is DataState.Loading val toggleDialog = { uiState.takeAction(PassesAction.ToggleFilterDialog) }
val refreshState = rememberPullRefreshState(refreshing = isRefreshing, if (uiState.isDialogShown) {
onRefresh = { viewModel.calculatePasses() }) FilterDialog(uiState.hours, uiState.elevation, uiState.modes, toggleDialog) { hours, elevation, modes ->
uiState.takeAction(PassesAction.ApplyFilter(hours, elevation, modes))
val value = state.value
var passes = emptyList<SatPass>()
if (value is DataState.Success) passes = value.data
val showDialog = rememberSaveable { mutableStateOf(false) }
val toggleDialog = { showDialog.value = showDialog.value.not() }
if (showDialog.value) {
val (hoursAhead, minElevation) = viewModel.getFilterSettings()
FilterDialog(hoursAhead, minElevation, { toggleDialog() }) { newHours, newElevation ->
viewModel.calculatePasses(System.currentTimeMillis(), newHours, newElevation)
} }
} }
Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
ElevatedCard(modifier = Modifier.height(52.dp)) { ElevatedCard(modifier = Modifier.height(52.dp)) {
Row( Row(
@@ -88,11 +72,11 @@ fun PassesScreen(navToRadar: (Int, Long) -> Unit) {
) )
} }
Column(verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.weight(1f)) { Column(verticalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.weight(1f)) {
Text(text = timerText.value?.first ?: "Null") Text(text = "Next - Id:${uiState.nextId}", maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = timerText.value?.second ?: "Null") Text(text = uiState.nextName, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
Text( Text(
text = timerText.value?.third ?: "00:00:00", text = uiState.nextTime,
fontSize = 36.sp, fontSize = 36.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -103,7 +87,7 @@ fun PassesScreen(navToRadar: (Int, Long) -> Unit) {
) )
} }
} }
PassesCard(refreshState, isRefreshing, passes, navToRadar) PassesCard(refreshState, uiState.isRefreshing, uiState.itemsList, navToRadar)
} }
} }
@@ -0,0 +1,23 @@
package com.rtbishop.look4sat.presentation.passes
import com.rtbishop.look4sat.domain.predict.SatPass
data class PassesState(
val isDialogShown: Boolean,
val isRefreshing: Boolean,
val isUtc: Boolean,
val nextId: Int,
val nextName: String,
val nextTime: String,
val hours: Int,
val elevation: Double,
val modes: List<String>,
val itemsList: List<SatPass>,
val takeAction: (PassesAction) -> Unit
)
sealed class PassesAction {
data class ApplyFilter(val hoursAhead: Int, val minElevation: Double, val modes: List<String>) : PassesAction()
data object RefreshPasses : PassesAction()
data object ToggleFilterDialog : PassesAction()
}
@@ -17,13 +17,14 @@
*/ */
package com.rtbishop.look4sat.presentation.passes package com.rtbishop.look4sat.presentation.passes
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.MainApplication import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.domain.model.DataState
import com.rtbishop.look4sat.domain.model.PassesSettings import com.rtbishop.look4sat.domain.model.PassesSettings
import com.rtbishop.look4sat.domain.predict.SatPass import com.rtbishop.look4sat.domain.predict.SatPass
import com.rtbishop.look4sat.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.domain.repository.ISatelliteRepo
@@ -32,8 +33,6 @@ import com.rtbishop.look4sat.domain.utility.toTimerString
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -41,46 +40,35 @@ class PassesViewModel(
private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo private val satelliteRepo: ISatelliteRepo, private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _passes = MutableStateFlow<DataState<List<SatPass>>>(DataState.Loading) private val _uiState = mutableStateOf(
private val timerData = Triple("Next - Id:Null", "Name: Null", "00:00:00") PassesState(
private val _timerText = MutableStateFlow(timerData) isDialogShown = false,
private var passesProcessing: Job? = null isRefreshing = true,
val passes: StateFlow<DataState<List<SatPass>>> = _passes isUtc = settingsRepo.otherSettings.value.stateOfUtc,
val timerText: StateFlow<Triple<String, String, String>?> = _timerText nextId = 0,
nextName = "...",
fun getFilterSettings() = settingsRepo.passesSettings.value nextTime = "00:00:00",
hours = settingsRepo.passesSettings.value.hoursAhead,
// fun shouldUseUTC() = settingsRepo.otherSettings.value.utcState elevation = settingsRepo.passesSettings.value.minElevation,
modes = settingsRepo.passesSettings.value.selectedModes,
itemsList = emptyList(),
takeAction = ::handleAction
)
)
private var processing: Job? = null
val uiState: State<PassesState> = _uiState
init { init {
viewModelScope.launch { satelliteRepo.initRepository() } viewModelScope.launch { satelliteRepo.initRepository() }
viewModelScope.launch { viewModelScope.launch {
satelliteRepo.passes.collect { passes -> satelliteRepo.passes.collect { passes ->
passesProcessing?.cancelAndJoin() processing?.cancelAndJoin()
passesProcessing = viewModelScope.launch { processing = viewModelScope.launch {
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val newPasses = satelliteRepo.processPasses(passes, timeNow) val newPasses = satelliteRepo.processPasses(passes, timeNow)
setPassInfo(newPasses, timeNow)
if (newPasses.isNotEmpty()) { _uiState.value = _uiState.value.copy(isRefreshing = false, itemsList = newPasses)
try {
val nextPass = newPasses.first { it.aosTime.minus(timeNow) > 0 }
val catNum = nextPass.catNum
val name = nextPass.name
val millisBeforeStart = nextPass.aosTime.minus(timeNow)
val timerString = millisBeforeStart.toTimerString()
_timerText.emit(Triple("Next - Id:$catNum", name, timerString))
} catch (exception: NoSuchElementException) {
val lastPass = newPasses.last()
val catNum = lastPass.catNum
val name = lastPass.name
val millisBeforeEnd = lastPass.losTime.minus(timeNow)
val timerString = millisBeforeEnd.toTimerString()
_timerText.emit(Triple("Next - Id:$catNum", name, timerString))
}
} else _timerText.emit(timerData)
_passes.emit(DataState.Success(newPasses))
delay(1000) delay(1000)
} }
} }
@@ -88,18 +76,45 @@ class PassesViewModel(
} }
} }
fun calculatePasses() = viewModelScope.launch { private fun handleAction(action: PassesAction) {
_passes.emit(DataState.Loading) when (action) {
passesProcessing?.cancelAndJoin() is PassesAction.ApplyFilter -> applyFilter(action.hoursAhead, action.minElevation, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.ToggleFilterDialog -> toggleFilterDialog()
}
}
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
processing?.cancelAndJoin()
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
_uiState.value = _uiState.value.copy(hours = hoursAhead, elevation = minElevation, modes = modes)
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation)
}
private fun refreshPasses() = viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
processing?.cancelAndJoin()
val (hoursAhead, minElevation) = settingsRepo.passesSettings.value val (hoursAhead, minElevation) = settingsRepo.passesSettings.value
satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation) satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation)
} }
fun calculatePasses(timeRef: Long, hoursAhead: Int, minElev: Double) = viewModelScope.launch { private fun setPassInfo(passes: List<SatPass>, timeNow: Long) {
_passes.emit(DataState.Loading) if (passes.isEmpty()) return
passesProcessing?.cancelAndJoin() try {
settingsRepo.updatePassesSettings(PassesSettings(hoursAhead, minElev)) val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 }
satelliteRepo.calculatePasses(timeRef, hoursAhead, minElev) val time = nextPass.aosTime.minus(timeNow).toTimerString()
_uiState.value = _uiState.value.copy(nextId = nextPass.catNum, nextName = nextPass.name, nextTime = time)
} catch (exception: NoSuchElementException) {
val lastPass = passes.last()
val time = lastPass.losTime.minus(timeNow).toTimerString()
_uiState.value = _uiState.value.copy(nextId = lastPass.catNum, nextName = lastPass.name, nextTime = time)
}
}
private fun toggleFilterDialog() {
val currentDialogState = _uiState.value.isDialogShown
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
} }
companion object { companion object {
@@ -148,7 +148,7 @@ class RadarViewModel(
private fun processRadios(radios: List<SatRadio>, satellite: Satellite, time: Long) { private fun processRadios(radios: List<SatRadio>, satellite: Satellite, time: Long) {
viewModelScope.launch { viewModelScope.launch {
delay(125) delay(125)
val list = satelliteRepo.processRadios(satellite, stationPos, radios, time) val list = satelliteRepo.getRadios(satellite, stationPos, radios, time)
transmitters.value = list transmitters.value = list
} }
} }
@@ -59,9 +59,7 @@ class SatelliteRepo(
return withContext(dispatcher) { sat.getPosition(pos, time) } return withContext(dispatcher) { sat.getPosition(pos, time) }
} }
override suspend fun getTrack( override suspend fun getTrack(sat: Satellite, pos: GeoPos, start: Long, end: Long): List<SatPos> {
sat: Satellite, pos: GeoPos, start: Long, end: Long
): List<SatPos> {
return withContext(dispatcher) { return withContext(dispatcher) {
val positions = mutableListOf<SatPos>() val positions = mutableListOf<SatPos>()
var currentTime = start var currentTime = start
@@ -73,9 +71,7 @@ class SatelliteRepo(
} }
} }
override suspend fun processRadios( override suspend fun getRadios(sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> {
sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long
): List<SatRadio> {
return withContext(dispatcher) { return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time) val satPos = sat.getPosition(pos, time)
val copiedList = radios.map { it.copy() } val copiedList = radios.map { it.copy() }
@@ -82,26 +82,19 @@ class SettingsRepo(private val manager: LocationManager, private val preferences
private val _passesSettings = MutableStateFlow(getPassesSettings()) private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun getSelectedModes(): List<String> { override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
val selectedModesString = preferences.getString(keySelectedModes, null) putInt(keyFilterHoursAhead, settings.hoursAhead)
return selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
} putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
override fun setSelectedModes(modes: List<String>) {
val selectedModes = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedModes, selectedModes) }
}
override fun updatePassesSettings(settings: PassesSettings) = preferences.edit {
putInt(keyFilterHoursAhead, settings.filterHoursAhead)
putLong(keyFilterMinElevation, settings.filterMinElevation.toRawBits())
_passesSettings.value = settings _passesSettings.value = settings
} }
private fun getPassesSettings(): PassesSettings { private fun getPassesSettings(): PassesSettings {
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
return PassesSettings(hoursAhead, minElevation) val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(hoursAhead, minElevation, selectedModes)
} }
//endregion //endregion
@@ -1,24 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2022 Arty Bishop (bishop.arty@gmail.com)
*
* 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.domain.model
sealed class DataState<out T> {
data class Success<out T>(val data: T) : DataState<T>()
data class Exception(val message: String?) : DataState<Nothing>()
data object Loading : DataState<Nothing>()
}
@@ -2,7 +2,7 @@ package com.rtbishop.look4sat.domain.model
data class DatabaseState(val numberOfRadios: Int, val numberOfSatellites: Int, val updateTimestamp: Long) data class DatabaseState(val numberOfRadios: Int, val numberOfSatellites: Int, val updateTimestamp: Long)
data class PassesSettings(val filterHoursAhead: Int, val filterMinElevation: Double) data class PassesSettings(val hoursAhead: Int, val minElevation: Double, val selectedModes: List<String>)
data class OtherSettings( data class OtherSettings(
val stateOfAutoUpdate: Boolean, val stateOfAutoUpdate: Boolean,
@@ -18,10 +18,7 @@
package com.rtbishop.look4sat.domain.repository package com.rtbishop.look4sat.domain.repository
interface IDatabaseRepo { interface IDatabaseRepo {
suspend fun updateFromFile(uri: String) suspend fun updateFromFile(uri: String)
suspend fun updateFromRemote() suspend fun updateFromRemote()
suspend fun clearAllData() suspend fun clearAllData()
} }
@@ -8,24 +8,13 @@ import com.rtbishop.look4sat.domain.predict.Satellite
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo { interface ISatelliteRepo {
val passes: StateFlow<List<SatPass>> val passes: StateFlow<List<SatPass>>
val satellites: StateFlow<List<Satellite>> val satellites: StateFlow<List<Satellite>>
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun initRepository() suspend fun initRepository()
suspend fun getPosition(sat: Satellite, pos: GeoPos, time: Long): SatPos suspend fun getPosition(sat: Satellite, pos: GeoPos, time: Long): SatPos
suspend fun getTrack(sat: Satellite, pos: GeoPos, start: Long, end: Long): List<SatPos> suspend fun getTrack(sat: Satellite, pos: GeoPos, start: Long, end: Long): List<SatPos>
suspend fun getRadios(sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processRadios(
sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long
): List<SatRadio>
suspend fun processPasses(passList: List<SatPass>, time: Long): List<SatPass> suspend fun processPasses(passList: List<SatPass>, time: Long): List<SatPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int = 8, minElevation: Double = 16.0) suspend fun calculatePasses(time: Long, hoursAhead: Int = 8, minElevation: Double = 16.0)
} }
@@ -4,20 +4,12 @@ import com.rtbishop.look4sat.domain.model.SatItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ISelectionRepo { interface ISelectionRepo {
fun getCurrentType(): String fun getCurrentType(): String
fun getTypesList(): List<String> fun getTypesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>> suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setType(type: String) suspend fun setType(type: String)
suspend fun setQuery(query: String) suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean) suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean) suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
suspend fun saveSelection() suspend fun saveSelection()
} }
@@ -63,9 +63,7 @@ interface ISettingsRepo {
//region # Passes filter settings //region # Passes filter settings
val passesSettings: StateFlow<PassesSettings> val passesSettings: StateFlow<PassesSettings>
fun getSelectedModes(): List<String> fun setPassesSettings(settings: PassesSettings)
fun setSelectedModes(modes: List<String>)
fun updatePassesSettings(settings: PassesSettings)
//endregion //endregion
//region # Station position settings //region # Station position settings
@@ -23,22 +23,13 @@ import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.Satellite import com.rtbishop.look4sat.domain.predict.Satellite
interface ILocalSource { interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<Satellite> suspend fun getEntriesWithIds(ids: List<Int>): List<Satellite>
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>) suspend fun insertRadios(radios: List<SatRadio>)
suspend fun deleteRadios() suspend fun deleteRadios()
} }
@@ -3,10 +3,7 @@ package com.rtbishop.look4sat.domain.source
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface ISensorSource { interface ISensorSource {
val orientation: StateFlow<Pair<Float, Float>> val orientation: StateFlow<Pair<Float, Float>>
fun enableSensor() fun enableSensor()
fun disableSensor() fun disableSensor()
} }