From 6d2b0ced49c0f22bc45a1a81b3f0f6945e340f32 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Wed, 22 Jul 2026 13:29:52 +0200 Subject: [PATCH] Implemented AOS window and elevation highlight filters --- .../java/com/rtbishop/look4sat/MainScreen.kt | 221 ++++++++-------- .../core/data/repository/SatelliteRepo.kt | 47 +++- .../core/data/repository/SettingsRepo.kt | 27 +- .../look4sat/core/domain/model/Settings.kt | 7 +- .../core/domain/repository/ISatelliteRepo.kt | 10 +- .../look4sat/core/presentation/Components.kt | 130 +++++++--- .../src/main/res/values-es/strings.xml | 11 +- .../src/main/res/values-ru/strings.xml | 11 +- .../src/main/res/values-si/strings.xml | 11 +- .../src/main/res/values-tr/strings.xml | 11 +- .../src/main/res/values-uk/strings.xml | 15 +- .../src/main/res/values-zh/strings.xml | 10 +- .../src/main/res/values/strings.xml | 9 +- .../look4sat/feature/passes/PassesDialog.kt | 240 ++++++++++++++++-- .../look4sat/feature/passes/PassesScreen.kt | 45 +++- .../look4sat/feature/passes/PassesState.kt | 16 +- .../feature/passes/PassesViewModel.kt | 90 ++++++- .../look4sat/feature/radar/SstvPage.kt | 83 +++--- .../feature/satellites/SatellitesScreen.kt | 20 +- .../feature/settings/SettingsDialog.kt | 15 +- 20 files changed, 777 insertions(+), 252 deletions(-) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index cab6e3c9..0efa0fe2 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaul import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -70,6 +71,8 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.DeeplinkResolver +import com.rtbishop.look4sat.core.presentation.ElevationThresholds +import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds import com.rtbishop.look4sat.core.presentation.RadarDestination import com.rtbishop.look4sat.core.presentation.Screen import com.rtbishop.look4sat.core.presentation.hasEnoughHeight @@ -127,120 +130,128 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() + val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() - NavigationSuiteScaffold( - navigationSuiteItems = { - navItems.forEach { screen -> - val isSelected = when (currentKey) { - is Screen.Satellites -> screen is Screen.Satellites - is Screen.Passes -> screen is Screen.Passes - is Screen.Radar -> screen is Screen.Radar - is Screen.Map -> screen is Screen.Map - is Screen.Settings -> screen is Screen.Settings - else -> false - } - item( - icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, - label = { Text(stringResource(screen.titleResId)) }, - selected = isSelected, - onClick = { - if (isSelected) return@item - while (backStack.size > 1) backStack.removeAt(backStack.size - 1) - if (screen !is Screen.Passes) backStack.add(screen) - } - ) - } - }, - navigationSuiteColors = NavigationSuiteDefaults.colors( - navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer - ), - layoutType = when { - !hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail - !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact - else -> NavigationSuiteType.ShortNavigationBarMedium - } + CompositionLocalProvider( + LocalElevationThresholds provides ElevationThresholds( + low = otherSettings.lowElevation, + high = otherSettings.highElevation + ) ) { - Column { - NavDisplay( - backStack = backStack, - modifier = Modifier.weight(1f), - onBack = navigateBack, - transitionSpec = { fadeTransition }, - popTransitionSpec = { fadeTransition }, - predictivePopTransitionSpec = { fadeTransition }, - entryDecorators = listOf( - // Required for saving Compose state per entry - rememberSaveableStateHolderNavEntryDecorator(), - // Required for ViewModel scoping per entry - rememberViewModelStoreNavEntryDecorator() - ), - entryProvider = entryProvider { - entry { - SatellitesDestination(navigateUp = navigateBack) + NavigationSuiteScaffold( + navigationSuiteItems = { + navItems.forEach { screen -> + val isSelected = when (currentKey) { + is Screen.Satellites -> screen is Screen.Satellites + is Screen.Passes -> screen is Screen.Passes + is Screen.Radar -> screen is Screen.Radar + is Screen.Map -> screen is Screen.Map + is Screen.Settings -> screen is Screen.Settings + else -> false } - entry { - PassesDestination { catNum, aosTime -> - container.satelliteRepo.selectPass(catNum, aosTime) - backStack.add(Screen.Radar) -// navigateToRadar() + item( + icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, + label = { Text(stringResource(screen.titleResId)) }, + selected = isSelected, + onClick = { + if (isSelected) return@item + while (backStack.size > 1) backStack.removeAt(backStack.size - 1) + if (screen !is Screen.Passes) backStack.add(screen) } - } - entry { - RadarDestination(navigateUp = navigateBack) - } - entry { - MapDestination() - } - entry { - SettingsDestination() - } + ) } - ) - // Radio tracking status banner - if (trackingState.isActive) { - val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, targetValue = 0.4f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), label = "pulseAlpha" - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .clickable { - val pass = trackingState.currentPass - if (pass != null) { - container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) + }, + navigationSuiteColors = NavigationSuiteDefaults.colors( + navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer + ), + layoutType = when { + !hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail + !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact + else -> NavigationSuiteType.ShortNavigationBarMedium + } + ) { + Column { + NavDisplay( + backStack = backStack, + modifier = Modifier.weight(1f), + onBack = navigateBack, + transitionSpec = { fadeTransition }, + popTransitionSpec = { fadeTransition }, + predictivePopTransitionSpec = { fadeTransition }, + entryDecorators = listOf( + // Required for saving Compose state per entry + rememberSaveableStateHolderNavEntryDecorator(), + // Required for ViewModel scoping per entry + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = entryProvider { + entry { + SatellitesDestination(navigateUp = navigateBack) + } + entry { + PassesDestination { catNum, aosTime -> + container.satelliteRepo.selectPass(catNum, aosTime) backStack.add(Screen.Radar) +// navigateToRadar() } } - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Box( + entry { + RadarDestination(navigateUp = navigateBack) + } + entry { + MapDestination() + } + entry { + SettingsDestination() + } + } + ) + // Radio tracking status banner + if (trackingState.isActive) { + val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), label = "pulseAlpha" + ) + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50).copy(alpha = alpha)) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Tracking: ${trackingState.currentPass?.name ?: ""}", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.weight(1f) - ) - val txOk = if (trackingState.txConnected) "TX" else "" - val rxOk = if (trackingState.rxConnected) "RX" else "" - Text( - text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable { + val pass = trackingState.currentPass + if (pass != null) { + container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) + backStack.add(Screen.Radar) + } + } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50).copy(alpha = alpha)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Tracking: ${trackingState.currentPass?.name ?: ""}", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.weight(1f) + ) + val txOk = if (trackingState.txConnected) "TX" else "" + val rxOk = if (trackingState.rxConnected) "RX" else "" + Text( + text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index 18a6bbd7..6c9c0c01 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import java.util.TimeZone class SatelliteRepo( private val dispatcher: CoroutineDispatcher, @@ -64,8 +65,16 @@ class SatelliteRepo( override suspend fun initRepository() = withContext(dispatcher) { settingsRepo.selectedIds.collect { selectedIds -> _satellites.update { localStorage.getEntriesWithIds(selectedIds) } - val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value - calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) + val settings = settingsRepo.passesSettings.value + calculatePasses( + time = System.currentTimeMillis(), + hoursAhead = settings.hoursAhead, + minElevation = settings.minElevation, + aosStartMinute = settings.aosStartMinute, + aosEndMinute = settings.aosEndMinute, + invertAosTimeWindow = settings.invertAosTimeWindow, + modes = settings.selectedModes + ) } } @@ -105,7 +114,15 @@ class SatelliteRepo( } } - override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List) { + override suspend fun calculatePasses( + time: Long, + hoursAhead: Int, + minElevation: Double, + aosStartMinute: Int, + aosEndMinute: Int, + invertAosTimeWindow: Boolean, + modes: List + ) { _isCalculating.value = true // Normalize to the start of the current minute so that coarse 60-second stepping // in getLeoPass always begins from the same phase, producing stable AOS/LOS times @@ -130,7 +147,12 @@ class SatelliteRepo( val newPasses = ArrayList() for (list in passLists) { for (pass in list) { - if (pass.losTime > time && pass.aosTime < timeFuture && pass.maxElevation > minElevation) { + if ( + pass.losTime > time + && pass.aosTime < timeFuture + && pass.maxElevation > minElevation + && (pass.isDeepSpace || isAosInRange(pass.aosTime, aosStartMinute, aosEndMinute, invertAosTimeWindow)) + ) { newPasses.add(pass) } } @@ -142,6 +164,23 @@ class SatelliteRepo( _isCalculating.value = false } + private fun isAosInRange( + aosTime: Long, + aosStartMinute: Int, + aosEndMinute: Int, + invertAosTimeWindow: Boolean + ): Boolean { + val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong() + val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L) + val aosMinute = (localMillis / 60_000L).toInt() + val inRange = if (aosStartMinute <= aosEndMinute) { + aosMinute in aosStartMinute..aosEndMinute + } else { + aosMinute >= aosStartMinute || aosMinute <= aosEndMinute + } + return if (invertAosTimeWindow) !inRange else inRange + } + private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List { val passes = mutableListOf() val endDate = time + hours * 60L * 60L * 1000L diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 0b54975d..ccc34102 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -54,6 +54,9 @@ class SettingsRepo( private val keyFilterShowDeepSpace = "filterShowDeepSpace" private val keyFilterHoursAhead = "filterHoursAhead" private val keyFilterMinElevation = "filterMinElevation" + private val keyFilterAosStartMinute = "filterAosStartMinute" + private val keyFilterAosEndMinute = "filterAosEndMinute" + private val keyFilterAosInvert = "filterAosInvert" private val keyNumberOfRadios = "numberOfRadios" private val keyNumberOfSatellites = "numberOfSatellites" private val keyRotatorAddress = "rotatorAddress" @@ -82,6 +85,8 @@ class SettingsRepo( private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keySstvMode = "sstvMode" + private val keyLowElevation = "lowElevation" + private val keyHighElevation = "highElevation" private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTleUrl = "tleUrl" @@ -127,6 +132,9 @@ class SettingsRepo( putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace) putInt(keyFilterHoursAhead, settings.hoursAhead) putLong(keyFilterMinElevation, settings.minElevation.toRawBits()) + putInt(keyFilterAosStartMinute, settings.aosStartMinute) + putInt(keyFilterAosEndMinute, settings.aosEndMinute) + putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow) putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma)) _passesSettings.value = settings } @@ -135,9 +143,20 @@ class SettingsRepo( val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true) val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24) val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits())) + val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59) + val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59) + val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false) val selectedModesString = preferences.getString(keySelectedModes, null) val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList() - return PassesSettings(showDeepSpace, hoursAhead, minElevation, selectedModes) + return PassesSettings( + showDeepSpace, + hoursAhead, + minElevation, + aosStartMinute, + aosEndMinute, + invertAosTimeWindow, + selectedModes + ) } //endregion @@ -335,6 +354,8 @@ class SettingsRepo( putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putString(keySstvMode, new.sstvMode) + putLong(keyLowElevation, new.lowElevation.toRawBits()) + putLong(keyHighElevation, new.highElevation.toRawBits()) } new } @@ -349,7 +370,9 @@ class SettingsRepo( stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), - sstvMode = preferences.getString(keySstvMode, null) ?: "Auto" + sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", + lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())), + highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())) ) //endregion diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index edf70dd0..ef830eb4 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -27,6 +27,9 @@ data class PassesSettings( val showDeepSpace: Boolean = true, val hoursAhead: Int, val minElevation: Double, + val aosStartMinute: Int = 0, + val aosEndMinute: Int = 23 * 60 + 59, + val invertAosTimeWindow: Boolean = false, val selectedModes: List ) @@ -57,7 +60,9 @@ data class OtherSettings( val stateOfNightMode: Boolean = false, val shouldSeeWarning: Boolean, val shouldSeeWhatsNew: Boolean, - val sstvMode: String = "Auto" + val sstvMode: String = "Auto", + val lowElevation: Double = 15.0, + val highElevation: Double = 45.0 ) data class DataSourcesSettings( diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt index 5ab4ba17..99d31474 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt @@ -44,7 +44,15 @@ interface ISatelliteRepo { suspend fun initRepository() /** Recalculate passes with the given filter parameters. */ - suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List) + suspend fun calculatePasses( + time: Long, + hoursAhead: Int, + minElevation: Double, + aosStartMinute: Int, + aosEndMinute: Int, + invertAosTimeWindow: Boolean, + modes: List + ) /** Get the current position of a single satellite. */ suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index c06ca405..cb0a3b65 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -39,18 +39,26 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedButton import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.hideFromAccessibility @@ -64,10 +72,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import com.rtbishop.look4sat.core.domain.predict.NearEarthObject import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalPass @@ -278,55 +287,87 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass( fun SharedDialog( title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit ) { - DialogShell(title = title, titleFontSize = 16, onDismissRequest = onCancel) { + SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ -> content() - Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) { - CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel)) - Spacer(modifier = Modifier.weight(1f)) - CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) - } } } @Composable -fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) { - DialogShell(title = title, titleFontSize = 18, onDismissRequest = {}) { - Text( - text = text, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = it) - ) - Row(modifier = Modifier.padding(start = it, bottom = it, end = it)) { - Spacer(modifier = Modifier.weight(1f)) - CardButton(onClick = onDismiss, text = stringResource(R.string.btn_accept)) +fun SharedDialog( + title: String, + onDismissRequest: () -> Unit, + onCancel: (() -> Unit)? = null, + onAccept: (() -> Unit)? = null, + titleFontSize: Int = 16, + titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start, + content: @Composable (padding: Dp) -> Unit +) { + DialogShell(onDismissRequest = onDismissRequest) { padding -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, top = padding, end = padding) + ) { + if (onCancel != null) { + CardButton(onClick = onCancel, text = stringResource(R.string.btn_cancel)) + } + Text( + text = title, + fontSize = titleFontSize.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + textAlign = titleTextAlign, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp) + ) + if (onAccept != null) { + CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) + } } + content(padding) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun DialogShell( - title: String, - titleFontSize: Int, onDismissRequest: () -> Unit, - content: @Composable (padding: androidx.compose.ui.unit.Dp) -> Unit + content: @Composable (padding: Dp) -> Unit ) { val padding = LocalSpacing.current.large - Dialog(onDismissRequest = onDismissRequest) { - ElevatedCard { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(padding) - ) { - Text( - text = title, - fontSize = titleFontSize.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = padding, top = padding, end = padding) - ) - content(padding) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val stopSheetFling = remember { + object : NestedScrollConnection { + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + return available } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset = Offset.Zero + } + } + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + shape = MaterialTheme.shapes.medium, + scrimColor = Color.Black.copy(alpha = 0.64f) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(padding), + modifier = Modifier + .fillMaxWidth() + .nestedScroll(stopSheetFling) + ) { + content(padding) } } } @@ -391,13 +432,26 @@ fun TopBar( @Composable fun elevationColor(elevation: Double): Color { + val thresholds = LocalElevationThresholds.current return when { - elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation - elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal - else -> Color(0xFF66BB6A) // soft green for high elevation + elevation < thresholds.low -> ElevationLowColor // soft red for low elevation + elevation < thresholds.high -> MaterialTheme.colorScheme.primary // accent yellow for normal + else -> ElevationHighColor // soft green for high elevation } } +/** User-configurable elevation highlight thresholds (in degrees). */ +data class ElevationThresholds(val low: Double = 15.0, val high: Double = 45.0) + +/** Provided at the app root from settings; defaults keep the original 15°/45° behavior. */ +val LocalElevationThresholds = compositionLocalOf { ElevationThresholds() } + +/** Soft red used for elevations below the low threshold. */ +val ElevationLowColor = Color(0xFFEF5350) + +/** Soft green used for elevations above the high threshold. */ +val ElevationHighColor = Color(0xFF66BB6A) + @Composable fun OutlinedText( text: String, diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index b2bb7db9..1d9bd834 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -31,7 +31,10 @@ Filtrar pases Elevación mínima - Horas por delante + Tiempo por delante + Ventana AOS + Invertir ventana AOS + DeepSpace (período >225min) Tipo de modulación Elevación: %.1f° Altitud: %.0f km @@ -139,6 +142,12 @@ Habilitar auto actualización de datos Habilitar animaciones radar Usar sensores para rotar vista de radar + Activar filtro nocturno rojo + + Resaltado de elevación + Baja < %1$d° + Media %1$d-%2$d° + Alta > %1$d° Me gustaría dar las gracias a: La app viene sin garantías de ningún tipo. diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index bb107c73..17031bf9 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -30,7 +30,10 @@ Фильтровать пролеты Минимальная элевация - Количество часов + Период расчёта + Окно AOS + Инвертировать окно AOS + DeepSpace (период >225мин) Выберите тип модуляции Элевация: %.1f° Высота: %d км @@ -138,6 +141,12 @@ Обновлять данные автоматически Показывать анимацию радара Использовать сенсоры устройства + Включить красный ночной фильтр + + Подсветка высоты + Низко < %1$d° + Средне %1$d-%2$d° + Высоко > %1$d° Я хотел бы сказать спасибо: Это ПО поставляется без гарантий. diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index ad77e225..44d1433b 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -31,7 +31,10 @@ පසුකිරිම් පිරිපහදුව අවම උත්තෝලනය - ඉදිරි පැය + ඉදිරි කාලය + AOS කවුළුව + AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න + DeepSpace (කාලය >225min) මූර්ජන ආකාරය තෝරන්න උත්තෝලනය: %.1f° උන්නතාශය: %d km @@ -139,6 +142,12 @@ ස්වයං දත්ත යාවත්කාලීනය radar sweep සජීවිකරණය සබල කරන්න Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න + රතු රාත්‍රී පෙරහන සබල කරන්න + + උස් උද්දීපනය + අඩු < %1$d° + මධ්‍ය %1$d-%2$d° + ඉහළ > %1$d° මම ස්තුති කිරීමට කැමති: මෘදුකාංගය වගකීමක් සමග නොලැබේ diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 071009d6..7e65fb7b 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -31,7 +31,10 @@ Geçişleri filtrele Minimum yükseklik açısı - Gösterilecek saat aralığı + Gösterilecek zaman + AOS aralığı + AOS aralığını tersine çevir + DeepSpace (periyot >225dk) -- : -- : -- Modulasyon türü seçin %05d @@ -182,6 +185,12 @@ Otomatik veri güncellemeyi etkinleştir Radar taramasını etkinleştir Radar görünümünü döndürmek için sensörleri kullan + Kırmızı gece filtresini etkinleştir + + Elevasyon vurgusu + Düşük < %1$d° + Orta %1$d-%2$d° + Yüksek > %1$d° Teşekkürler diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 8604a1b6..a6754918 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -30,8 +30,11 @@ Фільтр прольотів - Мінімальне піднесення - Кількість годин + Мінімальне піднесень + Час вперед + Вікно AOS + Інвертувати вікно AOS + DeepSpace (період >225хв) Виберіть тип модуляції Піднес.: %.1f° Висота: %.0f км @@ -66,7 +69,7 @@ Попередній Наступний Азимут: %.1f° - Піднесення: %.1f° + Піднесень: %.1f° Висота: %.0f км Дистанція: %.0f км Широта: %.1f° @@ -139,6 +142,12 @@ Автоматичне оновлення даних Показувати анімацію радара Використовувати сенсори пристрою + Увімкнути червоний нічний фільтр + + Підсвічування висоти + Низько < %1$d° + Середньо %1$d-%2$d° + Високо > %1$d° Я хотів би подякувати: Ця програма поставляється без жодних гарантій diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 8f69176a..50c0f6f5 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -26,7 +26,10 @@ 筛选过境 仰角值 - 小时数 + 提前时间 + AOS 时间段 + 反选 AOS 时间段 + DeepSpace(周期 >225分钟) 选择调制类型 仰角:%.1f° 高度:%d km @@ -133,6 +136,11 @@ 使用传感器旋转雷达视图 启用红色夜间模式 + 仰角高亮 + 低 < %1$d° + 中 %1$d-%2$d° + 高 > %1$d° + 我要感谢: • Look4Sat 所有用户及贡献者! diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index de886431..52ffb57a 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -31,7 +31,9 @@ Filter passes Minimal elevation - Hours ahead + Time ahead + AOS window + Invert AOS window DeepSpace (period >225min) -- : -- : -- Select modulation type @@ -182,6 +184,11 @@ Use sensors to rotate radar view Enable red night mode filter + Elevation highlight + Low < %1$d° + Mid %1$d–%2$d° + High > %1$d° + I would like to say thanks to * Look4Sat users and contributors! diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt index ac5fc16b..cc1c37ef 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesDialog.kt @@ -21,8 +21,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells @@ -31,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RangeSlider import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -55,7 +59,10 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.SharedDialog +import com.rtbishop.look4sat.core.presentation.ElevationHighColor +import com.rtbishop.look4sat.core.presentation.ElevationLowColor import com.rtbishop.look4sat.core.presentation.elevationColor +import kotlin.math.roundToInt private val allModes = listOf( "AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3", @@ -66,33 +73,81 @@ private val allModes = listOf( ) private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240) +private const val dayMinutes = 24 * 60 +private const val minuteStep = 15 +private const val endOfDayMinute = dayMinutes - 1 +private const val quarterHourSlots = dayMinutes / minuteStep + +data class PassFilterParams( + val hours: Int, + val elevation: Double, + val lowElevation: Double, + val highElevation: Double, + val aosStartMinute: Int, + val aosEndMinute: Int, + val invertAosTimeWindow: Boolean, + val showDeepSpace: Boolean +) @Preview @Composable private fun PassesDialogPreview() { - MainTheme { PassesDialog(24, 16.0, true, {}) { _, _, _ -> } } + MainTheme { + PassesFilterDialog( + hours = 24, + elevation = 16.0, + lowElevation = 16.0, + highElevation = 65.0, + aosStartMinute = 0, + aosEndMinute = 23 * 60 + 59, + invertAosTimeWindow = false, + showDeepSpace = true, + cancel = {}, + accept = {} + ) + } } @Composable -internal fun PassesDialog( +internal fun PassesFilterDialog( hours: Int, elevation: Double, + lowElevation: Double, + highElevation: Double, + aosStartMinute: Int, + aosEndMinute: Int, + invertAosTimeWindow: Boolean, showDeepSpace: Boolean, cancel: () -> Unit, - accept: (Int, Double, Boolean) -> Unit + accept: (PassFilterParams) -> Unit ) { val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) } val elevationValueNew = remember { mutableDoubleStateOf(elevation) } + val highlightBounds = 0f..90f + var highlightRange by remember(lowElevation, highElevation) { + mutableStateOf(lowElevation.toFloat()..highElevation.toFloat()) + } + var aosRangeValue by remember { + mutableStateOf(minuteToSlot(aosStartMinute).toFloat()..minuteToSlot(aosEndMinute).toFloat()) + } + var invertedAosRange by remember { mutableStateOf(invertAosTimeWindow) } var deepSpaceEnabled by remember { mutableStateOf(showDeepSpace) } val onAccept = { - accept(hourSteps[hoursIndex.intValue], elevationValueNew.doubleValue, deepSpaceEnabled).also { cancel() } + accept( + PassFilterParams( + hours = hourSteps[hoursIndex.intValue], + elevation = elevationValueNew.doubleValue, + lowElevation = highlightRange.start.roundToInt().toDouble(), + highElevation = highlightRange.endInclusive.roundToInt().toDouble(), + aosStartMinute = slotToMinute(aosRangeValue.start), + aosEndMinute = slotToMinute(aosRangeValue.endInclusive), + invertAosTimeWindow = invertedAosRange, + showDeepSpace = deepSpaceEnabled + ) + ) + cancel() } SharedDialog(title = stringResource(R.string.pass_filter_title), onCancel = cancel, onAccept = onAccept) { - ToggleRow( - title = stringResource(R.string.pass_filter_deep_space), - checked = deepSpaceEnabled, - onCheckedChange = { deepSpaceEnabled = it } - ) SliderRow( title = stringResource(R.string.pass_filter_elev), value = elevationValueNew.doubleValue, @@ -104,24 +159,74 @@ internal fun PassesDialog( SliderRow( title = stringResource(R.string.pass_filter_hours), value = hoursIndex.intValue.toDouble(), - displayValue = "${hourSteps[hoursIndex.intValue]}h", + displayValue = formatHoursLabel(hourSteps[hoursIndex.intValue]), valueResId = R.drawable.ic_clock, valueRange = 0f..(hourSteps.size - 1).toFloat(), steps = hourSteps.size - 2 ) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) } + ToggleRow( + title = stringResource(R.string.pass_filter_deep_space), + checked = deepSpaceEnabled, + onCheckedChange = { deepSpaceEnabled = it } + ) + ElevationColorsRangeSliderRow( + title = stringResource(R.string.prefs_highlight_title), + range = highlightRange, + valueRange = highlightBounds + ) { highlightRange = it } + TimeRangeSliderRow( + title = stringResource(R.string.pass_filter_aos_time), + range = aosRangeValue, + displayValue = formatTimeRange(aosRangeValue, invertedAosRange), + valueResId = R.drawable.ic_clock, + valueRange = 0f..quarterHourSlots.toFloat(), + ) { aosRangeValue = it } + ToggleRow( + title = stringResource(R.string.pass_filter_invert_time), + checked = invertedAosRange, + onCheckedChange = { invertedAosRange = it } + ) + Spacer(modifier = Modifier.height(0.dp)) } } +private fun slotToMinute(value: Float): Int { + val slot = value.roundToInt().coerceIn(0, quarterHourSlots) + return if (slot == quarterHourSlots) endOfDayMinute else slot * minuteStep +} + +private fun minuteToSlot(minute: Int): Int { + if (minute >= endOfDayMinute) return quarterHourSlots + return ((minute + minuteStep / 2) / minuteStep).coerceIn(0, quarterHourSlots) +} + +private fun formatMinuteOfDay(minute: Int): String { + val hourPart = minute / 60 + val minutePart = minute % 60 + return "%02d:%02d".format(hourPart, minutePart) +} + +private fun formatHoursLabel(hours: Int): String { + if (hours < 24) return "${hours}h" + val days = hours / 24 + val remainder = hours % 24 + return if (remainder == 0) "${days}d" else "${days}d ${remainder}h" +} + +private fun formatTimeRange( + range: ClosedFloatingPointRange, + inverted: Boolean +): String { + val start = formatMinuteOfDay(slotToMinute(range.start)) + val end = formatMinuteOfDay(slotToMinute(range.endInclusive)) + return if (inverted) "$end - $start" else "$start - $end" +} + @Composable -private fun SliderRow( +private fun SliderSection( title: String, - value: Double, - displayValue: String, - valueResId: Int, - valueRange: ClosedFloatingPointRange, - steps: Int = 0, - accentColor: Color = MaterialTheme.colorScheme.primary, - onChange: (Float) -> Unit + trailingContent: @Composable () -> Unit, + sliderContent: @Composable () -> Unit ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -138,6 +243,26 @@ private fun SliderRow( modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface ) + trailingContent() + } + sliderContent() + } +} + +@Composable +private fun SliderRow( + title: String, + value: Double, + displayValue: String, + valueResId: Int, + valueRange: ClosedFloatingPointRange, + steps: Int = 0, + accentColor: Color = MaterialTheme.colorScheme.primary, + onChange: (Float) -> Unit +) { + SliderSection( + title = title, + trailingContent = { Icon( painter = painterResource(id = valueResId), contentDescription = null, @@ -151,10 +276,89 @@ private fun SliderRow( color = accentColor ) } + ) { Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps) } } +@Composable +private fun TimeRangeSliderRow( + title: String, + range: ClosedFloatingPointRange, + displayValue: String, + valueResId: Int, + valueRange: ClosedFloatingPointRange, + onChange: (ClosedFloatingPointRange) -> Unit +) { + SliderSection( + title = title, + trailingContent = { + Icon( + painter = painterResource(id = valueResId), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = displayValue, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + ) { + RangeSlider( + value = range, + onValueChange = { onChange(it.start..it.endInclusive) }, + valueRange = valueRange, + steps = 0, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ElevationColorsRangeSliderRow( + title: String, + range: ClosedFloatingPointRange, + valueRange: ClosedFloatingPointRange, + onChange: (ClosedFloatingPointRange) -> Unit +) { + val low = range.start.roundToInt() + val high = range.endInclusive.roundToInt() + SliderSection( + title = title, + trailingContent = { + Text( + text = "$low°", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = ElevationLowColor + ) + Text( + text = "..", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "$high°", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = ElevationHighColor + ) + } + ) { + RangeSlider( + value = range, + onValueChange = { onChange(it.start..it.endInclusive) }, + valueRange = valueRange, + steps = 0, + modifier = Modifier.fillMaxWidth() + ) + } +} + @Preview(showBackground = true) @Composable private fun RadiosDialogPreview() { diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt index 508902ad..6b9752e2 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt @@ -67,11 +67,11 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard -import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.ScreenColumn +import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SwipeableItem import com.rtbishop.look4sat.core.presentation.TimerRow import com.rtbishop.look4sat.core.presentation.TopBar @@ -99,14 +99,31 @@ private fun PassesScreen( navigateToRadar: (Int, Long) -> Unit ) { if (uiState.isPassesDialogShown) { - PassesDialog( + PassesFilterDialog( hours = uiState.hours, elevation = uiState.elevation, + lowElevation = uiState.lowElevation, + highElevation = uiState.highElevation, + aosStartMinute = uiState.aosStartMinute, + aosEndMinute = uiState.aosEndMinute, + invertAosTimeWindow = uiState.invertAosTimeWindow, showDeepSpace = uiState.showDeepSpace, - cancel = { onAction(PassesAction.TogglePassesDialog) } - ) { hours, elevation, showDeepSpace -> - onAction(PassesAction.FilterPasses(hours, elevation, showDeepSpace)) - } + cancel = { onAction(PassesAction.TogglePassesDialog) }, + accept = { params -> + onAction( + PassesAction.FilterPasses( + hoursAhead = params.hours, + minElevation = params.elevation, + lowElevation = params.lowElevation, + highElevation = params.highElevation, + aosStartMinute = params.aosStartMinute, + aosEndMinute = params.aosEndMinute, + invertAosTimeWindow = params.invertAosTimeWindow, + showDeepSpace = params.showDeepSpace + ) + ) + } + ) } if (uiState.isRadiosDialogShown) { RadiosDialog( @@ -117,11 +134,19 @@ private fun PassesScreen( } } if (uiState.shouldSeeWhatsNew) { - InfoDialog( + val dismiss = { onAction(PassesAction.DismissWhatsNew) } + SharedDialog( title = stringResource(R.string.pass_whatsnew_title), - text = stringResource(R.string.pass_whatsnew_message) - ) { - onAction(PassesAction.DismissWhatsNew) + onDismissRequest = dismiss, + onAccept = dismiss, + titleFontSize = 18 + ) { padding -> + Text( + text = stringResource(R.string.pass_whatsnew_message), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = padding) + ) } } ScreenColumn( diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt index ebd9504a..3aef6726 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesState.kt @@ -29,6 +29,11 @@ data class PassesState( val isNextTimeAos: Boolean = true, val hours: Int = 24, val elevation: Double = 16.0, + val lowElevation: Double = 16.0, + val highElevation: Double = 65.0, + val aosStartMinute: Int = 0, + val aosEndMinute: Int = 23 * 60 + 59, + val invertAosTimeWindow: Boolean = false, val showDeepSpace: Boolean = true, val modes: List = emptyList(), val itemsList: List = emptyList(), @@ -40,7 +45,16 @@ data class PassesState( sealed interface PassesAction { data object DismissWhatsNew : PassesAction - data class FilterPasses(val hoursAhead: Int, val minElevation: Double, val showDeepSpace: Boolean) : PassesAction + data class FilterPasses( + val hoursAhead: Int, + val minElevation: Double, + val lowElevation: Double, + val highElevation: Double, + val aosStartMinute: Int, + val aosEndMinute: Int, + val invertAosTimeWindow: Boolean, + val showDeepSpace: Boolean + ) : PassesAction data class FilterRadios(val modes: List) : PassesAction data object RefreshPasses : PassesAction data object TogglePassesDialog : PassesAction diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index cde10e7c..aaeda056 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt @@ -54,6 +54,11 @@ class PassesViewModel( nextPass = defaultPass, hours = settingsRepo.passesSettings.value.hoursAhead, elevation = settingsRepo.passesSettings.value.minElevation, + lowElevation = settingsRepo.otherSettings.value.lowElevation, + highElevation = settingsRepo.otherSettings.value.highElevation, + aosStartMinute = settingsRepo.passesSettings.value.aosStartMinute, + aosEndMinute = settingsRepo.passesSettings.value.aosEndMinute, + invertAosTimeWindow = settingsRepo.passesSettings.value.invertAosTimeWindow, showDeepSpace = settingsRepo.passesSettings.value.showDeepSpace, modes = settingsRepo.passesSettings.value.selectedModes, shouldSeeWhatsNew = settingsRepo.otherSettings.value.shouldSeeWhatsNew @@ -71,7 +76,14 @@ class PassesViewModel( // React to settings changes: update UTC flag and whatsNew viewModelScope.launch { settingsRepo.otherSettings.collectLatest { settings -> - _uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } + _uiState.update { + it.copy( + isUtc = settings.stateOfUtc, + shouldSeeWhatsNew = settings.shouldSeeWhatsNew, + lowElevation = settings.lowElevation, + highElevation = settings.highElevation + ) + } } } // Main tick loop: reacts to new passes, then ticks every second @@ -107,9 +119,29 @@ class PassesViewModel( when (action) { PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() is PassesAction.FilterPasses -> - applyFilter(action.hoursAhead, action.minElevation, action.showDeepSpace, _uiState.value.modes) + applyFilter( + hoursAhead = action.hoursAhead, + minElevation = action.minElevation, + lowElevation = action.lowElevation, + highElevation = action.highElevation, + aosStartMinute = action.aosStartMinute, + aosEndMinute = action.aosEndMinute, + invertAosTimeWindow = action.invertAosTimeWindow, + showDeepSpace = action.showDeepSpace, + modes = _uiState.value.modes + ) is PassesAction.FilterRadios -> - applyFilter(_uiState.value.hours, _uiState.value.elevation, _uiState.value.showDeepSpace, action.modes) + applyFilter( + hoursAhead = _uiState.value.hours, + minElevation = _uiState.value.elevation, + lowElevation = _uiState.value.lowElevation, + highElevation = _uiState.value.highElevation, + aosStartMinute = _uiState.value.aosStartMinute, + aosEndMinute = _uiState.value.aosEndMinute, + invertAosTimeWindow = _uiState.value.invertAosTimeWindow, + showDeepSpace = _uiState.value.showDeepSpace, + modes = action.modes + ) PassesAction.RefreshPasses -> refreshPasses() PassesAction.TogglePassesDialog -> _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } @@ -198,19 +230,61 @@ class PassesViewModel( private fun applyFilter( hoursAhead: Int, minElevation: Double, + lowElevation: Double, + highElevation: Double, + aosStartMinute: Int, + aosEndMinute: Int, + invertAosTimeWindow: Boolean, showDeepSpace: Boolean, modes: List ) = viewModelScope.launch { - settingsRepo.setPassesSettings(PassesSettings(showDeepSpace, hoursAhead, minElevation, modes)) + settingsRepo.setPassesSettings( + PassesSettings( + showDeepSpace, + hoursAhead, + minElevation, + aosStartMinute, + aosEndMinute, + invertAosTimeWindow, + modes + ) + ) + settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) } _uiState.update { - it.copy(hours = hoursAhead, elevation = minElevation, showDeepSpace = showDeepSpace, modes = modes) + it.copy( + hours = hoursAhead, + elevation = minElevation, + lowElevation = lowElevation, + highElevation = highElevation, + aosStartMinute = aosStartMinute, + aosEndMinute = aosEndMinute, + invertAosTimeWindow = invertAosTimeWindow, + showDeepSpace = showDeepSpace, + modes = modes + ) } - satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) + satelliteRepo.calculatePasses( + time = System.currentTimeMillis(), + hoursAhead = hoursAhead, + minElevation = minElevation, + aosStartMinute = aosStartMinute, + aosEndMinute = aosEndMinute, + invertAosTimeWindow = invertAosTimeWindow, + modes = modes + ) } private fun refreshPasses() = viewModelScope.launch { - val (_, hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value - satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) + val settings = settingsRepo.passesSettings.value + satelliteRepo.calculatePasses( + time = System.currentTimeMillis(), + hoursAhead = settings.hoursAhead, + minElevation = settings.minElevation, + aosStartMinute = settings.aosStartMinute, + aosEndMinute = settings.aosEndMinute, + invertAosTimeWindow = settings.invertAosTimeWindow, + modes = settings.selectedModes + ) } companion object { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt index 9fa29a32..c0ceb48c 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/SstvPage.kt @@ -53,10 +53,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.OutlinedText import com.rtbishop.look4sat.core.presentation.R +import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.infiniteMarquee import kotlin.math.roundToInt @@ -95,53 +95,44 @@ internal fun SstvPage( if (showModeDialog.value) { val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } - Dialog(onDismissRequest = { showModeDialog.value = false }) { - ElevatedCard { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(vertical = 16.dp) - ) { - Text( - text = "SSTV Mode", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 12.dp) - ) - LazyColumn( + val dismiss = { showModeDialog.value = false } + SharedDialog( + title = "SSTV Mode", + onDismissRequest = dismiss, + onAccept = dismiss + ) { _ -> + LazyColumn( + modifier = Modifier + .fillMaxHeight(0.7f) + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(1.dp) + ) { + items(allModes) { mode -> + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxHeight(0.5f) - .background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.spacedBy(1.dp) - ) { - items(allModes) { mode -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface) - .clickable { - onAction(RadarAction.SstvSelectMode(mode)) - showModeDialog.value = false - } - ) { - Text( - text = mode, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .padding(start = 16.dp) - ) - RadioButton( - selected = mode == sstv.selectedMode, - onClick = null, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .clickable { + onAction(RadarAction.SstvSelectMode(mode)) + showModeDialog.value = false } - } + ) { + Text( + text = mode, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) + RadioButton( + selected = mode == sstv.selectedMode, + onClick = null, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) } } } diff --git a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt index d7b9e7d7..78e6bb5a 100644 --- a/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt +++ b/feature/satellites/src/main/java/com/rtbishop/look4sat/feature/satellites/SatellitesScreen.kt @@ -62,10 +62,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.CardLoadingIndicator import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.IconCard -import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.R +import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.isVerticalLayout @@ -95,11 +95,19 @@ private fun SatellitesScreen( ) } if (uiState.shouldSeeWarning) { - InfoDialog( - stringResource(R.string.sat_warning_title), - stringResource(R.string.sat_warning_message) - ) { - onAction(SatellitesAction.DismissWarning) + val dismiss = { onAction(SatellitesAction.DismissWarning) } + SharedDialog( + title = stringResource(R.string.sat_warning_title), + onDismissRequest = dismiss, + onAccept = dismiss, + titleFontSize = 18 + ) { padding -> + Text( + text = stringResource(R.string.sat_warning_message), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = padding) + ) } } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 1dab7436..9b799dfd 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -69,13 +69,16 @@ fun PositionDialog(lat: Double, lon: Double, dismiss: () -> Unit, save: (Double, OutlinedTextField( value = latValue.value, onValueChange = { latValue.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) } + label = { Text(text = stringResource(id = R.string.prefs_station_lat_text)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), ) OutlinedTextField( value = lonValue.value, onValueChange = { lonValue.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) } + label = { Text(text = stringResource(id = R.string.prefs_station_lon_text)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), ) + Spacer(modifier = Modifier.height(0.dp)) } } @@ -101,8 +104,10 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni OutlinedTextField( value = locator.value, onValueChange = { locator.value = it }, - label = { Text(text = stringResource(id = R.string.prefs_locator_text)) } + label = { Text(text = stringResource(id = R.string.prefs_locator_text)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), ) + Spacer(modifier = Modifier.height(0.dp)) } } @@ -208,6 +213,7 @@ fun DataSourcesDialog( modifier = Modifier.fillMaxWidth(), enabled = isEnabledCustomTransceivers.value, ) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -298,6 +304,7 @@ fun NetworkOutputDialog( onFormatChange = { frequencyFormat.value = it }, formatLabel = stringResource(R.string.prefs_net_frequency_format_hint) ) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -391,6 +398,7 @@ fun BluetoothOutputDialog( onFormatChange = { frequencyFormat.value = it }, formatLabel = stringResource(R.string.prefs_bt_frequency_output_hint) ) + Spacer(modifier = Modifier.height(12.dp)) } } } @@ -642,6 +650,7 @@ fun RadioControlDialog( ) } } + Spacer(modifier = Modifier.height(6.dp)) } } }