Integrated the AMSAT status page created by MCKero6423

This commit is contained in:
Arty Bishop committed 2026-08-11 14:05:55 +02:00
1 parent 060fa2dfcd
commit 3f5b48f270
14 files changed
+272 -280

No files matched your search

@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState() observeNightFilterState()
setContent { setContent {
MainTheme(isDarkTheme = true) { MainScreen() } MainTheme(isDarkTheme = true) { NavRoot() }
} }
} }
@@ -25,8 +25,6 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@@ -44,7 +42,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
@@ -70,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider 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.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds 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.radar.RadarDestination
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
import com.rtbishop.look4sat.feature.settings.SettingsDestination import com.rtbishop.look4sat.feature.settings.SettingsDestination
import com.rtbishop.look4sat.feature.status.SatStatusDestination
@Composable @Composable
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()
LaunchedEffect(deeplink) { LaunchedEffect(deeplink) {
deeplink?.let { deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
rootBackStack.add(destination)
}
} }
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() } val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) // 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( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
backStack = rootBackStack, backStack = rootBackStack,
onBack = navigateBack, onBack = navigateBack,
transitionSpec = { slideInTransition }, transitionSpec = { pushTransition },
popTransitionSpec = { slideOutTransition }, popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { slideOutTransition }, predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf( entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) } entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> { entry<RadarDestination> {
Scaffold { innerPadding -> Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack) RadarDestination(navigateUp = navigateBack)
innerPadding.calculateTopPadding()
} }
} }
} }
@@ -120,18 +124,20 @@ fun NavRoot(deeplink: String? = null) {
} }
@Composable @Composable
fun MainScreen(navigateToRadar: () -> Unit = {}) { private 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)
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle() val 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( CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds( LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation, low = otherSettings.lowElevation,
@@ -144,13 +150,18 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val isSelected = when (currentKey) { val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes 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.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings is Screen.Settings -> screen is Screen.Settings
else -> false else -> false
} }
item( item(
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) }, icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) }, label = { Text(stringResource(screen.titleResId)) },
selected = isSelected, selected = isSelected,
onClick = { onClick = {
@@ -179,9 +190,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
popTransitionSpec = { fadeTransition }, popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition }, predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf( entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(), rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator() rememberViewModelStoreNavEntryDecorator()
), ),
entryProvider = entryProvider { entryProvider = entryProvider {
@@ -191,69 +200,71 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) navigateToRadar()
// navigateToRadar()
} }
} }
entry<Screen.Radar> { entry<Screen.Status> { SatStatusDestination() }
RadarDestination(navigateUp = navigateBack) entry<Screen.Map> { MapDestination() }
} entry<Screen.Settings> { SettingsDestination() }
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
} }
) )
// Radio tracking status banner
if (trackingState.isActive) { if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse") TrackingBanner(
val alpha by infiniteTransition.animateFloat( state = trackingState,
initialValue = 1f, targetValue = 0.4f, onClick = {
animationSpec = infiniteRepeatable( val pass = trackingState.currentPass
animation = tween(1000, easing = LinearEasing), if (pass != null) {
repeatMode = RepeatMode.Reverse container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
), label = "pulseAlpha" navigateToRadar()
)
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)
}
} }
.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
)
}
}
@@ -36,6 +36,7 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":feature:radar")) implementation(project(":feature:radar"))
implementation(project(":feature:satellites")) implementation(project(":feature:satellites"))
implementation(project(":feature:settings")) implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive) implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3) implementation(libs.compose.navigation3)
@@ -35,13 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000 val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null 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 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@withContext 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) SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
@@ -52,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
return try { return try {
val arr = JSONObject(json).getJSONArray("data") val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") } (0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
} }
@@ -73,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso) reportedTimeUtcSec = parseIsoUtcSec(iso)
) )
} }
} catch (e: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
} }
@@ -82,22 +84,22 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private fun parseIsoUtcSec(iso: String): Long { private fun parseIsoUtcSec(iso: String): Long {
return try { return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) { } catch (_: Exception) {
0L 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<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> { private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name } val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) 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 utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" "${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
} }
return names.map { name -> 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 slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } 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)) SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
} }
SatStatus(name = name, days = days) SatStatus(name = name, days = days)
@@ -23,10 +23,10 @@ data class SatDay(
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24) val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
) )
/** One satellite, 6 days of state */ /** One satellite, 5 days of state */
data class SatStatus( data class SatStatus(
val name: String, // "AO-123_[FM]" val name: String, // "AO-123_[FM]"
val days: List<SatDay> // 6 天(新→旧) val days: List<SatDay> // 5 天(新→旧)
) )
/** Overall page parse result */ /** Overall page parse result */
@@ -24,13 +24,13 @@ import kotlinx.serialization.Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey { sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable @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 @Serializable
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 object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar) data object Status : Screen(R.drawable.ic_satellite, R.string.nav_status)
@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)
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</vector>
@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24" android:viewportHeight="24">
>
<path <path
android:fillColor="#FF000000" android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" /> android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
</vector> </vector>
@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:pathData="m105.14,114.12a2.86,2.86 90,0 0,1.7 -0.85l19.33,-19.33a2.86,2.86 90,0 0,-0 -4.03l-26.58,-26.62a2.86,2.86 90,0 0,-1.84 -0.81l-7.92,-0.63 5.77,-5.77a2.86,2.86 90,0 0,-0 -4.03l-19.6,-19.64a2.86,2.86 90,0 0,-4.07 -0l-5.77,5.77 -0.63,-7.92a2.86,2.86 90,0 0,-0.81 -1.83l-26.63,-26.58a2.86,2.86 90,0 0,-4.07 -0l-19.29,19.29a2.86,2.86 90,0 0,-0 4.07l26.58,26.62a2.86,2.86 90,0 0,1.83 0.81l7.92,0.63 -5.77,5.77a2.86,2.86 90,0 0,-0 4.07l19.6,19.6a2.86,2.86 90,0 0,4.07 -0l5.77,-5.77 0.63,7.92a2.86,2.86 90,0 0,0.81 1.83l26.63,26.58a2.86,2.86 90,0 0,2.06 0.85,2.86 2.86,90 0,0 0.27,-0zM104.82,107.23 L80.97,83.38 80.16,72.69 85.58,67.27 96.28,68.12 120.13,91.93zM55.33,47.85 L44.63,47 20.78,23.19 36.08,7.89 59.89,31.74 60.74,42.44z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,115.81a2.86,2.86 90,1 0,-0 -5.73c-11.46,-0 -22.88,-4.35 -31.63,-13.11 -8.74,-8.74 -13.1,-20.17 -13.1,-31.61a2.86,2.86 90,1 0,-5.73 -0c-0,12.9 4.94,25.83 14.77,35.66 9.85,9.85 22.77,14.78 35.68,14.78z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,127a2.86,2.86 90,1 0,-0 -5.73c-14.32,-0 -28.61,-5.44 -39.55,-16.37 -10.93,-10.93 -16.38,-25.23 -16.38,-39.54a2.86,2.86 90,1 0,-5.73 -0c-0,15.76 6.03,31.56 18.06,43.59 12.03,12.03 27.82,18.05 43.6,18.05z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,104.7a2.86,2.86 90,1 0,-0 -5.73c-8.62,-0 -17.2,-3.26 -23.78,-9.84 -6.58,-6.58 -9.86,-15.17 -9.86,-23.77a2.86,2.86 90,1 0,-5.73 -0c-0,10.06 3.86,20.15 11.53,27.82 7.67,7.67 17.76,11.52 27.83,11.52z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
<path
android:pathData="m62.65,93.48a2.86,2.86 90,1 0,-0 -5.73c-5.74,-0 -11.45,-2.17 -15.84,-6.56 -4.38,-4.38 -6.56,-10.1 -6.56,-15.83a2.86,2.86 90,1 0,-5.73 -0c-0,7.19 2.76,14.41 8.24,19.88 5.48,5.48 12.69,8.23 19.89,8.23z"
android:fillColor="#ffffff"
android:strokeColor="#00000000"
android:fillType="evenOdd"/>
</vector>
@@ -12,6 +12,7 @@
<string name="nav_radar">Radar</string> <string name="nav_radar">Radar</string>
<string name="nav_map">Map</string> <string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string> <string name="nav_prefs">Settings</string>
<string name="nav_status" translatable="false">AMSAT</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">Modes: %s</string> <string name="sat_type_hint">Modes: %s</string>
@@ -667,7 +667,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
.clickable { onClick() }) { .clickable { onClick() }) {
Spacer(Modifier) Spacer(Modifier)
Icon( Icon(
painter = painterResource(id = R.drawable.ic_satellites), painter = painterResource(id = R.drawable.ic_sputnik),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.primary
) )
@@ -1,34 +1,24 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.core.RepeatMode import androidx.compose.foundation.Image
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.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.size 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,11 +32,15 @@ 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
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus 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.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 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. * Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. * 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 { private fun statusColorOf(statusText: String): Color {
return when { return when {
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> 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) -> statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) ->
MaterialTheme.colorScheme.tertiaryContainer // Telemetry MaterialTheme.colorScheme.tertiaryContainer
statusText.contains("Not Heard", ignoreCase = true) -> statusText.contains("Not Heard", ignoreCase = true) ->
Color(0xFFDC267F) // NotHeard pink (no semantic slot) Color(0xFFDC267F)
else -> else ->
MaterialTheme.colorScheme.error // Conflict MaterialTheme.colorScheme.error
} }
} }
private val NoReportGray = Color(0xFFC0C0C0) // Neutral state
@Composable @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 viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } SatStatusScreen(uiState) { viewModel.refresh() }
}
Column( @Composable
modifier = Modifier private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
.fillMaxSize() var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
.statusBarsPadding() Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
.navigationBarsPadding() StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
.padding(horizontal = 8.dp)
) {
// Top: legend + refresh
StatusHeader(
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() }
)
LegendRow() LegendRow()
when { when {
@@ -113,29 +104,16 @@ fun SatStatusScreen(container: IMainContainer) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(text = stringResource(id = R.string.amsat_load_failed), color = MaterialTheme.colorScheme.error)
text = stringResource(id = R.string.amsat_load_failed), TextButton(onClick = refresh) { Text(text = stringResource(id = R.string.amsat_retry)) }
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = { viewModel.refresh() }) {
Text(text = stringResource(id = R.string.amsat_retry))
}
} }
} }
else -> { else -> {
// Header
HeaderRow(statuses = uiState.statuses) HeaderRow(statuses = uiState.statuses)
HorizontalDivider(thickness = 1.dp) HorizontalDivider(thickness = 1.dp)
// Row LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(uiState.statuses, key = { it.name }) { status -> items(uiState.statuses, key = { it.name }) { status ->
StatusRow( StatusRow(status = status, onClickDay = { day -> selectedDay = status to day })
status = status,
onClickDay = { day -> selectedDay = status to day }
)
} }
} }
} }
@@ -143,56 +121,41 @@ fun SatStatusScreen(container: IMainContainer) {
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog( ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null })
statusName = status.name,
day = day,
reports = uiState.reports,
onDismiss = { selectedDay = null }
)
} }
} }
/** Top: update time + refresh button (spinner while loading) */ /** Top: update time + refresh button (spinner while loading) */
@Composable @Composable
private fun StatusHeader( private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh: () -> Unit) {
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"
)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title), text = if (fetchedAtUtcMs > 0)
fontSize = 13.sp, stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs)
else
stringResource(id = R.string.amsat_title),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Box( Box(
modifier = Modifier modifier = Modifier
.size(34.dp) .size(32.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRefresh), .clickable(onClick = onRefresh),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isRefreshing) { if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else { } else {
Image( Image(
painter = painterResource(id = StatusR.drawable.ic_refresh), painter = painterResource(id = R.drawable.ic_refresh),
contentDescription = stringResource(id = R.string.amsat_refresh), contentDescription = stringResource(id = R.string.amsat_refresh),
modifier = Modifier modifier = Modifier.size(16.dp),
.size(18.dp)
.rotate(angle),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) 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 @Composable
private fun LegendRow() { private fun LegendRow() {
val legend = listOf( val legend = listOf(
stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary, stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer, stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), 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( FlowRow(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
legend.forEach { (label, color) -> 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( Box(
modifier = Modifier modifier = Modifier
.size(10.dp) .size(8.dp)
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(4.dp))
.background(color) .background(color)
) )
Spacer(modifier = Modifier.width(3.dp)) Spacer(modifier = Modifier.width(6.dp))
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) 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 @Composable
private fun HeaderRow(statuses: List<SatStatus>) { private fun HeaderRow(statuses: List<SatStatus>) {
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = stringResource(id = R.string.amsat_name), text = stringResource(id = R.string.amsat_name),
fontSize = 11.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(2f).padding(start = 4.dp) modifier = Modifier.weight(1f).padding(start = 4.dp)
) )
dates.forEach { date -> dates.forEach { date ->
Text( Text(
text = date, text = date,
fontSize = 10.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary, 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 @Composable
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
val noReportGray = 0xFFC0C0C0L val noReportGray = 0xFFC0C0C0L
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
.fillMaxWidth()
.padding(vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = status.name, text = status.name,
fontSize = 11.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(2f).padding(start = 4.dp) modifier = Modifier.weight(1f).padding(end = 4.dp)
) )
status.days.forEach { day -> status.days.forEach { day ->
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
DayCell( DayCell(
slot = slot, slot = slot,
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
onClick = { onClickDay(day) } 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 @Composable
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) { private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
val color = Color(slot.statusColor) val color = Color(slot.statusColor)
Box( Box(
modifier = modifier modifier = modifier
.height(24.dp) .height(28.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(color) .background(color)
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (slot.count > 0) { if (slot.count > 0) {
Text( Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
text = slot.count.toString(),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
} }
} }
} }
/** Report detail dialog (3 levels: callsign/date/time/grid) */ /** Report detail dialog (callsign / date / time / grid) */
@Composable @Composable
private fun ReportDialog( private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) {
statusName: String, val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
day: SatDay, SharedDialog(title = "$statusName · ${day.dateLabel}", onCancel = onDismiss, onAccept = onDismiss) {
reports: Map<String, SatReport>, if (dayReports.isEmpty()) {
onDismiss: () -> Unit Text(stringResource(id = R.string.amsat_no_reports))
) { Spacer(modifier = Modifier.height(8.dp))
val dayReports = day.slots.flatMap { it.reportIds } } else {
.mapNotNull { reports[it] } LazyColumn {
AlertDialog( items(dayReports) { report ->
onDismissRequest = onDismiss, Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
title = { Text(text = "$statusName · ${day.dateLabel}") }, Row(verticalAlignment = Alignment.CenterVertically) {
text = { Box(
if (dayReports.isEmpty()) { modifier = Modifier
Text(stringResource(id = R.string.amsat_no_reports)) .size(8.dp)
} else { .clip(CircleShape)
LazyColumn(modifier = Modifier.height(320.dp)) { .background(statusColorOf(report.statusText))
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
) )
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 { private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.timeInMillis = utcMs cal.timeInMillis = utcMs
return "%02d-%02d %02d:%02d".format( val day = cal.get(Calendar.DAY_OF_MONTH)
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), val month = MONTH_ABBR[cal.get(Calendar.MONTH)]
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE) 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"
} }
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
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.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -22,7 +23,7 @@ data class SatStatusUiState(
) )
class SatStatusViewModel( class SatStatusViewModel(
private val container: IMainContainer private val amSatRepo: IAmSatRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -36,7 +37,7 @@ class SatStatusViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
try { try {
val page = container.amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -65,7 +66,7 @@ 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 = container.amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -87,7 +88,7 @@ class SatStatusViewModel(
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container) SatStatusViewModel(container.amSatRepo)
} }
} }
} }