Compare commits

..
4 Commits
26 changed files with 370 additions and 269 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+39 -35
View File
@@ -25,50 +25,54 @@ jobs:
java-version: '17' java-version: '17'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v5 uses: gradle/actions/setup-gradle@v6
- name: Assemble Artifacts - name: Assemble Artifacts
run: | run: ./gradlew assembleRelease bundleRelease
./gradlew assembleRelease
./gradlew bundleRelease
- name: Sign APK - name: Sign APK
uses: r0adkll/sign-android-release@v1 run: |
id: sign_apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
with: APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
releaseDirectory: app/build/outputs/apk/release BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
signingKeyBase64: ${{ secrets.KEY_STORE }} ${BUILD_TOOLS}apksigner sign \
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} --ks keystore.jks \
alias: ${{ secrets.KEY_ALIAS }} --ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
keyPassword: ${{ secrets.KEY_PASSWORD }} --ks-key-alias ${{ secrets.KEY_ALIAS }} \
env: --key-pass pass:${{ secrets.KEY_PASSWORD }} \
BUILD_TOOLS_VERSION: "36.0.0" --out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
- name: Sign Bundle - name: Sign Bundle
uses: r0adkll/sign-android-release@v1
id: sign_bundle
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEY_STORE }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename Artifacts
run: | run: |
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Deploy Bundle - name: Setup Ruby
uses: r0adkll/upload-google-play@v1 uses: ruby/setup-ruby@v1
with: with:
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}} ruby-version: '3.3'
packageName: com.rtbishop.look4sat
releaseFiles: app/build/outputs/apk/release/look4sat.aab - name: Deploy Bundle to Google Play
track: production run: |
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release - name: Create Release
run: | run: |
+3 -1
View File
@@ -14,9 +14,11 @@
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -76,11 +76,11 @@ import com.rtbishop.look4sat.feature.settings.SettingsDestination
fun MainScreen() { fun MainScreen() {
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) } val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) // val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) // val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings) val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -138,25 +138,15 @@ fun MainScreen() {
} }
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
backStack.add(Screen.Radar(catNum, aosTime)) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
} }
} }
entry<Screen.Radar> { route -> entry<Screen.Radar> {
RadarDestination( RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) })
catNum = route.catNum,
aosTime = route.aosTime,
navigateUp = navigateBack,
navigateToRadioControl = { catNum, aosTime ->
backStack.add(Screen.RadioControl(catNum, aosTime))
}
)
} }
entry<Screen.RadioControl> { route -> entry<Screen.RadioControl> {
RadioControlDestination( RadioControlDestination(navigateUp = navigateBack)
catNum = route.catNum,
aosTime = route.aosTime,
navigateUp = navigateBack
)
} }
entry<Screen.Map> { entry<Screen.Map> {
MapDestination() MapDestination()
@@ -184,7 +174,8 @@ fun MainScreen() {
.clickable { .clickable {
val pass = trackingState.currentPass val pass = trackingState.currentPass
if (pass != null) { if (pass != null) {
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime)) container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.RadioControl)
} }
} }
.padding(horizontal = 12.dp, vertical = 6.dp) .padding(horizontal = 12.dp, vertical = 6.dp)
@@ -52,6 +52,13 @@ class SatelliteRepo(
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList()) private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites override val satellites: StateFlow<List<OrbitalObject>> = _satellites
private val _selectedPass = MutableStateFlow(0 to 0L)
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
override fun selectPass(catNum: Int, aosTime: Long) {
_selectedPass.value = catNum to aosTime
}
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
@@ -34,6 +34,12 @@ interface ISatelliteRepo {
/** Whether the repo is currently calculating passes. */ /** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean> val isCalculating: StateFlow<Boolean>
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
val selectedPass: StateFlow<Pair<Int, Long>>
/** Set the currently selected pass. */
fun selectPass(catNum: Int, aosTime: Long)
/** Load satellite objects from DB based on the current selection. */ /** Load satellite objects from DB based on the current selection. */
suspend fun initRepository() suspend fun initRepository()
@@ -178,12 +178,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
painter = painterResource(R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp) Text(text = "${pass.altitude} km", fontSize = 15.sp)
} }
Text( Text(
@@ -30,10 +30,10 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable @Serializable
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar) data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable @Serializable
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0) data object RadioControl : Screen(0, 0)
@Serializable @Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,105 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
}
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
},
content = { content() },
onDismiss = {
val targetValue = dismissState.targetValue
when (targetValue) {
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
SwipeToDismissBoxValue.Settled -> {}
}
if (targetValue != SwipeToDismissBoxValue.Settled) {
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
}
}
)
}
@Composable
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
val shape = RectangleShape
Box(
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
modifier = Modifier
.fillMaxSize()
.border(width = 1.dp, shape = shape, color = bgColor)
.dropShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Icon(
painter = painterResource(R.drawable.ic_filter),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.rotate(if (isSwipeRight) 0f else 180f)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
</vector>
@@ -49,10 +49,9 @@
\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">
* Added fixes for Chinese translation, by Mubi-Baihua (#216) * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
\n* Added zipped data sources handling to DatabaseRepo \n\n* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has
\n* Tweaked Passes list to show DeepSpace ones at the top a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
\n* Tweaked RadarView to display sun/moon positions correctly
</string> </string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -1,4 +1,2 @@
* Added fixes for Chinese translation, by Mubi-Baihua (#216) * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
* Added zipped data sources handling to DatabaseRepo * Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
* Tweaked Passes list to show DeepSpace ones at the top
* Tweaked RadarView to display sun/moon positions correctly
@@ -1,4 +0,0 @@
* Added fixes for Chinese translation, by Mubi-Baihua (#216)
* Added zipped data sources handling to DatabaseRepo
* Tweaked Passes list to show DeepSpace ones at the top
* Tweaked RadarView to display sun/moon positions correctly
@@ -123,10 +123,7 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
fun MapDestination() { fun MapDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
modelClass = MapViewModel::class.java,
factory = MapViewModel.factory(container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
@@ -76,7 +76,8 @@ class MapViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc) } _uiState.update { it.copy(isUtc = settings.stateOfUtc) }
} }
} }
selectDefaultSatellite(-1) val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
} }
fun onAction(action: MapAction) { fun onAction(action: MapAction) {
@@ -126,10 +127,16 @@ class MapViewModel(
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
// Scale update interval based on satellite count to avoid excessive CPU usage
val effectiveRate = when {
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
else -> updateFreq
}
while (isActive) { while (isActive) {
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(updateFreq) delay(effectiveRate)
} }
} }
} }
@@ -17,6 +17,11 @@
*/ */
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
@@ -67,6 +72,7 @@ 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.TimerRow 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.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -80,10 +86,7 @@ import java.util.TimeZone
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) { fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
modelClass = PassesViewModel::class.java,
factory = PassesViewModel.factory(container)
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar) PassesScreen(uiState, viewModel::onAction, navigateToRadar)
} }
@@ -143,9 +146,11 @@ private fun PassesScreen(
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc, isUtc = uiState.isUtc,
passes = uiState.itemsList, passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
refreshPasses = { onAction(PassesAction.RefreshPasses) }, onAction = onAction
sunTimes = uiState.sunTimes
) )
} }
} }
@@ -156,33 +161,19 @@ private fun PassesList(
isRefreshing: Boolean, isRefreshing: Boolean,
isUtc: Boolean, isUtc: Boolean,
passes: List<OrbitalPass>, passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit, onAction: (PassesAction) -> Unit
sunTimes: Map<String, Pair<String, String>>
) { ) {
val isVerticalLayout = isVerticalLayout() val isVerticalLayout = isVerticalLayout()
val refreshState = rememberPullToRefreshState() val refreshState = rememberPullToRefreshState()
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
val sdfDate = remember(isUtc) {
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
}
// Deep Space first, then date-grouped timed passes
val groupedPasses = remember(passes, isUtc) {
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["Deep Space"] = deepSpace
passes.filter { !it.isDeepSpace }
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
.forEach { (k, v) -> ordered[k] = v }
ordered
}
ElevatedCard(modifier = Modifier.fillMaxSize()) { ElevatedCard(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = refreshState, state = refreshState,
onRefresh = refreshPasses, onRefresh = { onAction(PassesAction.RefreshPasses) },
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = refreshState, state = refreshState,
@@ -196,20 +187,36 @@ private fun PassesList(
if (passes.isEmpty()) { if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message)) EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else { } else {
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) { AnimatedContent(
for ((dateLabel, dayPasses) in groupedPasses) { targetState = focusedCatNum,
stickyHeader(key = "header_$dateLabel") { transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--") label = "PassesFocusTransition"
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set) ) { targetFocus ->
} LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass -> for ((dateLabel, dayPasses) in groupedPasses) {
PassItem( val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
pass = pass, if (headerVisible) {
navigateToRadar = navigateToRadar, stickyHeader(key = "header_$dateLabel") {
modifier = Modifier.animateItem(), val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
isVerticalLayout = isVerticalLayout, StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
isUtc = isUtc }
) }
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(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
}
} }
} }
} }
@@ -361,13 +368,6 @@ private fun PassItem(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface) Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
} }
Row( Row(
@@ -32,9 +32,10 @@ data class PassesState(
val showDeepSpace: Boolean = true, val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(), val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(), val itemsList: List<OrbitalPass> = emptyList(),
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false, val shouldSeeWhatsNew: Boolean = false,
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group 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 {
@@ -44,4 +45,6 @@ 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 object ClearFocus : PassesAction
} }
@@ -74,37 +74,31 @@ class PassesViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } _uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
} }
} }
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second // Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch { viewModelScope.launch {
var lastSunTimesKey = "" // track when we need to recompute sun times satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace val showDeepSpace = _uiState.value.showDeepSpace
val allPasses = satelliteRepo.passes.value val filtered = allPasses
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace } .let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow) val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
// Recompute per-day sun times only when passes list or UTC setting changes val grouped = groupPasses(processed, isUtc)
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc" _uiState.update {
val sunTimes = if (sunTimesKey != lastSunTimesKey) { it.copy(
lastSunTimesKey = sunTimesKey itemsList = processed,
computeSunTimes(processed, isUtc) groupedPasses = grouped,
} else { sunTimes = sunTimes,
_uiState.value.sunTimes nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
} }
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos,
sunTimes = sunTimes
)
}
delay(1000)
} }
} }
} }
@@ -121,22 +115,24 @@ 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) }
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
} }
} }
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for Deep Space // Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> { private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz } val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz } val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>() val result = LinkedHashMap<String, Pair<String, String>>()
// Deep Space group always shows today's sun times // DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) { if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis()) val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--" val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--" val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["Deep Space"] = rise to set result["DeepSpace (period >225min)"] = rise to set
} }
for (pass in passes) { for (pass in passes) {
if (pass.isDeepSpace) continue if (pass.isDeepSpace) continue
@@ -150,6 +146,18 @@ class PassesViewModel(
return result return result
} }
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
passes.filter { !it.isDeepSpace }
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
.forEach { (k, v) -> ordered[k] = v }
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */ /** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> { private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size) val result = ArrayList<OrbitalPass>(passList.size)
@@ -42,7 +42,6 @@ import androidx.compose.material3.Icon
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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -77,42 +76,23 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
@Composable @Composable
fun RadarDestination( fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
catNum: Int = 0,
aosTime: Long = 0L,
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
modelClass = RadarViewModel::class.java,
key = "$catNum-$aosTime",
factory = RadarViewModel.factory(catNum, aosTime, container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl) RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
} }
@Composable @Composable
private fun RadarScreen( private fun RadarScreen(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateToRadioControl: () -> Unit
navigateToRadioControl: (Int, Long) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
LaunchedEffect(uiState.isLos) { if (uiState.isLos) navigateUp() }
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass -> uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val openRadioControl: () -> Unit = {
uiState.currentPass?.let { pass ->
navigateToRadioControl(pass.catNum, pass.aosTime)
}
} }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -125,7 +105,7 @@ private fun RadarScreen(
TopBar { TopBar {
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios) IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) } TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else { } else {
@@ -133,7 +113,7 @@ private fun RadarScreen(
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc) NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios) IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
} }
if (isVertical) { if (isVertical) {
@@ -43,8 +43,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RadarViewModel( class RadarViewModel(
private val catNum: Int,
private val aosTime: Long,
private val bluetoothReporter: IReporter, private val bluetoothReporter: IReporter,
private val networkReporter: IReporter, private val networkReporter: IReporter,
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -85,26 +83,24 @@ class RadarViewModel(
// Resolve which pass we're tracking and start the tick loop // Resolve which pass we're tracking and start the tick loop
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } val (catNum, aosTime) = satelliteRepo.selectedPass.value
?: passes.firstOrNull() val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass?.let { satPass -> satPass?.let { pass ->
_uiState.update { it.copy(currentPass = satPass) } _uiState.update { it.copy(currentPass = pass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
// Compute track once (it doesn't change for a given pass) // Compute track once (it doesn't change for a given pass)
if (!satPass.isDeepSpace) { if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack( val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
)
_uiState.update { it.copy(satTrack = track) } _uiState.update { it.copy(satTrack = track) }
} }
// Tick loop — position, timer, and radio updates every second // Tick loop — position, timer, and radio updates every second
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow) val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow) val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow) val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !satPass.isDeepSpace && timeNow > satPass.losTime val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update { _uiState.update {
it.copy( it.copy(
currentTime = time, currentTime = time,
@@ -115,7 +111,7 @@ class RadarViewModel(
moonPosition = moonPos moonPosition = moonPos
) )
} }
processRadios(transmitters, satPass.orbitalObject, timeNow) processRadios(transmitters, pass.orbitalObject, timeNow)
sendPassData(pos) sendPassData(pos)
delay(1000) delay(1000)
} }
@@ -213,11 +209,9 @@ class RadarViewModel(
} }
companion object { companion object {
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
RadarViewModel( RadarViewModel(
catNum = catNum,
aosTime = aosTime,
bluetoothReporter = container.provideBluetoothReporter(), bluetoothReporter = container.provideBluetoothReporter(),
networkReporter = container.provideNetworkReporter(), networkReporter = container.provideNetworkReporter(),
satelliteRepo = container.satelliteRepo, satelliteRepo = container.satelliteRepo,
@@ -66,14 +66,10 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Locale import java.util.Locale
@Composable @Composable
fun RadioControlDestination(catNum: Int = 0, aosTime: Long = 0L, navigateUp: () -> Unit) { fun RadioControlDestination(navigateUp: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: RadioControlViewModel = viewModel(factory = RadioControlViewModel.factory(container))
modelClass = RadioControlViewModel::class.java,
key = "$catNum-$aosTime",
factory = RadioControlViewModel.factory(catNum, aosTime, container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, viewModel::onAction, navigateUp) RadioControlScreen(uiState, viewModel::onAction, navigateUp)
} }
@@ -38,8 +38,6 @@ import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
class RadioControlViewModel( class RadioControlViewModel(
private val catNum: Int,
private val aosTime: Long,
private val trackingService: IRadioTrackingService, private val trackingService: IRadioTrackingService,
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
settingsRepo: ISettingsRepo settingsRepo: ISettingsRepo
@@ -73,27 +71,23 @@ class RadioControlViewModel(
// Resolve pass and load transponders // Resolve pass and load transponders
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } val (catNum, aosTime) = satelliteRepo.selectedPass.value
?: passes.firstOrNull() val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass = pass currentPass = satPass
pass?.let { satPass -> satPass?.let { pass ->
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum) val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
transponders = allRadios.filter { it.downlinkLow != null } transponders = allRadios.filter { it.downlinkLow != null }
_uiState.update { _uiState.update { it.copy(currentPass = pass, transponders = transponders) }
it.copy(currentPass = satPass, transponders = transponders)
}
// If service is already tracking this pass, sync the selected transponder // If service is already tracking this pass, sync the selected transponder
val svcState = trackingService.state.value val svcState = trackingService.state.value
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) { if (svcState.isActive && svcState.currentPass?.catNum == pass.catNum) {
_uiState.update { _uiState.update { it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) }
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
}
} }
// Tick loop — timer and satellite position updates every second // Tick loop — timer and satellite position updates every second
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow) val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
currentTime = timeStr, currentTime = timeStr,
@@ -187,11 +181,9 @@ class RadioControlViewModel(
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz) return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
} }
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
RadioControlViewModel( RadioControlViewModel(
catNum = catNum,
aosTime = aosTime,
trackingService = container.radioTrackingService, trackingService = container.radioTrackingService,
satelliteRepo = container.satelliteRepo, satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo settingsRepo = container.settingsRepo
@@ -75,10 +75,7 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
fun SatellitesDestination(navigateUp: () -> Unit) { fun SatellitesDestination(navigateUp: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
modelClass = SatellitesViewModel::class.java,
factory = SatellitesViewModel.factory(container)
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, viewModel::onAction, navigateUp) SatellitesScreen(uiState, viewModel::onAction, navigateUp)
} }
@@ -81,10 +81,7 @@ import java.util.Locale
fun SettingsDestination() { fun SettingsDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.factory(container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction) SettingsScreen(uiState, viewModel::onAction)
} }
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
val gitHubUrl = stringResource(R.string.prefs_github_url) val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url) val licenseUrl = stringResource(R.string.prefs_license_url)
val privacyUrl = stringResource(R.string.prefs_privacy_url) val privacyUrl = stringResource(R.string.prefs_privacy_url)
val safeOpenUri: (String) -> Unit = { url ->
try { uriHandler.openUri(url) } catch (_: Exception) {}
}
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
} }
TopBar { TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f)) BotCard(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
} }
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license) IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f)) BotCard(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
} }
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license) IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} }
} }
+7 -7
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "431" appVersionCode = "432"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.3.1" appVersionName = "4.3.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry #noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -12,21 +12,21 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.2.0" android-gradle-plugin = "9.2.1"
androidx-core-ktx = "1.18.0" androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4" androidx-room = "2.8.4"
compose-bom = "2026.04.01" compose-bom = "2026.05.01"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.10.0" compose-lifecycle = "2.10.0"
compose-navigation3 = "1.1.1" compose-navigation3 = "1.1.2"
google-ksp = "2.3.6" google-ksp = "2.3.7"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlin-coroutines = "1.10.2" kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0" kotlin-serialization = "1.11.0"
other-okhttp = "5.3.2" other-okhttp = "5.3.2"
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Apr 28 15:46:23 BST 2026 #Mon May 25 11:20:48 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME