From 3f5b48f27015df6673e37271487b44f03f81ee6b Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Mon, 10 Aug 2026 12:14:26 +0200 Subject: [PATCH] Integrated the AMSAT status page created by MCKero6423 --- .../com/rtbishop/look4sat/MainActivity.kt | 2 +- .../java/com/rtbishop/look4sat/MainScreen.kt | 177 +++++------ .../look4sat/convention/ApplicationPlugin.kt | 1 + .../core/data/repository/AmSatRepository.kt | 24 +- .../look4sat/core/domain/model/SatStatus.kt | 4 +- .../look4sat/core/presentation/Navigation.kt | 4 +- .../src/main/res/drawable/ic_radar.xml | 9 - .../src/main/res/drawable/ic_refresh.xml | 6 +- .../src/main/res/drawable/ic_satellite.xml | 31 ++ .../{ic_satellites.xml => ic_sputnik.xml} | 0 .../src/main/res/values/strings.xml | 1 + .../feature/settings/SettingsScreen.kt | 2 +- .../feature/status/SatStatusScreen.kt | 282 ++++++++---------- .../feature/status/SatStatusViewModel.kt | 9 +- 14 files changed, 272 insertions(+), 280 deletions(-) delete mode 100644 core/presentation/src/main/res/drawable/ic_radar.xml rename {feature/status => core/presentation}/src/main/res/drawable/ic_refresh.xml (75%) create mode 100644 core/presentation/src/main/res/drawable/ic_satellite.xml rename core/presentation/src/main/res/drawable/{ic_satellites.xml => ic_sputnik.xml} (100%) diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt index 3a8ded66..80913a63 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt @@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) observeNightFilterState() setContent { - MainTheme(isDarkTheme = true) { MainScreen() } + MainTheme(isDarkTheme = true) { NavRoot() } } } diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 0efa0fe2..c4345b1a 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold @@ -70,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.rtbishop.look4sat.core.domain.repository.IContainerProvider +import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds @@ -82,37 +81,42 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination import com.rtbishop.look4sat.feature.radar.RadarDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination +import com.rtbishop.look4sat.feature.status.SatStatusDestination @Composable fun NavRoot(deeplink: String? = null) { val rootBackStack = rememberNavBackStack(Screen.Passes) val deeplinkResolver = DeeplinkResolver() LaunchedEffect(deeplink) { - deeplink?.let { - val destination = deeplinkResolver.resolve(it) // rootBackStack.clear() - rootBackStack.add(destination) - } + deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) } } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } - val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) - val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) + val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) } + // Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style) + val pushTransition = slideInHorizontally(tween(350)) { it } togetherWith + slideOutHorizontally(tween(350)) { -it / 3 } + // Reverse: outgoing slides out to the right, incoming drifts in from the left + val popTransition = slideInHorizontally(tween(350)) { -it / 3 } togetherWith + slideOutHorizontally(tween(350)) { it } NavDisplay( modifier = Modifier.fillMaxSize(), backStack = rootBackStack, onBack = navigateBack, - transitionSpec = { slideInTransition }, - popTransitionSpec = { slideOutTransition }, - predictivePopTransitionSpec = { slideOutTransition }, + transitionSpec = { pushTransition }, + popTransitionSpec = { popTransition }, + predictivePopTransitionSpec = { popTransition }, entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry - rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() ), entryProvider = entryProvider { - entry { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) } + entry { MainScreen(navigateToRadar = navigateToRadar) } entry { - Scaffold { innerPadding -> + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { RadarDestination(navigateUp = navigateBack) - innerPadding.calculateTopPadding() } } } @@ -120,18 +124,20 @@ fun NavRoot(deeplink: String? = null) { } @Composable -fun MainScreen(navigateToRadar: () -> Unit = {}) { - val backStack = rememberNavBackStack(Screen.Passes) - val currentKey = backStack.lastOrNull() - val navigateBack: () -> Unit = { backStack.removeLastOrNull() } - val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) - val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings) - +private fun MainScreen(navigateToRadar: () -> Unit = {}) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() + val backStack = rememberNavBackStack(Screen.Passes) + val currentKey = backStack.lastOrNull() + val navigateBack: () -> Unit = { backStack.removeLastOrNull() } + val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith + fadeOut(animationSpec = tween(350)) + val navItems = + listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) + CompositionLocalProvider( LocalElevationThresholds provides ElevationThresholds( low = otherSettings.lowElevation, @@ -144,13 +150,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { val isSelected = when (currentKey) { is Screen.Satellites -> screen is Screen.Satellites is Screen.Passes -> screen is Screen.Passes - is Screen.Radar -> screen is Screen.Radar + is Screen.Status -> screen is Screen.Status is Screen.Map -> screen is Screen.Map is Screen.Settings -> screen is Screen.Settings else -> false } item( - icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, + icon = { + Icon( + painter = painterResource(screen.iconResId), + contentDescription = stringResource(screen.titleResId) + ) + }, label = { Text(stringResource(screen.titleResId)) }, selected = isSelected, onClick = { @@ -179,9 +190,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { popTransitionSpec = { fadeTransition }, predictivePopTransitionSpec = { fadeTransition }, entryDecorators = listOf( - // Required for saving Compose state per entry rememberSaveableStateHolderNavEntryDecorator(), - // Required for ViewModel scoping per entry rememberViewModelStoreNavEntryDecorator() ), entryProvider = entryProvider { @@ -191,69 +200,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) { entry { PassesDestination { catNum, aosTime -> container.satelliteRepo.selectPass(catNum, aosTime) - backStack.add(Screen.Radar) -// navigateToRadar() + navigateToRadar() } } - entry { - RadarDestination(navigateUp = navigateBack) - } - entry { - MapDestination() - } - entry { - SettingsDestination() - } + entry { SatStatusDestination() } + entry { MapDestination() } + entry { SettingsDestination() } } ) - // Radio tracking status banner if (trackingState.isActive) { - val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") - val alpha by infiniteTransition.animateFloat( - initialValue = 1f, targetValue = 0.4f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), label = "pulseAlpha" - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .clickable { - val pass = trackingState.currentPass - if (pass != null) { - container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) - backStack.add(Screen.Radar) - } + TrackingBanner( + state = trackingState, + onClick = { + val pass = trackingState.currentPass + if (pass != null) { + container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) + navigateToRadar() } - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50).copy(alpha = alpha)) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Tracking: ${trackingState.currentPass?.name ?: ""}", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.weight(1f) - ) - val txOk = if (trackingState.txConnected) "TX" else "" - val rxOk = if (trackingState.rxConnected) "RX" else "" - Text( - text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"), - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } + } + ) } } } } } + +@Composable +private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) { + val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse + ), + label = "pulseAlpha" + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50).copy(alpha = alpha)) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Tracking: ${state.currentPass?.name ?: ""}", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.weight(1f) + ) + val connections = listOfNotNull( + "TX".takeIf { state.txConnected }, "RX".takeIf { state.rxConnected } + ) + Text( + text = connections.joinToString("/"), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } +} diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index 1b33372f..cdb95975 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin { implementation(project(":feature:radar")) implementation(project(":feature:satellites")) implementation(project(":feature:settings")) + implementation(project(":feature:status")) implementation(libs.androidx.core.splashscreen) implementation(libs.compose.material3.adaptive) implementation(libs.compose.navigation3) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index fe0d058a..9edc85a3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -35,13 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { val nowSec = System.currentTimeMillis() / 1000 val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null - val reportsJson = remoteSource.getAmSatReports(hours = 168, limit = 500) ?: return@withContext null - + // 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. + // 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 names = parseCatalog(catalogJson) val reports = parseReports(reportsJson) - + if (names.isEmpty() && reports.isEmpty()) return@withContext null - + val statuses = buildStatuses(names, reports, nowSec) val reportMap = reports.associate { it.id to toSatReport(it) } SatStatusPage(System.currentTimeMillis(), statuses, reportMap) @@ -52,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor return try { val arr = JSONObject(json).getJSONArray("data") (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } } @@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor reportedTimeUtcSec = parseIsoUtcSec(iso) ) } - } catch (e: Exception) { + } catch (_: Exception) { emptyList() } } @@ -82,22 +84,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor private fun parseIsoUtcSec(iso: String): Long { return try { (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 - } catch (e: Exception) { + } catch (_: Exception) { 0L } } - /** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */ + /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */ private fun buildStatuses(names: List, reports: List, nowSec: Long): List { val byName = reports.groupBy { it.name } val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) - val labels = (0 until 6).map { d -> + val labels = (0 until 3).map { d -> utc.timeInMillis = (nowSec - d * 86400L) * 1000 "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" } return names.map { name -> - val slots = (0 until 72).map { slotIdx -> + val slots = (0 until 36).map { slotIdx -> val slotStart = nowSec - (slotIdx + 1) * 7200L val slotEnd = nowSec - slotIdx * 7200L val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } @@ -112,7 +114,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor ) } } - val days = (0 until 6).map { d -> + val days = (0 until 3).map { d -> SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) } SatStatus(name = name, days = days) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt index 22c9fae8..d3554047 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -23,10 +23,10 @@ data class SatDay( val slots: List // 12 槽(00-02 ... 22-24) ) -/** One satellite, 6 days of state */ +/** One satellite, 5 days of state */ data class SatStatus( val name: String, // "AO-123_[FM]" - val days: List // 6 天(新→旧) + val days: List // 5 天(新→旧) ) /** Overall page parse result */ diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index b0ff0c1b..4da16eb2 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { @Serializable - data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat) + data object Satellites : Screen(R.drawable.ic_sputnik, R.string.nav_sat) @Serializable data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) @Serializable - data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) + data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status) @Serializable data object Map : Screen(R.drawable.ic_map, R.string.nav_map) diff --git a/core/presentation/src/main/res/drawable/ic_radar.xml b/core/presentation/src/main/res/drawable/ic_radar.xml deleted file mode 100644 index fbfcebb2..00000000 --- a/core/presentation/src/main/res/drawable/ic_radar.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/feature/status/src/main/res/drawable/ic_refresh.xml b/core/presentation/src/main/res/drawable/ic_refresh.xml similarity index 75% rename from feature/status/src/main/res/drawable/ic_refresh.xml rename to core/presentation/src/main/res/drawable/ic_refresh.xml index 909aa74e..9e52eb73 100644 --- a/feature/status/src/main/res/drawable/ic_refresh.xml +++ b/core/presentation/src/main/res/drawable/ic_refresh.xml @@ -1,12 +1,10 @@ - + android:viewportHeight="24"> diff --git a/core/presentation/src/main/res/drawable/ic_satellite.xml b/core/presentation/src/main/res/drawable/ic_satellite.xml new file mode 100644 index 00000000..5fbe7163 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_satellite.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/core/presentation/src/main/res/drawable/ic_satellites.xml b/core/presentation/src/main/res/drawable/ic_sputnik.xml similarity index 100% rename from core/presentation/src/main/res/drawable/ic_satellites.xml rename to core/presentation/src/main/res/drawable/ic_sputnik.xml diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 06189736..3d6250d2 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -12,6 +12,7 @@ Radar Map Settings + AMSAT Modes: %s diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 53594bfb..dd71422f 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -667,7 +667,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version: .clickable { onClick() }) { Spacer(Modifier) Icon( - painter = painterResource(id = R.drawable.ic_satellites), + painter = painterResource(id = R.drawable.ic_sputnik), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 202cfcde..ca301e11 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -1,34 +1,24 @@ package com.rtbishop.look4sat.feature.status -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -42,11 +32,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -55,11 +49,15 @@ import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatStatus -import com.rtbishop.look4sat.core.domain.repository.IMainContainer +import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.presentation.R -import com.rtbishop.look4sat.feature.status.R as StatusR +import com.rtbishop.look4sat.core.presentation.SharedDialog +import com.rtbishop.look4sat.core.presentation.layoutPadding import java.util.Calendar +/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */ +private val TILE_WIDTH: Dp = 64.dp + /** * Map AMSAT status text to Material3 colorScheme colors. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. @@ -68,37 +66,30 @@ import java.util.Calendar private fun statusColorOf(statusText: String): Color { return when { statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> - MaterialTheme.colorScheme.tertiary // Active + MaterialTheme.colorScheme.tertiary statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> - MaterialTheme.colorScheme.tertiaryContainer // Telemetry + MaterialTheme.colorScheme.tertiaryContainer statusText.contains("Not Heard", ignoreCase = true) -> - Color(0xFFDC267F) // NotHeard pink (no semantic slot) + Color(0xFFDC267F) else -> - MaterialTheme.colorScheme.error // Conflict + MaterialTheme.colorScheme.error } } -private val NoReportGray = Color(0xFFC0C0C0) // Neutral state - @Composable -fun SatStatusScreen(container: IMainContainer) { +fun SatStatusDestination() { + val context = LocalContext.current + val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var selectedDay by remember { mutableStateOf?>(null) } + SatStatusScreen(uiState) { viewModel.refresh() } +} - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - .navigationBarsPadding() - .padding(horizontal = 8.dp) - ) { - // Top: legend + refresh - StatusHeader( - fetchedAtUtcMs = uiState.fetchedAtUtcMs, - isRefreshing = uiState.isRefreshing, - onRefresh = { viewModel.refresh() } - ) +@Composable +private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { + var selectedDay by remember { mutableStateOf?>(null) } + Column(modifier = Modifier.fillMaxSize().layoutPadding()) { + StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) LegendRow() when { @@ -113,29 +104,16 @@ fun SatStatusScreen(container: IMainContainer) { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = stringResource(id = R.string.amsat_load_failed), - color = MaterialTheme.colorScheme.error - ) - TextButton(onClick = { viewModel.refresh() }) { - Text(text = stringResource(id = R.string.amsat_retry)) - } + Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error) + TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) } } } else -> { - // Header HeaderRow(statuses = uiState.statuses) HorizontalDivider(thickness = 1.dp) - // Row - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 16.dp) - ) { + LazyColumn(modifier = Modifier.fillMaxSize()) { items(uiState.statuses, key = { it.name }) { status -> - StatusRow( - status = status, - onClickDay = { day -> selectedDay = status to day } - ) + StatusRow(status = status, onClickDay = { day -> selectedDay = status to day }) } } } @@ -143,56 +121,41 @@ fun SatStatusScreen(container: IMainContainer) { } selectedDay?.let { (status, day) -> - ReportDialog( - statusName = status.name, - day = day, - reports = uiState.reports, - onDismiss = { selectedDay = null } - ) + ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null }) } } /** Top: update time + refresh button (spinner while loading) */ @Composable -private fun StatusHeader( - fetchedAtUtcMs: Long, - isRefreshing: Boolean, - onRefresh: () -> Unit -) { - val infiniteTransition = rememberInfiniteTransition(label = "refresh") - val angle by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart), - label = "angle" - ) +private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title), - fontSize = 13.sp, + text = if (fetchedAtUtcMs > 0) + stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) + else + stringResource(id = R.string.amsat_title), + fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f) ) Box( modifier = Modifier - .size(34.dp) + .size(32.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant) .clickable(onClick = onRefresh), contentAlignment = Alignment.Center ) { if (isRefreshing) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) } else { Image( - painter = painterResource(id = StatusR.drawable.ic_refresh), + painter = painterResource(id = R.drawable.ic_refresh), contentDescription = stringResource(id = R.string.amsat_refresh), - modifier = Modifier - .size(18.dp) - .rotate(angle), + modifier = Modifier.size(16.dp), colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) ) } @@ -200,171 +163,164 @@ private fun StatusHeader( } } -/** Legend (official four colors) */ +/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */ @Composable private fun LegendRow() { val legend = listOf( - stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary, - stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer, + stringResource(id = R.string.amsat_active) to Color(0xFF648FFF), + stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000), stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), - stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error + stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100) ) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + FlowRow( + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { legend.forEach { (label, color) -> - Row(verticalAlignment = Alignment.CenterVertically) { + val alphaColor = color.copy(alpha = 0.25f) + Row( + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .background(alphaColor) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier - .size(10.dp) - .clip(RoundedCornerShape(2.dp)) + .size(8.dp) + .clip(RoundedCornerShape(4.dp)) .background(color) ) - Spacer(modifier = Modifier.width(3.dp)) - Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = label, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } -/** Header: satellite name + 6 day dates */ +/** Header: satellite name column + fixed-width date labels aligned to tiles. */ @Composable private fun HeaderRow(statuses: List) { val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(id = R.string.amsat_name), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(2f).padding(start = 4.dp) + modifier = Modifier.weight(1f).padding(start = 4.dp) ) dates.forEach { date -> Text( text = date, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(0.8f) + modifier = Modifier.width(TILE_WIDTH) ) } } } -/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */ +/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */ @Composable private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { val noReportGray = 0xFFC0C0C0L Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = status.name, - fontSize = 11.sp, + fontSize = 14.sp, maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - modifier = Modifier.weight(2f).padding(start = 4.dp) + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 4.dp) ) status.days.forEach { day -> val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() DayCell( slot = slot, - modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), + modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp), onClick = { onClickDay(day) } ) } } } -/** Day block: newest reported status among the day's 12 slots; gray when none */ +/** Day block: newest reported status among the day's 12 slots; gray when none. */ @Composable private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { val color = Color(slot.statusColor) Box( modifier = modifier - .height(24.dp) + .height(28.dp) .clip(RoundedCornerShape(4.dp)) .background(color) .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { if (slot.count > 0) { - Text( - text = slot.count.toString(), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = Color.White - ) + Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White) } } } -/** Report detail dialog (3 levels: callsign/date/time/grid) */ +/** Report detail dialog (callsign / date / time / grid) */ @Composable -private fun ReportDialog( - statusName: String, - day: SatDay, - reports: Map, - onDismiss: () -> Unit -) { - val dayReports = day.slots.flatMap { it.reportIds } - .mapNotNull { reports[it] } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = "$statusName · ${day.dateLabel}") }, - text = { - if (dayReports.isEmpty()) { - Text(stringResource(id = R.string.amsat_no_reports)) - } else { - LazyColumn(modifier = Modifier.height(320.dp)) { - items(dayReports) { report -> - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(statusColorOf(report.statusText)) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = report.statusText, - fontSize = 13.sp, - fontWeight = FontWeight.Bold - ) - } - Text( - text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + - if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant +private fun ReportDialog(statusName: String, day: SatDay, reports: Map, onDismiss: () -> Unit) { + val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } + SharedDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) { + if (dayReports.isEmpty()) { + Text(stringResource(id = R.string.amsat_no_reports)) + Spacer(modifier = Modifier.height(8.dp)) + } else { + LazyColumn { + items(dayReports) { report -> + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(statusColorOf(report.statusText)) ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold) } - HorizontalDivider(thickness = 0.5.dp) + Text( + text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + + if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + HorizontalDivider(thickness = 0.5.dp) } } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) } } - ) + } } +private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private fun formatFetchedAt(utcMs: Long): String { val cal = Calendar.getInstance() cal.timeInMillis = utcMs - return "%02d-%02d %02d:%02d".format( - cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), - cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE) - ) + val day = cal.get(Calendar.DAY_OF_MONTH) + val month = MONTH_ABBR[cal.get(Calendar.MONTH)] + val year = cal.get(Calendar.YEAR) + val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') + val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0') + return "$day$month $year - $hh:$mm:$ss" } diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt index 70788fb3..49371011 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatStatus +import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IMainContainer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,7 +23,7 @@ data class SatStatusUiState( ) class SatStatusViewModel( - private val container: IMainContainer + private val amSatRepo: IAmSatRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) @@ -36,7 +37,7 @@ class SatStatusViewModel( viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } try { - val page = container.amSatRepo.fetchStatus() + val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { _uiState.update { it.copy( @@ -65,7 +66,7 @@ class SatStatusViewModel( _uiState.update { it.copy(isRefreshing = true, error = null) } viewModelScope.launch { try { - val page = container.amSatRepo.fetchStatus() + val page = amSatRepo.fetchStatus() if (page != null && page.statuses.isNotEmpty()) { _uiState.update { it.copy( @@ -87,7 +88,7 @@ class SatStatusViewModel( companion object { fun factory(container: IMainContainer) = viewModelFactory { initializer { - SatStatusViewModel(container) + SatStatusViewModel(container.amSatRepo) } } }