Compare commits

...
7 Commits
Author SHA1 Message Date
Arty Bishop 83ce7229f6 Added AOS/LOS timer to each pass and a SearchBar filter 2026-10-04 20:19:48 +01:00
Arty Bishop 782b438a07 v4.4.8 - Fuzzy satellites search, light theme, various fixes 2026-09-27 14:23:02 +01:00
Michael S fac319b215 Fixed IC-705 frequency read in dual-radio mode (#260) 2026-09-27 10:45:21 +01:00
Ondrej KolonicnyandClaude Opus 4.8 b7ac9c913a Added light theme option suitable for e-ink devices (#256)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-19 11:35:42 +01:00
atsunatsuandatsunatsu de3d5a6d84 Added fuzzy satellite name search ignoring separators (#255)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-19 11:13:39 +01:00
atsunatsuandatsunatsu 5e39c945b2 Applied user elevation color thresholds to the radar page (#253)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-19 11:10:50 +01:00
wty2019wtyanddependabot[bot] 1d07f76ada Fixed the AOS window filter so it follows the UTC setting (#252)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 11:03:05 +01:00
30 changed files with 649 additions and 297 deletions

No files matched your search

@@ -27,6 +27,8 @@ import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -48,8 +50,10 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState() observeNightFilterState()
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
setContent { setContent {
MainTheme(isDarkTheme = true) { NavRoot() } val otherSettings by settingsRepo.otherSettings.collectAsState()
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
} }
} }
@@ -99,6 +99,19 @@ fun NavRoot(deeplink: String? = null) {
// Reverse: outgoing slides out to the right, incoming drifts in from the left // Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it } slideOutHorizontally(tween(300)) { it }
// Elevation color thresholds must be provided at the root level so that BOTH
// the tab content (MainScreen) and the root-level RadarDestination see the
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
// in this NavDisplay, so a provider inside MainScreen never reaches it.
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
high = otherSettings.highElevation
)
) {
NavDisplay( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
backStack = rootBackStack, backStack = rootBackStack,
@@ -122,6 +135,7 @@ fun NavRoot(deeplink: String? = null) {
} }
} }
) )
}
} }
@Composable @Composable
@@ -129,7 +143,6 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
@@ -139,12 +152,6 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val navItems = val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
high = otherSettings.highElevation
)
) {
NavigationSuiteScaffold( NavigationSuiteScaffold(
navigationSuiteItems = { navigationSuiteItems = {
navItems.forEach { screen -> navItems.forEach { screen ->
@@ -223,7 +230,6 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
} }
} }
}
} }
@Composable @Composable
@@ -330,12 +330,13 @@ object IcomCivProtocol {
/** /**
* Parse frequency + mode from a CMD_READ_FREQ reply payload. * Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte] * Payload layout after stripping command byte: [5 freq bytes]
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
*/ */
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? { fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null if (payload.size < 5) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5)) val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
return freqHz to mode return freqHz to mode
} }
@@ -35,6 +35,8 @@ 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.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone import java.util.TimeZone
@@ -67,8 +69,9 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
combine( combine(
settingsRepo.selectedIds, settingsRepo.selectedIds,
settingsRepo.stationPosition settingsRepo.stationPosition,
) { selectedIds, _ -> selectedIds } settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds -> .collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value val settings = settingsRepo.passesSettings.value
@@ -176,7 +179,13 @@ class SatelliteRepo(
aosEndMinute: Int, aosEndMinute: Int,
invertAosTimeWindow: Boolean invertAosTimeWindow: Boolean
): Boolean { ): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong() // Must match the timezone used to display pass times (UTC toggle vs device local)
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
TimeZone.getTimeZone("UTC")
} else {
TimeZone.getDefault()
}
val offsetMillis = tz.getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L) val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt() val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) { val inRange = if (aosStartMinute <= aosEndMinute) {
@@ -114,12 +114,29 @@ class SelectionRepo(
/** /**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow, * Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item. * and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/ */
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> { private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items if (query.isBlank()) return items
val catnum = query.toIntOrNull() val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum } if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase() val tokens = query.split(' ')
return items.filter { it.name.lowercase().contains(lowerQuery) } .map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
} }
}
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
} }
@@ -0,0 +1,33 @@
package com.rtbishop.look4sat.core.data.framework
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class IcomCivProtocolTest {
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
@Test
fun parseFreqModePayload_ic705ReadFreqReply() {
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
assertNotNull(response)
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
}
@Test
fun parseFreqModePayload_withModeByte() {
assertEquals(
435611000L to "USB",
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
)
}
@Test
fun parseFreqModePayload_tooShort() {
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
}
}
@@ -84,6 +84,65 @@ class SelectionRepoTest {
assertEquals(listOf(true, false, false), items.map { it.isSelected }) assertEquals(listOf(true, false, false), items.map { it.isSelected })
} }
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("ao7")
val items = flow.first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
// guarantee is that AO-7 — unreachable before because of dashes/brackets
// — is now found.
assertEquals(7530, items.first().catnum)
assertEquals(listOf(7530, 39444), items.map { it.catnum })
}
@Test
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("fo29")
assertEquals(listOf(99999), flow.first().map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zarya iss")
assertEquals(listOf(25544), flow.first().map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("funcube")
assertEquals(listOf(39444), flow.first().map { it.catnum })
}
@Test
fun `unmatched query returns nothing`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zzzznomatch")
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
}
private fun repoWithSearchEntries(): SelectionRepo {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
SatItem(39444, "AO-73 (FUNcube-1)", false),
SatItem(43803, "JO-97 (BIRDS-3)", false),
SatItem(99999, "FO-29", false)
)
)
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
}
private class FakeLocalSource( private class FakeLocalSource(
private val entries: List<SatItem> private val entries: List<SatItem>
) : ILocalSource { ) : ILocalSource {
@@ -31,4 +31,15 @@ data class OrbitalPass(
val catNum: Int = orbitalObject.data.catnum val catNum: Int = orbitalObject.data.catnum
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
/**
* Fraction of the pass elapsed at [timeMillis], derived on demand so that a ticking clock
* never has to rewrite the pass list. A DeepSpace object is always in view, so it reads 1.
*/
fun progressAt(timeMillis: Long): Float {
if (isDeepSpace) return 1f
val total = (losTime - aosTime).toFloat()
if (total <= 0f) return 0f
return ((timeMillis - aosTime) / total).coerceIn(0f, 1f)
}
} }
@@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -46,21 +47,32 @@ import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -428,6 +440,55 @@ private fun DialogShell(
} }
} }
@Composable
fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val updateQuery = { newValue: String ->
currentQuery.value = newValue
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp)
) {
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = updateQuery,
singleLine = true,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (currentQuery.value.isEmpty()) {
Text(
text = stringResource(id = R.string.sat_search_hint),
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
innerTextField()
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
}
}
}
@Composable @Composable
fun hasEnoughHeight(): Boolean = fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480) currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@@ -580,3 +641,30 @@ fun formatFrequency(frequencyHz: Long): String {
val hz = frequencyHz % 1_000 val hz = frequencyHz % 1_000
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz) return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
} }
/**
* Strokes the [shape] outline as a track, then reveals it left to right up to the [progress]
* fraction of the width, so the top and bottom edges fill in sync.
*/
@Composable
fun Modifier.progressOutline(
progress: Float,
shape: Shape = RoundedCornerShape(12.dp),
width: Dp = 0.5.dp,
trackColor: Color = ProgressIndicatorDefaults.linearTrackColor,
progressColor: Color = ProgressIndicatorDefaults.linearColor
) = this.drawWithCache {
val widthPx = width.toPx()
val outline = shape.createOutline(Size(size.width - widthPx, size.height - widthPx), layoutDirection, this)
val outlinePath = Path().apply {
addOutline(outline)
translate(Offset(widthPx / 2f, widthPx / 2f))
}
val stroke = Stroke(width = widthPx)
val revealWidth = size.width * progress.coerceIn(0f, 1f)
onDrawWithContent {
drawContent()
drawPath(outlinePath, trackColor, style = stroke)
clipRect(right = revealWidth) { drawPath(outlinePath, progressColor, style = stroke) }
}
}
@@ -61,8 +61,8 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
SideEffect { SideEffect {
val window = (view.context as ComponentActivity).window val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = false insetsController.isAppearanceLightNavigationBars = !isDarkTheme
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false window.isNavigationBarContrastEnforced = false
} }
@@ -39,6 +39,7 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() } val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx } val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } } val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val isSwiping by remember { derivedStateOf { dismissState.dismissDirection != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) { LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
@@ -49,8 +50,10 @@ fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @C
enableDismissFromStartToEnd = true, enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true, enableDismissFromEndToStart = true,
backgroundContent = { backgroundContent = {
if (isSwiping) {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary) SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
}
}, },
content = { content() }, content = { content() },
onDismiss = { onDismiss = {
@@ -161,10 +161,13 @@
<string name="prefs_other_title">Otros</string> <string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string> <string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string> <string name="prefs_other_switch_update">Auto-actualizar</string>
<string name="prefs_other_switch_sweep">Animación radar</string> <string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string> <string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string> <string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_other_switch_light_theme">Tema claro</string>
<string name="prefs_other_compass_offset_az">Desfase de brújula (ácimut)</string>
<string name="prefs_other_compass_offset_elev">Desfase de brújula (elevación)</string>
<string name="prefs_highlight_title">Resaltado de elevación</string> <string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string> <string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -161,10 +161,13 @@
<string name="prefs_other_title">Прочее</string> <string name="prefs_other_title">Прочее</string>
<string name="prefs_other_switch_utc">Время в UTC</string> <string name="prefs_other_switch_utc">Время в UTC</string>
<string name="prefs_other_switch_update">Автообновление данных</string> <string name="prefs_other_switch_update">Автообновление</string>
<string name="prefs_other_switch_sweep">Анимация радара</string> <string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string> <string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string> <string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_other_switch_light_theme">Светлая тема</string>
<string name="prefs_other_compass_offset_az">Смещение компаса (азимут)</string>
<string name="prefs_other_compass_offset_elev">Смещение компаса (элевация)</string>
<string name="prefs_highlight_title">Подсветка высоты</string> <string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string> <string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -161,10 +161,13 @@
<string name="prefs_other_title">වෙනත්</string> <string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">UTC වේලාව</string> <string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string> <string name="prefs_other_switch_update">ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string> <string name="prefs_other_switch_sweep">රේඩාර් පරිලෝකනය</string>
<string name="prefs_other_switch_sensors">සංවේදක</string> <string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string> <string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_other_switch_light_theme">ආලෝක තේමාව</string>
<string name="prefs_other_compass_offset_az">මාලිමා අපගමනය (දිගංශය)</string>
<string name="prefs_other_compass_offset_elev">මාලිමා අපගමනය (උත්තෝලනය)</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string> <string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string> <string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -193,10 +193,13 @@
<string name="prefs_other_title">Diğer</string> <string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string> <string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string> <string name="prefs_other_switch_update">Otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string> <string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string> <string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string> <string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_other_switch_light_theme">Açık tema</string>
<string name="prefs_other_compass_offset_az">Pusula sapması (azimut)</string>
<string name="prefs_other_compass_offset_elev">Pusula sapması (yükseklik açısı)</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string> <string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string> <string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -161,10 +161,13 @@
<string name="prefs_other_title">Інше</string> <string name="prefs_other_title">Інше</string>
<string name="prefs_other_switch_utc">Час в UTC</string> <string name="prefs_other_switch_utc">Час в UTC</string>
<string name="prefs_other_switch_update">Автооновлення даних</string> <string name="prefs_other_switch_update">Автооновлення</string>
<string name="prefs_other_switch_sweep">Анімація радара</string> <string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string> <string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string> <string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_other_switch_light_theme">Світла тема</string>
<string name="prefs_other_compass_offset_az">Зміщення компаса (азимут)</string>
<string name="prefs_other_compass_offset_elev">Зміщення компаса (піднесення)</string>
<string name="prefs_highlight_title">Підсвічування висоти</string> <string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string> <string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -219,12 +219,13 @@
<string name="prefs_other_title">其他</string> <string name="prefs_other_title">其他</string>
<string name="prefs_other_switch_utc">UTC 时间</string> <string name="prefs_other_switch_utc">UTC 时间</string>
<string name="prefs_other_switch_update">自动更新数据</string> <string name="prefs_other_switch_update">自动更新</string>
<string name="prefs_other_switch_sweep">雷达扫描动画</string> <string name="prefs_other_switch_sweep">雷达扫描</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string> <string name="prefs_other_switch_sensors">传感器</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string> <string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string> <string name="prefs_other_switch_light_theme">浅色主题</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string> <string name="prefs_other_compass_offset_az">罗盘偏移量(方位角)</string>
<string name="prefs_other_compass_offset_elev">罗盘偏移量(仰角)</string>
<string name="prefs_highlight_title">仰角高亮</string> <string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string> <string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -47,8 +47,13 @@
<string name="pass_deep_space" translatable="false">DeepSpace</string> <string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Altitude: %d km</string> <string name="pass_altitude">Altitude: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string> <string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_azimuth" translatable="false">%03d°</string>
<string name="pass_duration_placeholder" translatable="false">--m --s</string>
<string name="pass_timer_aos" translatable="false">AOS %s</string>
<string name="pass_timer_los" translatable="false">LOS %s</string>
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Make sure your filters are correct and you have selected satellites</string> Make sure your filters are correct and you have selected satellites</string>
<string name="pass_empty_search_message">No upcoming passes match your search</string>
<string name="pass_error_message">There are no upcoming satellite passes to display</string> <string name="pass_error_message">There are no upcoming satellite passes to display</string>
<string name="pass_welcome_title">Welcome to Look4Sat\!</string> <string name="pass_welcome_title">Welcome to Look4Sat\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
@@ -56,14 +61,12 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241) * Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
\n\n* Overhauled sources import, added HTTP status by wty2019wty (#242) \n\n* Applied elevation color thresholds to the Radar by atsunatsu (#253)
\nSources now have priority - the top ones define satellite names and transceiver data. \n\n* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
\nYou can enable/disable sources and restore defaults via the Satellite data import dialog. \n\n* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
\n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245) \n\n* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
\n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247) \n\n* Fixed day/night map overlay calculation and display issue by rt-bishop
\n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -262,12 +265,13 @@
<string name="prefs_other_title">Other</string> <string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string> <string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string> <string name="prefs_other_switch_update">Auto-update</string>
<string name="prefs_other_switch_sweep">Radar sweep</string> <string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string> <string name="prefs_other_switch_sensors">Sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string> <string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string> <string name="prefs_other_switch_light_theme">Light theme</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string> <string name="prefs_other_compass_offset_az">Compass offset (azimuth)</string>
<string name="prefs_other_compass_offset_elev">Compass offset (elevation)</string>
<string name="prefs_highlight_title">Elevation highlight</string> <string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string> <string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -1,5 +1,6 @@
* Tweaked sun calculations, added tests by atsunatsu (#241) * Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
* Overhauled sources import, added HTTP status by wty2019wty (#242) * Applied elevation color thresholds to the Radar by atsunatsu (#253)
* Localized AMSAT status screen for Chinese by atsunatsu (#245) * Added fuzzy satellite search ignoring separators by atsunatsu (#255)
* Extracted hardcoded UI strings to resources by TianhengZhuang (#247) * Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
* Added AMSAT satellite status reports submission by atsunatsu (#248) * Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
* Fixed day/night map overlay calculation and display issue by rt-bishop
@@ -116,8 +116,9 @@ class MapNightOverlay : Overlay() {
else -> { else -> {
// Terminator crosses this column — find the crossing pixel by binary search // Terminator crosses this column — find the crossing pixel by binary search
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff) val isNightAtTop = dotTop < 0
if (dotTop < 0) { val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff, isNightAtTop)
if (isNightAtTop) {
// Night at top, day at bottom // Night at top, day at bottom
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat()) rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
canvas.drawRect(rect, nightPaint) canvas.drawRect(rect, nightPaint)
@@ -134,7 +135,11 @@ class MapNightOverlay : Overlay() {
/** /**
* Binary-search for the pixel row where the day/night boundary crosses column [x]. * Binary-search for the pixel row where the day/night boundary crosses column [x].
* [yTop] is in day, [yBot] is in night (or vice versa). *
* [isNightAtTop] states which side [yTop] is on. Both polarities occur: the pole nearest
* the sub-solar point is lit, so the terminator runs day-over-night while the sub-solar
* latitude is positive and night-over-day once it turns negative after the September
* equinox. Anchoring lo to the top pixel's side keeps the search valid either way.
*/ */
private fun findCrossingY( private fun findCrossingY(
proj: org.osmdroid.views.Projection, proj: org.osmdroid.views.Projection,
@@ -143,7 +148,8 @@ class MapNightOverlay : Overlay() {
yBot: Int, yBot: Int,
sinSunLat: Double, sinSunLat: Double,
cosSunLat: Double, cosSunLat: Double,
cosLonDiff: Double cosLonDiff: Double,
isNightAtTop: Boolean
): Int { ): Int {
var lo = yTop var lo = yTop
var hi = yBot var hi = yBot
@@ -152,7 +158,8 @@ class MapNightOverlay : Overlay() {
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
val latRad = Math.toRadians(geo.latitude) val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid // Keep lo on the top pixel's side of the terminator and hi on the opposite side
if ((dot < 0) == isNightAtTop) lo = mid else hi = mid
} }
return (lo + hi) / 2 return (lo + hi) / 2
} }
@@ -122,6 +122,39 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN) android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
} }
/** Accent used for the station marker; recolored per theme so it stays visible on a light map. */
private var mapAccentColor = "#FFE082".toColorInt()
/** The dark-map filter (grayscale + invert) is only applied when NOT in light theme; cached to avoid rebuilds. */
private val darkTileFilter by lazy { createColorFilter() }
/**
* Recolor the shared overlay paints for the current theme. The light OSM tiles are white, so the
* dark-theme amber/grey overlays would vanish; light theme swaps them for dark, high-contrast colors.
* Must run before the set*() overlay builders on each frame, as several icons cache the paint color.
*/
private fun applyMapColors(isLightUi: Boolean) {
if (isLightUi) {
mapAccentColor = "#715C0C".toColorInt() // dark amber (matches app lightScheme primary)
footprintPaint.color = mapAccentColor
textPaint.color = "#1E1B13".toColorInt()
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter("#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
} else {
mapAccentColor = "#FFE082".toColorInt()
footprintPaint.color = mapAccentColor
textPaint.color = mapAccentColor
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
}
@Composable @Composable
fun MapDestination() { fun MapDestination() {
val context = LocalContext.current val context = LocalContext.current
@@ -177,6 +210,8 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
ElevatedCard(modifier = Modifier.weight(1f)) { ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) { Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { view -> AndroidView({ mapView }) { view ->
applyMapColors(uiState.isLightUi)
view.overlayManager.tilesOverlay.setColorFilter(if (uiState.isLightUi) null else darkTileFilter)
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) }
@@ -288,7 +323,9 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply { mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position) icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
setTint(mapAccentColor)
}
position = GeoPoint(stationPos.latitude, stationPos.longitude) position = GeoPoint(stationPos.latitude, stationPos.longitude)
} }
} }
@@ -585,7 +622,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(createColorFilter()) // Tile color filter (dark inversion vs light) is applied per-theme in the AndroidView update block.
setScrollableAreaLimitLatitude(maxLat, minLat, 0) setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() }) overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
} }
@@ -80,7 +80,7 @@ class MapViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) } _uiState.update { it.copy(isUtc = settings.stateOfUtc, isLightUi = settings.stateOfLightTheme) }
} }
} }
val (selectedCatNum, _) = satelliteRepo.selectedPass.value val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -106,7 +106,7 @@ class MapViewModel(
private fun selectDefaultSatellite(catnum: Int) { private fun selectDefaultSatellite(catnum: Int) {
if (allSatellites.isNotEmpty()) { if (allSatellites.isNotEmpty()) {
if (catnum == -1) { if (catnum == -1) {
allPasses.find { pass -> pass.progress < 100 && !pass.isDeepSpace } allPasses.find { pass -> pass.progress < 1 && !pass.isDeepSpace }
?.let { pass -> selectSatellite(pass.orbitalObject) } ?.let { pass -> selectSatellite(pass.orbitalObject) }
} else { } else {
allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) } allSatellites.find { it.data.catnum == catnum }?.let { selectSatellite(it) }
@@ -189,8 +189,8 @@ class MapViewModel(
} }
} }
for ((localPositions, localSelectedPos) in deferreds.awaitAll()) { for ((localPositions, localSelectedPos) in deferreds.awaitAll()) {
for (pair in localPositions) { for ((first, second) in localPositions) {
positionsMap[pair.first] = pair.second positionsMap[first] = second
} }
if (localSelectedPos != null) { if (localSelectedPos != null) {
selectedSatPos = localSelectedPos selectedSatPos = localSelectedPos
@@ -17,11 +17,6 @@
*/ */
package com.rtbishop.look4sat.feature.passes package com.rtbishop.look4sat.feature.passes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -37,16 +32,18 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider 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.LocalTextStyle
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
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
@@ -54,6 +51,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -65,19 +64,21 @@ import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.EmptyListCard import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem import com.rtbishop.look4sat.core.presentation.SearchBar
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.WhatsNewDialog import com.rtbishop.look4sat.core.presentation.WhatsNewDialog
import com.rtbishop.look4sat.core.presentation.elevationColor import com.rtbishop.look4sat.core.presentation.elevationColor
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -89,12 +90,13 @@ fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container)) val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar) PassesScreen(uiState, viewModel.timeNow, viewModel::onAction, navigateToRadar)
} }
@Composable @Composable
private fun PassesScreen( private fun PassesScreen(
uiState: PassesState, uiState: PassesState,
timeNow: StateFlow<Long>,
onAction: (PassesAction) -> Unit, onAction: (PassesAction) -> Unit,
navigateToRadar: (Int, Long) -> Unit navigateToRadar: (Int, Long) -> Unit
) { ) {
@@ -145,7 +147,10 @@ private fun PassesScreen(
IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios) IconCard(action = { onAction(PassesAction.ToggleRadiosDialog) }, resId = R.drawable.ic_radios)
}, },
topInfo = { topInfo = {
TimerRow(timeString = uiState.nextTime, isTimeAos = uiState.isNextTimeAos) SearchBar(
onQueryChange = { onAction(PassesAction.SearchFor(it)) },
modifier = Modifier.weight(1f)
)
}, },
bottomInfo = { bottomInfo = {
NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc) NextPassRow(pass = uiState.nextPass, isUtc = uiState.isUtc)
@@ -162,14 +167,14 @@ private fun PassesScreen(
passes = uiState.itemsList, passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses, groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes, sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum, isSearching = uiState.searchQuery.isNotBlank(),
timeNow = timeNow,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
onAction = onAction onAction = onAction
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun PassesList( private fun PassesList(
isRefreshing: Boolean, isRefreshing: Boolean,
@@ -177,7 +182,8 @@ private fun PassesList(
passes: List<OrbitalPass>, passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>, groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>, sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?, isSearching: Boolean,
timeNow: StateFlow<Long>,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
onAction: (PassesAction) -> Unit onAction: (PassesAction) -> Unit
) { ) {
@@ -199,32 +205,24 @@ private fun PassesList(
} }
) { ) {
if (passes.isEmpty()) { if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message)) val emptyMessage = if (isSearching) {
stringResource(R.string.pass_empty_search_message)
} else {
stringResource(R.string.pass_empty_list_message)
}
EmptyListCard(message = emptyMessage)
} else { } else {
AnimatedContent(
targetState = focusedCatNum,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "PassesFocusTransition"
) { targetFocus ->
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) { LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
for ((dateLabel, dayPasses) in groupedPasses) { for ((dateLabel, dayPasses) in groupedPasses) {
val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
if (headerVisible) {
stickyHeader(key = "header_$dateLabel") { stickyHeader(key = "header_$dateLabel") {
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--") val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set) StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
} }
} items(items = dayPasses, key = { item -> "${item.catNum}_${item.aosTime}" }) { pass ->
val visiblePasses = if (targetFocus == null) dayPasses
else dayPasses.filter { it.catNum == targetFocus }
items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
SwipeableItem(
onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
onSwipeLeft = { onAction(PassesAction.ClearFocus) }
) {
PassItem( PassItem(
pass = pass, pass = pass,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
timeNow = timeNow,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout, isVerticalLayout = isVerticalLayout,
isUtc = isUtc isUtc = isUtc
@@ -235,8 +233,6 @@ private fun PassesList(
} }
} }
} }
}
}
} }
@Composable @Composable
@@ -278,7 +274,7 @@ private fun StickyDateHeader(label: String, sunriseTime: String, sunsetTime: Str
} }
} }
private fun displayLocale(): Locale { internal fun displayLocale(): Locale {
val locale = Locale.getDefault() val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
} }
@@ -288,51 +284,127 @@ private fun displayLocale(): Locale {
private fun DeepSpacePassPreview() { 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, 180.0, 36650, 45.0, satellite, 0.5f)
MainTheme { PassItem(pass = pass, { _, _ -> }) } MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(0L)) }
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun NearEarthPassPreview() { private fun UpcomingPassPreview() {
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 aosTime = 1767225600000L
val pass = OrbitalPass(1L, 180.0, 10L, 360.0, 36650, 45.0, satellite, 0.5f) val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0f)
MainTheme { PassItem(pass = pass, { _, _ -> }) } MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime - 866000L)) }
}
@Preview(showBackground = true)
@Composable
private fun ActivePassPreview() {
val data = OrbitalData("Satellite", 0.0, 15.0, 0.0, 0.0, 0.0, 0.0, 0.0, 45000, 0.0)
val aosTime = 1767225600000L
val pass = OrbitalPass(aosTime, 180.0, aosTime + 702000L, 360.0, 36650, 45.0, NearEarthObject(data), 0.4f)
MainTheme { PassItem(pass = pass, { _, _ -> }, MutableStateFlow(aosTime + 280000L)) }
}
/**
* Countdown to AOS while the pass is pending, then to LOS while it is active.
* Gray denotes a pending pass, primary an active one. Collects the clock itself so a
* tick recomposes only this chip.
*/
@Composable
private fun PassTimerChip(pass: OrbitalPass, timeNow: StateFlow<Long>) {
val now by timeNow.collectAsStateWithLifecycle()
// A DeepSpace object is permanently in view, so it gets a neutral label instead of a countdown
val isActive = !pass.isDeepSpace && now >= pass.aosTime
val text = when {
pass.isDeepSpace -> stringResource(R.string.pass_deep_space)
isActive -> stringResource(R.string.pass_timer_los, (pass.losTime - now).toTimerString())
else -> stringResource(R.string.pass_timer_aos, (pass.aosTime - now).toTimerString())
}
Surface(
shape = MaterialTheme.shapes.small,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
contentColor = if (isActive) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
) {
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
style = LocalTextStyle.current.copy(
// Tabular figures stop the chip reflowing on every tick, while the trimmed
// line height keeps it short without shrinking the timer text
fontFeatureSettings = "tnum",
lineHeight = 16.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both
)
),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
/** Elapsed fraction of the pass. Collects the clock itself to keep ticks off the text rows. */
@Composable
private fun PassProgressBar(pass: OrbitalPass, timeNow: StateFlow<Long>, modifier: Modifier = Modifier) {
val now by timeNow.collectAsStateWithLifecycle()
LinearProgressIndicator(
progress = { pass.progressAt(now) },
drawStopIndicator = {},
modifier = modifier
)
} }
@Composable @Composable
private fun PassItem( private fun PassItem(
pass: OrbitalPass, pass: OrbitalPass,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
timeNow: StateFlow<Long>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isVerticalLayout: Boolean = true, isVerticalLayout: Boolean = true,
isUtc: Boolean = false isUtc: Boolean = false
) { ) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum) val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp val horizontalPadding = if (isVerticalLayout) 6.dp else 12.dp
val timeZone = remember(isUtc) { val timeZone = remember(isUtc) {
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
} }
val sdfTime = remember(isUtc) { val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone } SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
} }
val azimuthFormat = stringResource(id = R.string.pass_azimuth)
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)) }
val aosAzStr = remember(pass.aosAzimuth, azimuthFormat) {
String.format(displayLocale(), azimuthFormat, pass.aosAzimuth.toInt() % 360)
}
val losAzStr = remember(pass.losAzimuth, azimuthFormat) {
String.format(displayLocale(), azimuthFormat, pass.losAzimuth.toInt() % 360)
}
val durationStr = remember(pass.aosTime, pass.losTime) { val durationStr = remember(pass.aosTime, pass.losTime) {
val seconds = (pass.losTime - pass.aosTime) / 1000 val seconds = (pass.losTime - pass.aosTime) / 1000
"${seconds / 60}m ${seconds % 60}s" "${seconds / 60}m ${seconds % 60}s"
} }
// do not delete
// val progressTint = MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f)
// val progressFraction = if (pass.isDeepSpace) 0f else pass.progress.coerceIn(0f, 1f)
Column( Column(
modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) } modifier = modifier.clickable { navigateToRadar(pass.catNum, pass.aosTime) }
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface) .background(color = MaterialTheme.colorScheme.surface)
.padding(horizontal = horizontalPadding, vertical = 4.dp) // do not delete
// .drawBehind { drawRect(progressTint, size = Size(size.width * progressFraction, size.height)) }
.padding(horizontal = horizontalPadding, vertical = 6.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -350,6 +422,35 @@ private fun PassItem(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
PassTimerChip(pass = pass, timeNow = timeNow)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (pass.isDeepSpace) {
stringResource(R.string.pass_duration_placeholder)
} else {
durationStr
},
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
// AOS azimuth, peak elevation and LOS azimuth read as the arc of the pass
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = aosAzStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
Row(verticalAlignment = Alignment.CenterVertically) {
val elevColor = elevationColor(pass.maxElevation) val elevColor = elevationColor(pass.maxElevation)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_elevation), painter = painterResource(id = R.drawable.ic_elevation),
@@ -363,72 +464,47 @@ private fun PassItem(
color = elevColor color = elevColor
) )
} }
Text(
text = losAzStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
text = "${pass.altitude} km",
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
modifier = Modifier.weight(1f)
)
}
// A DeepSpace object has no AOS/LOS to count between, so it drops the progress row
if (!pass.isDeepSpace) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (pass.isDeepSpace) {
Text( Text(
text = stringResource(R.string.pass_deep_space), text = aosTimeStr,
fontSize = 15.sp, fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} else { PassProgressBar(
Text(text = durationStr, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface) pass = pass,
} timeNow = timeNow,
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
}
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,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
val defaultTime = " - - : - - "
Text(
text = if (pass.isDeepSpace) defaultTime else aosTimeStr,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface
)
LinearProgressIndicator(
progress = { if (pass.isDeepSpace) 100f else pass.progress },
drawStopIndicator = {},
modifier = Modifier.fillMaxWidth(0.75f) modifier = Modifier.fillMaxWidth(0.75f)
) )
Text( Text(
text = if (pass.isDeepSpace) defaultTime else losTimeStr, text = losTimeStr,
fontSize = 15.sp, fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
} }
}
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.background)
} }
} }
@@ -25,8 +25,7 @@ data class PassesState(
val isRefreshing: Boolean = true, val isRefreshing: Boolean = true,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val nextPass: OrbitalPass, val nextPass: OrbitalPass,
val nextTime: String = "00:00:00", val searchQuery: String = "",
val isNextTimeAos: Boolean = true,
val hours: Int = 24, val hours: Int = 24,
val elevation: Double = 16.0, val elevation: Double = 16.0,
val lowElevation: Double = 16.0, val lowElevation: Double = 16.0,
@@ -39,8 +38,7 @@ data class PassesState(
val itemsList: List<OrbitalPass> = emptyList(), val itemsList: List<OrbitalPass> = emptyList(),
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(), val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false, val shouldSeeWhatsNew: Boolean = false,
val sunTimes: Map<String, Pair<String, String>> = emptyMap(), val sunTimes: Map<String, Pair<String, String>> = emptyMap()
val focusedCatNum: Int? = null
) )
sealed interface PassesAction { sealed interface PassesAction {
@@ -59,6 +57,5 @@ sealed interface PassesAction {
data object RefreshPasses : PassesAction data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction data class SearchFor(val query: String) : PassesAction
data object ClearFocus : PassesAction
} }
@@ -27,8 +27,6 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,6 +44,14 @@ import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
/** Inputs that together determine the visible pass list; a change in any one regroups it. */
private data class PassesInput(
val passes: List<OrbitalPass>,
val isUtc: Boolean,
val showDeepSpace: Boolean,
val query: String
)
class PassesViewModel( class PassesViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
@@ -70,6 +76,16 @@ class PassesViewModel(
) )
val uiState: StateFlow<PassesState> = _uiState val uiState: StateFlow<PassesState> = _uiState
private val _timeNow = MutableStateFlow(System.currentTimeMillis())
private val searchQuery = MutableStateFlow("")
/**
* Ticks once per second, kept separate from [uiState] so that a clock update only
* recomposes the countdown chips and progress bars instead of the whole screen.
*/
val timeNow: StateFlow<Long> = _timeNow
init { init {
// Refresh indicator: mirrors the repo's isCalculating state // Refresh indicator: mirrors the repo's isCalculating state
viewModelScope.launch { viewModelScope.launch {
@@ -95,39 +111,53 @@ class PassesViewModel(
_uiState.update { it.copy(modes = modes) } _uiState.update { it.copy(modes = modes) }
} }
} }
// Tick loop: restarts on passes change, UTC/DeepSpace changes. Grouping/sun-time // Tick loop, gated on having an observer so it stops while the screen is in the
// computations run once per restart, progress/countdown are calculated every second. // background. Restarts on passes, UTC or DeepSpace changes. Sun times and grouping are
// computed only when the pass list itself changes; the clock alone never rebuilds them.
viewModelScope.launch { viewModelScope.launch {
_uiState.subscriptionCount
.map { count -> count > 0 }
.distinctUntilChanged()
.collectLatest { isObserved ->
if (!isObserved) return@collectLatest
combine( combine(
satelliteRepo.passes, satelliteRepo.passes,
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(), settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged(),
settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged() settingsRepo.passesSettings.map { it.showDeepSpace }.distinctUntilChanged(),
) { passes, isUtc, showDeepSpace -> Triple(passes, isUtc, showDeepSpace) } searchQuery
.collectLatest { (allPasses, isUtc, showDeepSpace) -> ) { passes, isUtc, showDeepSpace, query ->
val filtered = if (showDeepSpace) allPasses PassesInput(passes, isUtc, showDeepSpace, query)
else allPasses.filter { !it.isDeepSpace } }
// Expensive: recompute sun times once per items/UTC change, not every second .collectLatest { (allPasses, isUtc, showDeepSpace, query) ->
val filtered = filterByQuery(
if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace },
query
)
val sunTimes = computeSunTimes(filtered, isUtc) val sunTimes = computeSunTimes(filtered, isUtc)
_uiState.update { it.copy(sunTimes = sunTimes) } var live: List<OrbitalPass>? = null
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val processed = computePassProgress(filtered, timeNow) _timeNow.value = timeNow
val grouped = groupPasses(processed, isUtc) val current = filtered.filter { it.isDeepSpace || timeNow < it.losTime }
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) // Only a pass dropping off the list warrants a regroup
if (live == null || current.size != live.size) {
live = current
_uiState.update { _uiState.update {
it.copy( it.copy(
itemsList = processed, itemsList = current,
groupedPasses = grouped, groupedPasses = groupPasses(current, isUtc),
nextPass = nextPass, sunTimes = sunTimes
nextTime = nextTime,
isNextTimeAos = isAos
) )
} }
}
val nextPass = resolveNextPass(current, timeNow)
_uiState.update { it.copy(nextPass = nextPass) }
delay(1000.milliseconds) delay(1000.milliseconds)
} }
} }
} }
} }
}
fun onAction(action: PassesAction) { fun onAction(action: PassesAction) {
when (action) { when (action) {
@@ -149,8 +179,10 @@ class PassesViewModel(
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog -> PassesAction.ToggleRadiosDialog ->
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) } _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) } is PassesAction.SearchFor -> {
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) } searchQuery.value = action.query
_uiState.update { it.copy(searchQuery = action.query) }
}
} }
} }
@@ -208,43 +240,34 @@ class PassesViewModel(
return ordered return ordered
} }
/** Computes live progress for each pass, filtering out expired ones. */ /** Resolves the next upcoming pass, falling back to the one currently in progress. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> { private fun resolveNextPass(passes: List<OrbitalPass>, timeNow: Long): OrbitalPass {
val result = ArrayList<OrbitalPass>(passList.size) val upcoming = passes.firstOrNull { it.aosTime > timeNow }
for (pass in passList) { if (upcoming != null) return upcoming
if (!pass.isDeepSpace && time > pass.aosTime) { return passes.lastOrNull() ?: defaultPass
val deltaNow = time.minus(pass.aosTime).toFloat()
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
val newProgress = (deltaNow / deltaTotal).round(2)
if (newProgress >= 1.0f) continue
if (newProgress != pass.progress) {
result.add(pass.copy(progress = newProgress))
} else {
result.add(pass)
}
} else {
result.add(pass)
}
}
return result
} }
/** Resolves the next upcoming or active pass and its countdown timer. */ /**
private fun resolveNextPass( * Filters passes by query, mirroring the satellite search: a numeric query matches the
passes: List<OrbitalPass>, * catalog number, otherwise every space-separated token must appear in the name once both
timeNow: Long * sides are normalized, so "ao7" matches "AO-7 (AMSAT-OSCAR 7)".
): Triple<OrbitalPass, String, Boolean> { */
val upcoming = passes.firstOrNull { it.aosTime > timeNow } private fun filterByQuery(passes: List<OrbitalPass>, query: String): List<OrbitalPass> {
if (upcoming != null) { if (query.isBlank()) return passes
return Triple(upcoming, (upcoming.aosTime - timeNow).toTimerString(), true) val catNum = query.trim().toIntOrNull()
if (catNum != null) return passes.filter { it.catNum == catNum }
val tokens = query.split(' ').map { normalizeForSearch(it) }.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return passes
return passes.filter { pass ->
val normalizedName = normalizeForSearch(pass.name)
tokens.all { normalizedName.contains(it) }
} }
if (passes.isNotEmpty()) {
val lastPass = passes.last()
return Triple(lastPass, (lastPass.losTime - timeNow).toTimerString(), false)
}
return Triple(defaultPass, "00:00:00", true)
} }
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
private fun applyFilter( private fun applyFilter(
hoursAhead: Int, hoursAhead: Int,
minElevation: Double, minElevation: Double,
@@ -30,26 +30,20 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
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.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -67,6 +61,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SearchBar
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -191,55 +186,6 @@ private fun SatellitesScreen(
} }
} }
@Composable
private fun SearchBar(onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
val currentQuery = rememberSaveable { mutableStateOf("") }
val updateQuery = { newValue: String ->
currentQuery.value = newValue
onQueryChange(newValue)
}
ElevatedCard(modifier = modifier.height(48.dp)) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp)
) {
Icon(painter = painterResource(id = R.drawable.ic_search), contentDescription = null)
BasicTextField(
value = currentQuery.value,
onValueChange = updateQuery,
singleLine = true,
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
textStyle = TextStyle(
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
if (currentQuery.value.isEmpty()) {
Text(
text = stringResource(id = R.string.sat_search_hint),
fontSize = 16.sp,
lineHeight = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
innerTextField()
},
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
)
IconButton(onClick = { updateQuery("") }) {
val clearCd = stringResource(R.string.sat_search_clear)
Icon(painter = painterResource(id = R.drawable.ic_close), contentDescription = clearCd)
}
}
}
}
@Composable @Composable
private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) { private fun ModeCard(modes: List<String>, onClick: () -> Unit, modifier: Modifier = Modifier) {
val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ") val modesText = if (modes.isEmpty()) "All" else modes.joinToString(", ")
@@ -372,5 +318,3 @@ private fun SummaryHeader(selectedText: String, availableText: String) {
) )
} }
} }
@@ -495,17 +495,31 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Display preferences: UTC clock + night filter // Data preferences: auto-update + UTC clock
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) { SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it)) onAction(SettingsAction.ToggleUtc(it))
} }
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { }
Spacer(modifier = Modifier.height(4.dp))
// Appearance: light theme + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
onAction(SettingsAction.ToggleLightTheme(it))
}
SwitchTile(
R.string.prefs_other_switch_night_mode,
settings.stateOfNightMode,
enabled = !settings.stateOfLightTheme
) {
onAction(SettingsAction.ToggleNightMode(it)) onAction(SettingsAction.ToggleNightMode(it))
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control // Radar behavior: sweep animation + sensors, followed by the compass offset sliders
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it)) onAction(SettingsAction.ToggleSweep(it))
@@ -515,14 +529,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom // Compass calibration sliders at the bottom
CompassOffsetRow( CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset, labelResId = R.string.prefs_other_compass_offset_az,
value = settings.radarCompassOffset, value = settings.radarCompassOffset,
range = -180f..180f range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) } ) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
@@ -549,7 +558,14 @@ private fun CompassOffsetRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(text = stringResource(id = labelResId)) Text(
text = stringResource(id = labelResId),
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.infiniteMarquee(),
maxLines = 1
)
Text(text = "${value.toInt()}°") Text(text = "${value.toInt()}°")
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -561,19 +577,12 @@ private fun CompassOffsetRow(
} }
@Composable @Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { private fun RowScope.SwitchTile(
Row( labelResId: Int,
horizontalArrangement = Arrangement.SpaceBetween, checked: Boolean,
verticalAlignment = Alignment.CenterVertically, enabled: Boolean = true,
modifier = Modifier.fillMaxWidth() onCheckedChange: (Boolean) -> Unit
) { ) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -581,9 +590,13 @@ private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChan
) { ) {
Text( Text(
text = stringResource(id = labelResId), text = stringResource(id = labelResId),
modifier = Modifier.weight(1f) modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.infiniteMarquee(),
maxLines = 1
) )
Switch(checked = checked, onCheckedChange = onCheckedChange) Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
} }
} }
@@ -127,7 +127,10 @@ class SettingsViewModel(
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) } is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) } is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings {
// Night filter (red-only overlay) is incompatible with the light theme, so turn it off.
it.copy(stateOfLightTheme = action.value, stateOfNightMode = if (action.value) false else it.stateOfNightMode)
}
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) } is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) } is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) } is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
+5 -5
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "447" appVersionCode = "448"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7" appVersionName = "4.4.8"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -12,16 +12,16 @@ jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.4.0" android-gradle-plugin = "9.4.1"
androidx-core-ktx = "1.19.0" androidx-core-ktx = "1.19.1"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.5" androidx-room = "2.8.5"
compose-bom = "2026.09.00" compose-bom = "2026.09.00"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.11.0" compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.7" compose-navigation3 = "1.2.0"
google-ksp = "2.3.11" google-ksp = "2.3.11"
+2 -2
View File
@@ -1,7 +1,7 @@
#Sat Sep 12 11:29:59 BST 2026 #Sun Sep 27 11:54:01 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME