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