Compare commits

...
Author SHA1 Message Date
atsunatsu 5c35f2418d chore: bump version to 4.4.6-ba7opf.5 2026-08-28 01:07:26 +08:00
atsunatsu 2aa9858660 feat: open map from radar header
Replace the radar page calendar action with the map icon and route the action through the Passes map flow so the Passes navigation item remains selected.
2026-08-28 00:42:00 +08:00
atsunatsu bc6e065804 feat: prefetch AMSAT status on foreground
Start AMSAT status prefetch when the app enters foreground and share the in-flight request with the status page fetch path to avoid duplicate network requests.
2026-08-28 00:41:32 +08:00
atsunatsu 7924a6f69e feat: cache AMSAT status while app is foreground 2026-08-23 19:27:16 +08:00
10 changed files with 307 additions and 44 deletions

No files matched your search

@@ -17,7 +17,10 @@
*/ */
package com.rtbishop.look4sat package com.rtbishop.look4sat
import android.app.Activity
import android.app.Application import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import com.rtbishop.look4sat.core.data.injection.MainContainer import com.rtbishop.look4sat.core.data.injection.MainContainer
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -35,12 +38,37 @@ class MainApplication : Application(), IContainerProvider {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = MainContainer(this) container = MainContainer(this)
clearAmSatCacheOnAppBackground()
// trigger automatic update every 48 hours // trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() } container.appScope.launch { checkAutoUpdate() }
// load satellite data on every app start // load satellite data on every app start
container.appScope.launch { container.satelliteRepo.initRepository() } container.appScope.launch { container.satelliteRepo.initRepository() }
} }
private fun clearAmSatCacheOnAppBackground() {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var startedActivityCount = 0
override fun onActivityStarted(activity: Activity) {
if (startedActivityCount == 0) {
container.appScope.launch { container.amSatRepo.prefetchStatus() }
}
startedActivityCount += 1
}
override fun onActivityStopped(activity: Activity) {
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
})
}
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) { private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) { if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
@@ -51,6 +51,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.clip import androidx.compose.ui.draw.clip
@@ -91,11 +94,16 @@ import com.rtbishop.look4sat.feature.status.SatStatusDestination
fun NavRoot(deeplink: String? = null) { fun NavRoot(deeplink: String? = null) {
val rootBackStack = rememberNavBackStack(Screen.Passes) val rootBackStack = rememberNavBackStack(Screen.Passes)
val deeplinkResolver = DeeplinkResolver() val deeplinkResolver = DeeplinkResolver()
var openMapRequest by remember { mutableIntStateOf(0) }
LaunchedEffect(deeplink) { LaunchedEffect(deeplink) {
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) } deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
} }
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) } val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val navigateToMap: () -> Unit = {
while (rootBackStack.size > 1) rootBackStack.removeAt(rootBackStack.size - 1)
openMapRequest += 1
}
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style) // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
slideOutHorizontally(tween(300)) { -it / 3 } slideOutHorizontally(tween(300)) { -it / 3 }
@@ -114,13 +122,19 @@ fun NavRoot(deeplink: String? = null) {
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) } entry<Screen.Passes> {
MainScreen(
navigateToRadar = navigateToRadar,
openMapRequest = openMapRequest,
onOpenMapRequestHandled = { openMapRequest = 0 }
)
}
entry<RadarDestination> { entry<RadarDestination> {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack, navigateToMap = navigateToMap)
} }
} }
} }
@@ -128,7 +142,11 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) { fun MainScreen(
navigateToRadar: () -> Unit = {},
openMapRequest: Int = 0,
onOpenMapRequestHandled: () -> Unit = {}
) {
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() } val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
@@ -136,6 +154,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
while (backStack.size > 1) backStack.removeAt(backStack.size - 1) while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Map) backStack.add(Screen.Map)
} }
LaunchedEffect(openMapRequest) {
if (openMapRequest > 0) {
navigateToMap()
onOpenMapRequestHandled()
}
}
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings) val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Mutual, Screen.Settings)
@@ -78,7 +78,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val selectionRepo = provideSelectionRepo() override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo() override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo() override val databaseRepo = provideDatabaseRepo()
override val amSatRepo by lazy { AmSatRepository(remoteSource) } override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
override val radioTrackingService: IRadioTrackingService by lazy { override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -9,7 +9,13 @@ import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -29,28 +35,85 @@ private data class ApiReport(
) )
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */ /** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository { class AmSatRepository(
private val remoteSource: IRemoteSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : IAmSatRepository {
private val statusCacheMutex = Mutex()
@Volatile
private var statusCache: SatStatusPage? = null
@Volatile
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
@Volatile
private var cacheGeneration = 0
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC") timeZone = TimeZone.getTimeZone("UTC")
} }
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { override fun getCachedStatus(): SatStatusPage? = statusCache
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
if (!forceRefresh) statusCache?.let { return it }
var cachedPage: SatStatusPage? = null
val inFlightFetch = statusCacheMutex.withLock {
if (!forceRefresh) {
val cached = statusCache
if (cached != null) {
cachedPage = cached
return@withLock null
}
}
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
fetchStatusFromRemote(cacheGeneration)
}.also { statusFetchInFlight = it }
}
cachedPage?.let { return it }
val fetch = inFlightFetch ?: return null
return try {
fetch.await()
} finally {
statusCacheMutex.withLock {
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
}
}
}
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
val nowSec = System.currentTimeMillis() / 1000 val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null val catalogJson = remoteSource.getAmSatCatalog() ?: return null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send. // 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days. // 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats. // Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
val names = parseCatalog(catalogJson) val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson) val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null if (names.isEmpty() && reports.isEmpty()) return null
val statuses = buildStatuses(names, reports, nowSec) val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) } val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap) return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
if (generation == cacheGeneration) statusCache = page
}
}
override suspend fun prefetchStatus() {
fetchStatus(forceRefresh = false)
}
override fun clearStatusCache() {
cacheGeneration += 1
statusCache = null
statusFetchInFlight = null
} }
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) { override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
@@ -0,0 +1,106 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
class AmSatRepositoryTest {
@Test
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
val cachedPage = SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
repository.seedStatusCache(cachedPage)
val firstPage = repository.fetchStatus()
val secondPage = repository.fetchStatus()
assertSame(cachedPage, firstPage)
assertSame(cachedPage, secondPage)
assertSame(cachedPage, repository.getCachedStatus())
assertEquals(0, remoteSource.catalogRequests)
assertEquals(0, remoteSource.reportRequests)
repository.clearStatusCache()
assertNull(repository.getCachedStatus())
repository.fetchStatus()
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun forceRefreshBypassesCachedPage() = runTest {
val remoteSource = FakeAmSatRemoteSource()
val repository = AmSatRepository(remoteSource)
repository.seedStatusCache(
SatStatusPage(
fetchedAtUtcMs = 123L,
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
reports = emptyMap()
)
)
repository.fetchStatus(forceRefresh = true)
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
val repository = AmSatRepository(remoteSource)
val prefetch = async { repository.prefetchStatus() }
val pageFetch = async { repository.fetchStatus() }
prefetch.await()
val page = pageFetch.await()
assertSame(page, repository.getCachedStatus())
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
cacheField.isAccessible = true
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getAmSatCatalog(): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
catalogRequests += 1
return """{"data":[{"name":"AO-7"}]}"""
}
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return """{"data":[]}"""
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
@@ -6,8 +6,17 @@ import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */ /** AMSAT satellite status data source */
interface IAmSatRepository { interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */ /** Cached AMSAT status page for the current foreground session, if any. */
suspend fun fetchStatus(): SatStatusPage? fun getCachedStatus(): SatStatusPage?
/** Fetch and parse the AMSAT status page; null on failure. */
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
/** Warm the foreground-session cache without forcing a network reload. */
suspend fun prefetchStatus()
/** Clear the foreground-session status cache. */
fun clearStatusCache()
/** Submit a public AMSAT satellite status report. */ /** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
@@ -84,7 +84,7 @@ private enum class RadarPage(val title: String) {
} }
@Composable @Composable
fun RadarDestination(navigateUp: () -> Unit) { fun RadarDestination(navigateUp: () -> Unit, navigateToMap: () -> 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: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -96,6 +96,12 @@ fun RadarDestination(navigateUp: () -> Unit) {
} }
navigateUp() navigateUp()
} }
val navigateToMapAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateToMap()
}
LaunchedEffect(mutualData.endTime) { LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) { while (true) {
@@ -121,7 +127,7 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted)) viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted)) viewModel.onAction(RadarAction.CwPermissionResult(granted))
} }
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = { RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, navigateToMapAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) })
} }
@@ -131,13 +137,11 @@ private fun RadarScreen(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
navigateToMap: () -> Unit,
mutualData: MutualPassData, mutualData: MutualPassData,
requestMicPermission: () -> Unit requestMicPermission: () -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
val addToCalendar: () -> Unit = {
uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
}
// Station-B overlay: full track line (only where B's elevation > 0) + live position dot // Station-B overlay: full track line (only where B's elevation > 0) + live position dot
// at the current moment, same display mode as the local station. // at the current moment, same display mode as the local station.
val trackB = remember(mutualData.trackSamples) { val trackB = remember(mutualData.trackSamples) {
@@ -173,7 +177,7 @@ private fun RadarScreen(
TopBar { TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back) IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = navigateToMap, resId = R.drawable.ic_map)
} }
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) } TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else { } else {
@@ -181,7 +185,7 @@ private fun RadarScreen(
IconCard(action = navigateUp, resId = R.drawable.ic_back) IconCard(action = navigateUp, resId = R.drawable.ic_back)
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 = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = navigateToMap, resId = R.drawable.ic_map)
} }
} }
if (isVertical) { if (isVertical) {
@@ -32,6 +32,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -50,6 +51,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatDay
@@ -106,7 +110,22 @@ fun SatStatusDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DisposableEffect(lifecycleOwner, viewModel) {
var skipInitialStart = true
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_START) {
if (skipInitialStart) {
skipInitialStart = false
} else {
viewModel.fetch()
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
SatStatusScreen( SatStatusScreen(
uiState = uiState, uiState = uiState,
refresh = viewModel::refresh, refresh = viewModel::refresh,
@@ -7,6 +7,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
@@ -51,28 +52,25 @@ class SatStatusViewModel(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val cachedStatusPage = amSatRepo.getCachedStatus()
private val _uiState = MutableStateFlow(cachedStatusPage?.toUiState() ?: SatStatusUiState(isLoading = true))
val uiState: StateFlow<SatStatusUiState> = _uiState val uiState: StateFlow<SatStatusUiState> = _uiState
init { init {
fetch() if (cachedStatusPage == null) fetch()
} }
fun fetch() { fun fetch() {
amSatRepo.getCachedStatus()?.let { page ->
applyStatusPage(page)
return
}
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { applyStatusPage(page)
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else { } else {
_uiState.update { _uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
@@ -91,16 +89,9 @@ class SatStatusViewModel(
_uiState.update { it.copy(isRefreshing = true, error = null) } _uiState.update { it.copy(isRefreshing = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus(forceRefresh = true)
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { applyStatusPage(page)
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else { } else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
} }
@@ -110,6 +101,19 @@ class SatStatusViewModel(
} }
} }
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() { fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign() val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare() val defaultGrid = defaultGridSquare()
@@ -275,6 +279,12 @@ class SatStatusViewModel(
} }
} }
private fun SatStatusPage.toUiState() = SatStatusUiState(
statuses = statuses,
reports = reports,
fetchedAtUtcMs = fetchedAtUtcMs
)
private data class PreparedUpload( private data class PreparedUpload(
val report: String, val report: String,
val callsign: String, val callsign: String,
+2 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "447" appVersionCode = "449"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.3" appVersionName = "4.4.6-ba7opf.5"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry