Added multiple tweaks to Satellites, Passes and Map screens

This commit is contained in:
Arty Bishop committed 2026-04-10 11:50:32 +01:00
1 parent 2df0b9ba3d
commit aadc285d7f
12 files changed
+352 -328

No files matched your search

@@ -21,10 +21,11 @@ import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
}
@@ -115,15 +115,12 @@ fun NavGraphBuilder.mapDestination() {
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, mapView)
MapScreen(uiState, viewModel::onAction, mapView)
}
}
@Composable
private fun MapScreen(uiState: MapState, mapView: MapView) {
val onItemClick = { item: OrbitalObject -> uiState.sendAction(MapAction.SelectItem(item)) }
val selectPrev = { uiState.sendAction(MapAction.SelectPrev) }
val selectNext = { uiState.sendAction(MapAction.SelectNext) }
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
@@ -136,17 +133,17 @@ private fun MapScreen(uiState: MapState, mapView: MapView) {
val isVertical = isVerticalLayout()
if (isVertical) {
TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
}
TopBar { NextPassRow(pass = uiState.orbitalPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = selectPrev, resId = R.drawable.ic_arrow, modifier = rotateMod)
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
NextPassRow(pass = uiState.orbitalPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = selectNext, resId = R.drawable.ic_arrow)
IconCard(action = { onAction(MapAction.SelectNext) }, resId = R.drawable.ic_arrow)
}
}
ElevatedCard(modifier = Modifier.weight(1f)) {
@@ -155,7 +152,7 @@ private fun MapScreen(uiState: MapState, mapView: MapView) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view, onItemClick) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
view.invalidate()
}
uiState.mapData?.let { mapData ->
@@ -369,7 +366,7 @@ private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
try {
val rangeCircle = orbitalPos.getRangeCircle()
// Lazily initialise the reusable point list and polyline
// Lazily initialize the reusable point list and polyline
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
@@ -23,22 +23,21 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState(
val mapData: MapData?,
val isLightUi: Boolean,
val isUtc: Boolean,
val stationPosition: GeoPos?,
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>?,
val footprint: OrbitalPos?,
val positions: Map<OrbitalObject, GeoPos>?,
val sendAction: (MapAction) -> Unit
val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null,
val positions: Map<OrbitalObject, GeoPos>? = null
)
sealed class MapAction {
data object SelectPrev: MapAction()
data object SelectNext: MapAction()
data class SelectItem(val item: OrbitalObject): MapAction()
data class SelectDefaultItem(val catnum: Int): MapAction()
data object SelectPrev : MapAction()
data object SelectNext : MapAction()
data class SelectItem(val item: OrbitalObject) : MapAction()
data class SelectDefaultItem(val catnum: Int) : MapAction()
}
data class MapData(
@@ -56,15 +56,9 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow(
MapState(
mapData = null,
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
stationPosition = null,
orbitalPass = defaultPass,
track = null,
footprint = null,
positions = null,
sendAction = ::handleAction
orbitalPass = defaultPass
)
)
private var allPasses = satelliteRepo.passes.value
@@ -83,7 +77,7 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
selectDefaultSatellite(-1)
}
private fun handleAction(action: MapAction) {
fun onAction(action: MapAction) {
when (action) {
MapAction.SelectPrev -> scrollSelection(true)
MapAction.SelectNext -> scrollSelection(false)
@@ -215,14 +209,19 @@ class MapViewModel(private val satelliteRepo: ISatelliteRepo, private val settin
?.let { satPass ->
orbitalPass = satPass
if (!satPass.isDeepSpace) {
aosTime = if (date.time < satPass.aosTime) {
val millisBeforeStart = satPass.aosTime.minus(date.time)
isTimeAos = true
millisBeforeStart.toTimerString()
} else {
val millisBeforeEnd = satPass.losTime.minus(date.time)
isTimeAos = false
millisBeforeEnd.toTimerString()
when {
date.time < satPass.aosTime -> {
// Pass hasn't started yet — count down to AOS
isTimeAos = true
aosTime = (satPass.aosTime - date.time).toTimerString()
}
date.time < satPass.losTime -> {
// Pass is in progress — count down to LOS
isTimeAos = false
aosTime = (satPass.losTime - date.time).toTimerString()
}
// else: pass has ended (losTime <= date.time) — keep default "00:00:00"
}
}
}
@@ -32,12 +32,11 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -68,7 +67,7 @@ private fun PassesDialogPreview() {
}
@Composable
fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
internal fun PassesDialog(hours: Int, elevation: Double, cancel: () -> Unit, accept: (Int, Double) -> Unit) {
val hoursValue = remember { mutableIntStateOf(hours) }
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
val onAccept = {
@@ -140,12 +139,12 @@ private fun RadiosDialogPreview() {
}
@Composable
fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateListOf<String>().apply { addAll(modes) } }
val select = { mode: String ->
if (selected.contains(mode)) selected.remove(mode) else selected.add(mode)
internal fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit) {
val selected = remember { mutableStateOf(modes.toSet()) }
val toggle = { mode: String ->
selected.value = if (mode in selected.value) selected.value - mode else selected.value + mode
}
val onAccept = { accept(selected.toList()).also { cancel() } }
val onAccept = { accept(selected.value.toList()).also { cancel() } }
SharedDialog(title = stringResource(R.string.pass_modes_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
@@ -156,32 +155,30 @@ fun RadiosDialog(modes: List<String>, cancel: () -> Unit, accept: (List<String>)
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 + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
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(horizontal = 16.dp, vertical = 8.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { toggle(item) }
) {
Text(
text = "${index + 1}).",
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.primary
)
Text(
text = item,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = item in selected.value,
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
@@ -35,10 +35,10 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
@@ -86,27 +86,31 @@ fun NavGraphBuilder.passesDestination(navigateToRadar: (Int, Long) -> Unit) {
factory = PassesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, navigateToRadar)
PassesScreen(uiState, viewModel::onAction, navigateToRadar)
}
}
@Composable
private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> Unit) {
val refreshList = { uiState.takeAction(PassesAction.RefreshPasses) }
val showPassesDialog = { uiState.takeAction(PassesAction.TogglePassesDialog) }
val showRadiosDialog = { uiState.takeAction(PassesAction.ToggleRadiosDialog) }
private fun PassesScreen(
uiState: PassesState,
onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit
) {
if (uiState.isPassesDialogShown) {
PassesDialog(
hours = uiState.hours,
elevation = uiState.elevation,
cancel = showPassesDialog
cancel = { onAction(PassesAction.TogglePassesDialog) }
) { hours, elevation ->
uiState.takeAction(PassesAction.FilterPasses(hours, elevation))
onAction(PassesAction.FilterPasses(hours, elevation))
}
}
if (uiState.isRadiosDialogShown) {
RadiosDialog(modes = uiState.modes, cancel = showRadiosDialog) { modes ->
uiState.takeAction(PassesAction.FilterRadios(modes))
RadiosDialog(
modes = uiState.modes,
cancel = { onAction(PassesAction.ToggleRadiosDialog) }
) { modes ->
onAction(PassesAction.FilterRadios(modes))
}
}
if (uiState.shouldSeeWhatsNew) {
@@ -114,7 +118,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
title = stringResource(R.string.pass_whatsnew_title),
text = stringResource(R.string.pass_whatsnew_message)
) {
uiState.takeAction(PassesAction.DismissWhatsNew)
onAction(PassesAction.DismissWhatsNew)
}
}
val gridState = rememberLazyGridState()
@@ -123,7 +127,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
TopBar(
isVerticalLayout = isVerticalLayout,
startAction = {
IconCard(action = showPassesDialog, resId = R.drawable.ic_filter)
IconCard(action = { onAction(PassesAction.TogglePassesDialog) }, resId = R.drawable.ic_filter)
},
topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos)
@@ -132,7 +136,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
},
endAction = {
IconCard(action = showRadiosDialog, resId = R.drawable.ic_radios)
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
}
)
}
@@ -142,7 +146,7 @@ private fun PassesScreen(uiState: PassesState, navigateToRadar: (Int, Long) -> U
isUtc = uiState.isUtc,
passes = uiState.itemsList,
navigateToRadar = navigateToRadar,
refreshPasses = refreshList,
refreshPasses = { onAction(PassesAction.RefreshPasses) },
gridState = gridState
)
}
@@ -184,7 +188,7 @@ private fun PassesList(
modifier = Modifier.fillMaxSize()
) {
items(items = passes, key = { item -> item.catNum + item.aosTime }) { pass ->
NearEarthPass(
PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
@@ -204,7 +208,7 @@ private fun DeepSpacePassPreview() {
val data = OrbitalData("Satellite", 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = DeepSpaceObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
MainTheme { PassItem(pass = pass, { _, _ -> }) }
}
@Preview(showBackground = true)
@@ -213,11 +217,11 @@ private fun NearEarthPassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val satellite = NearEarthObject(data)
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f)
MainTheme { NearEarthPass(pass = pass, { _, _ -> }) }
MainTheme { PassItem(pass = pass, { _, _ -> }) }
}
@Composable
private fun NearEarthPass(
private fun PassItem(
pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit,
modifier: Modifier = Modifier,
@@ -235,118 +239,121 @@ private fun NearEarthPass(
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
}
// Cache formatted date/time strings — aosTime/losTime never change for a given pass
val aosDateStr = remember(pass.aosTime, isUtc) { sdfDate.format(Date(pass.aosTime)) }
val aosTimeStr = remember(pass.aosTime, isUtc) { sdfTime.format(Date(pass.aosTime)) }
val losTimeStr = remember(pass.losTime, isUtc) { sdfTime.format(Date(pass.losTime)) }
Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) {
Surface(modifier = Modifier
.padding(bottom = 2.dp)
.clickable { navigateToRadar(pass.catNum, pass.aosTime) }) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
) {
Column(
verticalArrangement = Arrangement.spacedBy(1.dp),
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text(
text = stringResource(R.string.pass_deep_space),
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(text = aosDateStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp)
} else {
Text(
text = aosDateStr,
fontSize = 15.sp
)
}
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.altitude} km",
fontSize = 15.sp
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
fontSize = 15.sp
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = Modifier.fillMaxWidth(0.75f)
)
Text(
text = if (pass.isDeepSpace) defaultTime else losTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
}
}
@@ -20,19 +20,18 @@ package com.rtbishop.look4sat.feature.passes
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
data class PassesState(
val isPassesDialogShown: Boolean,
val isRadiosDialogShown: Boolean,
val isRefreshing: Boolean,
val isUtc: Boolean,
val isPassesDialogShown: Boolean = false,
val isRadiosDialogShown: Boolean = false,
val isRefreshing: Boolean = true,
val isUtc: Boolean = false,
val nextPass: OrbitalPass,
val nextTime: String,
val isNextTimeAos: Boolean,
val hours: Int,
val elevation: Double,
val modes: List<String>,
val itemsList: List<OrbitalPass>,
val shouldSeeWhatsNew: Boolean,
val takeAction: (PassesAction) -> Unit
val nextTime: String = "00:00:00",
val isNextTimeAos: Boolean = true,
val hours: Int = 24,
val elevation: Double = 16.0,
val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(),
val shouldSeeWhatsNew: Boolean = false
)
sealed class PassesAction {
@@ -46,30 +46,24 @@ class PassesViewModel(
private val defaultPass = getDefaultPass()
private val _uiState = MutableStateFlow(
PassesState(
isPassesDialogShown = false,
isRadiosDialogShown = false,
isRefreshing = true,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
nextTime = "00:00:00",
isNextTimeAos = true,
nextPass = defaultPass,
hours = settingsRepo.passesSettings.value.hoursAhead,
elevation = settingsRepo.passesSettings.value.minElevation,
modes = settingsRepo.passesSettings.value.selectedModes,
itemsList = emptyList(),
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew,
takeAction = ::handleAction
shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew
)
)
val uiState: StateFlow<PassesState> = _uiState
init {
// React to raw pass list changes (initial load, recalculation, filter change)
// React to raw pass list changes — stops the refreshing indicator on first emission
viewModelScope.launch {
var initialLoadDone = false
satelliteRepo.passes.collectLatest { passes ->
if (!initialLoadDone && passes.isNotEmpty()) {
satelliteRepo.passes.collectLatest { _ ->
if (!initialLoadDone) {
initialLoadDone = true
delay(1000) // Artificial delay to show the refreshing state on first load
_uiState.update { it.copy(isRefreshing = false) }
}
}
@@ -79,13 +73,13 @@ class PassesViewModel(
while (isActive) {
val timeNow = System.currentTimeMillis()
val processed = computePassProgress(satelliteRepo.passes.value, timeNow)
val nextInfo = resolveNextPass(processed, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextInfo.first,
nextTime = nextInfo.second,
isNextTimeAos = nextInfo.third
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
@@ -103,6 +97,17 @@ class PassesViewModel(
}
}
fun onAction(action: PassesAction) {
when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, _uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(_uiState.value.hours, _uiState.value.elevation, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog -> _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
}
}
/** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size)
@@ -129,28 +134,17 @@ class PassesViewModel(
passes: List<OrbitalPass>,
timeNow: Long
): Triple<OrbitalPass, String, Boolean> {
val upcoming = passes.firstOrNull { it.aosTime.minus(timeNow) > 0 }
val upcoming = passes.firstOrNull { it.aosTime > timeNow }
if (upcoming != null) {
return Triple(upcoming, upcoming.aosTime.minus(timeNow).toTimerString(), true)
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true)
}
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, lastPass.losTime.minus(timeNow).toTimerString(), false)
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
}
private fun handleAction(action: PassesAction) {
when (action) {
PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed()
is PassesAction.FilterPasses -> applyFilter(action.hoursAhead, action.minElevation, uiState.value.modes)
is PassesAction.FilterRadios -> applyFilter(uiState.value.hours, uiState.value.elevation, action.modes)
PassesAction.RefreshPasses -> refreshPasses()
PassesAction.TogglePassesDialog -> toggleFilterDialog()
PassesAction.ToggleRadiosDialog -> toggleRadiosDialog()
}
}
private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List<String>) = viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes))
@@ -166,15 +160,6 @@ class PassesViewModel(
_uiState.update { it.copy(isRefreshing = false) }
}
private fun toggleFilterDialog() {
val currentDialogState = _uiState.value.isPassesDialogShown
_uiState.update { it.copy(isPassesDialogShown = currentDialogState.not()) }
}
private fun toggleRadiosDialog() {
val currentDialogState = _uiState.value.isRadiosDialogShown
_uiState.update { it.copy(isRadiosDialogShown = currentDialogState.not()) }
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
@@ -30,7 +30,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -51,14 +51,14 @@ private fun MultiTypesDialogPreview() {
}
@Composable
fun MultiTypesDialog(
internal fun MultiTypesDialog(
allTypes: List<String>, types: List<String>, cancel: () -> Unit, accept: (List<String>) -> Unit
) {
val selected = remember { mutableStateListOf<String>().apply { addAll(types) } }
val select = { type: String ->
if (selected.contains(type)) selected.remove(type) else selected.add(type)
val selected = remember { mutableStateOf(types.toSet()) }
val toggle = { type: String ->
selected.value = if (type in selected.value) selected.value - type else selected.value + type
}
val onAccept = { accept(selected.toList()) }
val onAccept = { accept(selected.value.toList()) }
SharedDialog(title = stringResource(R.string.sat_type_title), onCancel = cancel, onAccept = onAccept) {
LazyVerticalGrid(
columns = GridCells.Adaptive(240.dp),
@@ -73,7 +73,7 @@ fun MultiTypesDialog(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.clickable { select(item) }
.clickable { toggle(item) }
) {
Text(
text = "${index + 1}).",
@@ -89,7 +89,7 @@ fun MultiTypesDialog(
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = selected.contains(item),
checked = item in selected.value,
onCheckedChange = null,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
@@ -32,10 +33,10 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
@@ -77,54 +78,97 @@ fun NavGraphBuilder.satellitesDestination(navigateUp: () -> Unit) {
modelClass = SatellitesViewModel::class.java, factory = SatellitesViewModel.Factory
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, navigateUp)
SatellitesScreen(uiState, viewModel::onAction, navigateUp)
}
}
@Composable
private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
val toggleDialog = { uiState.takeAction(SatellitesAction.ToggleTypesDialog) }
private fun SatellitesScreen(
uiState: SatellitesState,
onAction: (SatellitesAction) -> Unit,
navigateUp: () -> Unit
) {
if (uiState.isDialogShown) {
MultiTypesDialog(allTypes = uiState.typesList, types = uiState.currentTypes, toggleDialog) {
uiState.takeAction(SatellitesAction.SelectTypes(it))
}
MultiTypesDialog(
allTypes = uiState.typesList,
types = uiState.currentTypes,
cancel = { onAction(SatellitesAction.ToggleTypesDialog) },
accept = { onAction(SatellitesAction.SelectTypes(it)) }
)
}
if (uiState.shouldSeeWarning) {
InfoDialog(
stringResource(R.string.sat_warning_title),
stringResource(R.string.sat_warning_message)
) {
uiState.takeAction(SatellitesAction.DismissWarning)
onAction(SatellitesAction.DismissWarning)
}
}
val unselectAll = { uiState.takeAction(SatellitesAction.UnselectAll) }
val selectAll = { uiState.takeAction(SatellitesAction.SelectAll) }
val setQuery = { newQuery: String -> uiState.takeAction(SatellitesAction.SearchFor(newQuery)) }
val saveSelection = { uiState.takeAction(SatellitesAction.SaveSelection).also { navigateUp() } }
val saveAndNavigateUp = {
onAction(SatellitesAction.SaveSelection)
navigateUp()
}
val primCardCd = stringResource(R.string.btn_accept)
val primCardMod = Modifier.semantics { contentDescription = primCardCd }
val clearAllCd = stringResource(R.string.sat_clear_all)
val clearAllMod = Modifier.semantics { contentDescription = clearAllCd }
val selectAllCd = stringResource(R.string.sat_select_all)
val selectAllMod = Modifier.semantics { contentDescription = selectAllCd }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (isVerticalLayout()) {
TopBar {
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
modifier = Modifier.weight(1f)
)
PrimaryIconCard(
onClick = saveAndNavigateUp,
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
}
TopBar {
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
SearchBar(
onQueryChange = { onAction(SatellitesAction.SearchFor(it)) },
modifier = Modifier.weight(1f)
)
IconCard(
action = { onAction(SatellitesAction.UnselectAll) },
resId = R.drawable.ic_check_off,
modifier = Modifier.semantics { contentDescription = clearAllCd }
)
IconCard(
action = { onAction(SatellitesAction.SelectAll) },
resId = R.drawable.ic_check_on,
modifier = Modifier.semantics { contentDescription = selectAllCd }
)
}
} else {
TopBar {
PrimaryIconCard(onClick = saveSelection, resId = R.drawable.ic_done, modifier = primCardMod)
TypeCard(types = uiState.currentTypes, toggleDialog, modifier = Modifier.weight(1f))
SearchBar(setQuery = { setQuery(it) }, modifier = Modifier.weight(1f))
IconCard(action = unselectAll, resId = R.drawable.ic_check_off, modifier = clearAllMod)
IconCard(action = selectAll, resId = R.drawable.ic_check_on, modifier = selectAllMod)
PrimaryIconCard(
onClick = saveAndNavigateUp,
resId = R.drawable.ic_done,
modifier = Modifier.semantics { contentDescription = primCardCd }
)
TypeCard(
types = uiState.currentTypes,
onClick = { onAction(SatellitesAction.ToggleTypesDialog) },
modifier = Modifier.weight(1f)
)
SearchBar(
onQueryChange = { onAction(SatellitesAction.SearchFor(it)) },
modifier = Modifier.weight(1f)
)
IconCard(
action = { onAction(SatellitesAction.UnselectAll) },
resId = R.drawable.ic_check_off,
modifier = Modifier.semantics { contentDescription = clearAllCd }
)
IconCard(
action = { onAction(SatellitesAction.SelectAll) },
resId = R.drawable.ic_check_on,
modifier = Modifier.semantics { contentDescription = selectAllCd }
)
}
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
@@ -133,7 +177,7 @@ private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
uiState.isLoading -> CardLoadingIndicator()
uiState.itemsList.isEmpty() -> EmptyListCard(message = emptyMessage)
else -> SatellitesCard(uiState.itemsList) { id, isTicked ->
uiState.takeAction(SatellitesAction.SelectSingle(id, isTicked))
onAction(SatellitesAction.SelectSingle(id, isTicked))
}
}
}
@@ -141,11 +185,11 @@ private fun SatellitesScreen(uiState: SatellitesState, navigateUp: () -> Unit) {
}
@Composable
private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier) {
private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val setNewQuery = { newValue: String ->
val updateQuery = { newValue: String ->
currentQuery.value = newValue
setQuery(newValue)
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
@@ -156,9 +200,11 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = { setNewQuery(it) },
onValueChange = updateQuery,
singleLine = true,
modifier = modifier.padding(start = 12.dp),
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
@@ -179,7 +225,7 @@ private fun SearchBar(setQuery: (String) -> Unit, modifier: Modifier = Modifier)
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { setNewQuery("") }) {
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
@@ -220,39 +266,40 @@ private fun TypeCard(types: List<String>, onClick: () -> Unit, modifier: Modifie
@Composable
private fun SatellitePreview() {
val satItem = SatItem(44444, "Ultra Super Mega long satellite name", true)
MainTheme { Satellite(item = satItem, onSelected = { _, _ -> run {} }, modifier = Modifier) }
MainTheme { Satellite(item = satItem, onSelected = { _, _ -> }) }
}
@Composable
private fun Satellite(item: SatItem, onSelected: (Int, Boolean) -> Unit, modifier: Modifier) {
private fun Satellite(
item: SatItem,
onSelected: (Int, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val passSatId = stringResource(id = R.string.pass_satId, item.catnum)
Surface(
color = MaterialTheme.colorScheme.background,
modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
Surface(modifier = Modifier.padding(bottom = 1.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface)
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
) {
Text(
text = "$passSatId - ", color = MaterialTheme.colorScheme.primary
)
Text(
text = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Checkbox(
checked = item.isSelected,
onCheckedChange = null,
modifier = Modifier.padding(start = 6.dp)
)
}
Column(modifier = modifier.clickable { onSelected(item.catnum, item.isSelected) }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(start = 14.dp, top = 8.dp, end = 12.dp, bottom = 8.dp)
) {
Text(text = "$passSatId - ", color = MaterialTheme.colorScheme.primary)
Text(
text = item.name,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Checkbox(
checked = item.isSelected,
onCheckedChange = null,
modifier = Modifier.padding(start = 6.dp)
)
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.background)
}
}
@@ -264,11 +311,11 @@ private fun SatellitesPreview() {
SatItem(44444, "ISS", true),
SatItem(88888, "Starlink", false)
)
MainTheme { SatellitesCard(entries) { _, _ -> run {} } }
MainTheme { SatellitesCard(entries) { _, _ -> } }
}
@Composable
fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
@@ -20,13 +20,12 @@ package com.rtbishop.look4sat.feature.satellites
import com.rtbishop.look4sat.core.domain.model.SatItem
data class SatellitesState(
val isDialogShown: Boolean,
val isLoading: Boolean,
val shouldSeeWarning: Boolean,
val itemsList: List<SatItem>,
val currentTypes: List<String>,
val typesList: List<String>,
val takeAction: (SatellitesAction) -> Unit
val isDialogShown: Boolean = false,
val isLoading: Boolean = true,
val shouldSeeWarning: Boolean = false,
val itemsList: List<SatItem> = emptyList(),
val currentTypes: List<String> = emptyList(),
val typesList: List<String> = emptyList()
)
sealed class SatellitesAction {
@@ -25,7 +25,6 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -40,20 +39,16 @@ class SatellitesViewModel(
private val defaultTypes = selectionRepo.getCurrentTypes()
private val _uiState = MutableStateFlow(
SatellitesState(
isDialogShown = false,
isLoading = true,
shouldSeeWarning = settingsRepo.otherSettings.value.shouldSeeWarning,
itemsList = emptyList(),
currentTypes = defaultTypes,
typesList = selectionRepo.getTypesList(),
takeAction = ::handleAction
typesList = selectionRepo.getTypesList()
)
)
val uiState: StateFlow<SatellitesState> = _uiState
init {
viewModelScope.launch {
delay(1000)
selectionRepo.setQuery(String())
selectionRepo.setTypes(defaultTypes)
selectionRepo.getEntriesFlow().collectLatest { items ->
@@ -67,7 +62,7 @@ class SatellitesViewModel(
}
}
private fun handleAction(action: SatellitesAction) {
fun onAction(action: SatellitesAction) {
when (action) {
SatellitesAction.DismissWarning -> settingsRepo.setWarningDismissed()
SatellitesAction.SaveSelection -> saveSelection()
@@ -94,12 +89,11 @@ class SatellitesViewModel(
private fun selectTypes(types: List<String>) = viewModelScope.launch {
selectionRepo.setTypes(types)
_uiState.value = _uiState.value.copy(currentTypes = types, isDialogShown = false)
_uiState.update { it.copy(currentTypes = types, isDialogShown = false) }
}
private fun toggleTypesDialog() {
val currentDialogState = _uiState.value.isDialogShown
_uiState.value = _uiState.value.copy(isDialogShown = currentDialogState.not())
_uiState.update { it.copy(isDialogShown = !it.isDialogShown) }
}
companion object {