diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 75d7f7df..34e4a12c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ic705Controller import com.rtbishop.look4sat.core.data.framework.NetworkReporter import com.rtbishop.look4sat.core.data.framework.RadioTrackingService +import com.rtbishop.look4sat.core.data.repository.AmSatRepository import com.rtbishop.look4sat.core.data.repository.DatabaseRepo import com.rtbishop.look4sat.core.data.repository.SatelliteRepo import com.rtbishop.look4sat.core.data.repository.SelectionRepo @@ -66,12 +67,14 @@ import okhttp3.OkHttpClient class MainContainer(private val context: Context) : IMainContainer { private val localSource = provideLocalSource() + private val remoteSource = provideRemoteSource() private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") } override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler) override val settingsRepo = provideSettingsRepo() override val selectionRepo = provideSelectionRepo() override val satelliteRepo = provideSatelliteRepo() override val databaseRepo = provideDatabaseRepo() + override val amSatRepo by lazy { AmSatRepository(remoteSource) } override val radioTrackingService: IRadioTrackingService by lazy { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) 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 new file mode 100644 index 00000000..fe0d058a --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -0,0 +1,156 @@ +package com.rtbishop.look4sat.core.data.repository + +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.model.SatStatusPage +import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository +import com.rtbishop.look4sat.core.domain.source.IRemoteSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone + +/** One report from the AMSAT API (data layer model). */ +private data class ApiReport( + val id: String, + val name: String, + val callsign: String, + val report: String, + val gridSquare: String, + val reportedTimeUtcSec: Long +) + +/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */ +class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository { + + private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + 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 + + 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) + } + + /** Parse catalog JSON to list of satellite names */ + private fun parseCatalog(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse reports JSON to list of ApiReport domain objects */ + private fun parseReports(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).mapNotNull { i -> + val o = arr.getJSONObject(i) + val iso = o.optString("reported_time", "") + if (iso.isEmpty()) null else ApiReport( + id = o.optString("id", ""), + name = o.optString("name", ""), + callsign = o.optString("callsign", ""), + report = o.optString("report", ""), + gridSquare = o.optString("grid_square", ""), + reportedTimeUtcSec = parseIsoUtcSec(iso) + ) + } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */ + private fun parseIsoUtcSec(iso: String): Long { + return try { + (isoUtcFormat.parse(iso)?.time ?: 0L) / 1000 + } catch (e: Exception) { + 0L + } + } + + /** Build one SatStatus (6 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 -> + 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 slotStart = nowSec - (slotIdx + 1) * 7200L + val slotEnd = nowSec - slotIdx * 7200L + val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd } + if (inSlot.isEmpty()) { + SatSlot(statusColor = NO_REPORT_GRAY, count = 0) + } else { + val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! + SatSlot( + statusColor = statusColorOf(newest.report), + count = inSlot.size, + reportIds = inSlot.map { it.id } + ) + } + } + val days = (0 until 6).map { d -> + SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12)) + } + SatStatus(name = name, days = days) + } + } + + private fun toSatReport(r: ApiReport): SatReport { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = r.reportedTimeUtcSec * 1000 + val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') + val y = cal.get(Calendar.YEAR) + val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + return SatReport( + id = r.id, + statusText = r.report, + call = r.callsign, + grid = r.gridSquare, + dateUtc = "$y-$mo-$d", + timeUtc = "$hh:$mm UTC" + ) + } + + /** Map status text to color value (for UI rendering). */ + private fun statusColorOf(report: String): Long = when (report.lowercase()) { + "heard", "crew active" -> ACTIVE_BLUE + "telemetry only" -> TLM_ORANGE + "not heard" -> NOT_HEARD_PINK + else -> CONFLICT_DEEP_ORANGE + } + + companion object { + // AMSAT official status colors (from amsat.org/status) + private const val ACTIVE_BLUE = 0xFF648FFF + private const val TLM_ORANGE = 0xFFFFB000 + private const val NOT_HEARD_PINK = 0xFFDC267F + private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100 + private const val NO_REPORT_GRAY = 0xFFC0C0C0 + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 2678abbf..37864bb2 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -58,4 +58,36 @@ class RemoteSource( null } } + + override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/catalog.php") + .header("User-Agent", "Look4Sat") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body.string() + } + } catch (exception: Exception) { + println("RemoteSource getAmSatCatalog exception: $exception") + null + } + } + + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit") + .header("User-Agent", "Look4Sat") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body.string() + } + } catch (exception: Exception) { + println("RemoteSource getAmSatReports exception: $exception") + null + } + } } 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 new file mode 100644 index 00000000..22c9fae8 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -0,0 +1,37 @@ +package com.rtbishop.look4sat.core.domain.model + +/** One satellite status report (AMSAT site tooltip data) */ +data class SatReport( + val id: String, // 报告 ID(a885153) + val statusText: String, // Heard / Telemetry Only / Not Heard ... + val call: String, // 呼号 + val grid: String, // 网格坐标(可为空) + val dateUtc: String, // 2026-08-04 + val timeUtc: String // 2:46-:59 UTC +) + +/** State of one 2-hour slot */ +data class SatSlot( + val statusColor: Long, // ARGB 状态色(-1 = 无报告) + val count: Int, // 报告数量(0 = 无) + val reportIds: List = emptyList() // 该槽报告 ID 列表 +) + +/** One satellite day (12 two-hour slots) */ +data class SatDay( + val dateLabel: String, // "Aug 4" + val slots: List // 12 槽(00-02 ... 22-24) +) + +/** One satellite, 6 days of state */ +data class SatStatus( + val name: String, // "AO-123_[FM]" + val days: List // 6 天(新→旧) +) + +/** Overall page parse result */ +data class SatStatusPage( + val fetchedAtUtcMs: Long, + val statuses: List, + val reports: Map // id → 报告 +) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt new file mode 100644 index 00000000..71d37add --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IAmSatRepository.kt @@ -0,0 +1,9 @@ +package com.rtbishop.look4sat.core.domain.repository + +import com.rtbishop.look4sat.core.domain.model.SatStatusPage + +/** AMSAT satellite status data source */ +interface IAmSatRepository { + /** Fetch and parse the AMSAT status page; null on failure */ + suspend fun fetchStatus(): SatStatusPage? +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 34e3e8fa..5b077abf 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -29,6 +29,7 @@ interface IMainContainer { val selectionRepo: ISelectionRepo val satelliteRepo: ISatelliteRepo val databaseRepo: IDatabaseRepo + val amSatRepo: IAmSatRepository val radioTrackingService: IRadioTrackingService fun provideAddToCalendar(): IAddToCalendar fun provideShowToast(): IShowToast diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index 1ba5d62d..6e1ccfff 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -22,4 +22,6 @@ import java.io.InputStream interface IRemoteSource { suspend fun getFileStream(uri: String): InputStream? suspend fun getNetworkStream(url: String): InputStream? + suspend fun getAmSatCatalog(): String? + suspend fun getAmSatReports(hours: Int, limit: Int): String? } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt index f0864b41..80f4ade9 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt @@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme( onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFF1E1BB), onSecondaryContainer = Color(0xFF221B04), + tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme) + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker) + onTertiaryContainer = Color(0xFF000000), background = Color(0xFFFFF8F0), onBackground = Color(0xFF1E1B13), surface = Color(0xFFFFF8F0), @@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme( onSecondary = Color(0xFF000000), secondaryContainer = Color(0xFF404040), // navBar indicator, onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon -// tertiary = Color(0xFF121212), -// onTertiary = Color(0xFF121212), -// tertiaryContainer = Color(0xFF121212), -// onTertiaryContainer = Color(0xFF121212), + tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status) + onTertiary = Color(0xFF000000), + tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry + onTertiaryContainer = Color(0xFF000000), background = Color(0xFF121212), onBackground = Color(0xFFE0E0E0), surface = Color(0xFF202020), // card background diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 6cc651a1..06189736 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -67,6 +67,21 @@ \n* Replaced the types selection dialog with modes selection to avoid confusion + + AMSAT + AMSAT Satellite Status + Refresh + Retry + Updated: + Active + TLM/Beacon + Not Heard + Conflicting + Name + Load failed - check network and retry + No reports for this period + Close + Back Notify diff --git a/feature/status/build.gradle.kts b/feature/status/build.gradle.kts new file mode 100644 index 00000000..77b30387 --- /dev/null +++ b/feature/status/build.gradle.kts @@ -0,0 +1,17 @@ +import com.android.build.api.dsl.LibraryExtension + +plugins { + alias(libs.plugins.convention.featurePlugin) +} + +android { + namespace = "com.rtbishop.look4sat.feature.status" + compileOptions { + encoding = "UTF-8" + } +} + +dependencies { + implementation(project(":core:domain")) + implementation(project(":core:presentation")) +} 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 new file mode 100644 index 00000000..202cfcde --- /dev/null +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -0,0 +1,370 @@ +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.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.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 +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +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.presentation.R +import com.rtbishop.look4sat.feature.status.R as StatusR +import java.util.Calendar + +/** + * Map AMSAT status text to Material3 colorScheme colors. + * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. + */ +@Composable +private fun statusColorOf(statusText: String): Color { + return when { + statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiary // Active + statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiaryContainer // Telemetry + statusText.contains("Not Heard", ignoreCase = true) -> + Color(0xFFDC267F) // NotHeard pink (no semantic slot) + else -> + MaterialTheme.colorScheme.error // Conflict + } +} + +private val NoReportGray = Color(0xFFC0C0C0) // Neutral state + +@Composable +fun SatStatusScreen(container: IMainContainer) { + val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var selectedDay by remember { mutableStateOf?>(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 8.dp) + ) { + // Top: legend + refresh + StatusHeader( + fetchedAtUtcMs = uiState.fetchedAtUtcMs, + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() } + ) + LegendRow() + + when { + uiState.isLoading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + uiState.error != null && uiState.statuses.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + 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)) + } + } + } + else -> { + // Header + HeaderRow(statuses = uiState.statuses) + HorizontalDivider(thickness = 1.dp) + // Row + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items(uiState.statuses, key = { it.name }) { status -> + StatusRow( + status = status, + onClickDay = { day -> selectedDay = status to day } + ) + } + } + } + } + } + + selectedDay?.let { (status, day) -> + 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" + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.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, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onRefresh), + contentAlignment = Alignment.Center + ) { + if (isRefreshing) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Image( + painter = painterResource(id = StatusR.drawable.ic_refresh), + contentDescription = stringResource(id = R.string.amsat_refresh), + modifier = Modifier + .size(18.dp) + .rotate(angle), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + } + } +} + +/** Legend (official four colors) */ +@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_not_heard) to Color(0xFFDC267F), + stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + legend.forEach { (label, color) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(2.dp)) + .background(color) + ) + Spacer(modifier = Modifier.width(3.dp)) + Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** Header: satellite name + 6 day dates */ +@Composable +private fun HeaderRow(statuses: List) { + val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList() + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.amsat_name), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(2f).padding(start = 4.dp) + ) + dates.forEach { date -> + Text( + text = date, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(0.8f) + ) + } + } +} + +/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */ +@Composable +private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { + val noReportGray = 0xFFC0C0C0L + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = status.name, + fontSize = 11.sp, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + modifier = Modifier.weight(2f).padding(start = 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), + onClick = { onClickDay(day) } + ) + } + } +} + +/** 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) + .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 + ) + } + } +} + +/** Report detail dialog (3 levels: 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 + ) + } + HorizontalDivider(thickness = 0.5.dp) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) } + } + ) +} + + +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) + ) +} 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 new file mode 100644 index 00000000..70788fb3 --- /dev/null +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusViewModel.kt @@ -0,0 +1,94 @@ +package com.rtbishop.look4sat.feature.status + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.IMainContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SatStatusUiState( + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val statuses: List = emptyList(), + val reports: Map = emptyMap(), + val fetchedAtUtcMs: Long = 0L, + val error: String? = null +) + +class SatStatusViewModel( + private val container: IMainContainer +) : ViewModel() { + + private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) + val uiState: StateFlow = _uiState + + init { + fetch() + } + + fun fetch() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + try { + val page = container.amSatRepo.fetchStatus() + if (page != null && page.statuses.isNotEmpty()) { + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + statuses = page.statuses, + reports = page.reports, + fetchedAtUtcMs = page.fetchedAtUtcMs + ) + } + } else { + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") + } + } + } catch (exception: Exception) { + _uiState.update { + it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed") + } + } + } + } + + fun refresh() { + if (_uiState.value.isRefreshing) return + _uiState.update { it.copy(isRefreshing = true, error = null) } + viewModelScope.launch { + try { + val page = container.amSatRepo.fetchStatus() + if (page != null && page.statuses.isNotEmpty()) { + _uiState.update { + it.copy( + isRefreshing = false, + statuses = page.statuses, + reports = page.reports, + fetchedAtUtcMs = page.fetchedAtUtcMs + ) + } + } else { + _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } + } + } catch (exception: Exception) { + _uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") } + } + } + } + + companion object { + fun factory(container: IMainContainer) = viewModelFactory { + initializer { + SatStatusViewModel(container) + } + } + } +} diff --git a/feature/status/src/main/res/drawable/ic_refresh.xml b/feature/status/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..909aa74e --- /dev/null +++ b/feature/status/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 667e5214..42134982 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,5 +25,6 @@ include( ":feature:passes", ":feature:radar", ":feature:satellites", - ":feature:settings" + ":feature:settings", + ":feature:status" )