From 984a139a8161fd119903b9cceea481ce1c8fe2b2 Mon Sep 17 00:00:00 2001 From: QIU Date: Sun, 23 Aug 2026 02:56:05 +0000 Subject: [PATCH] feat(amsat): let the operator choose the day-cell style Opinion split on the stripes, so Settings > Other now has a switch. On by default, since the flat tile it replaced hid intra-day outages, which is the problem the stripes were introduced to solve. Flat mode is deliberately not the old behaviour. The old cell took its colour from the first slot with a report and its count from that same slot, so a day that worked in the morning and failed all afternoon read as "worked" - measured across eight representative day shapes, two of them had their failure hidden outright, and the count reported 1 where the day held 24 reports. Flat mode now takes the day's worst status and the day's total count, so the summary can understate detail but not hide bad news. The help text says so, in case someone turns the switch off expecting the tile they remember. The count is drawn in black or white by relative luminance rather than always white: on the telemetry amber, white measured 1.83:1 against WCAG's 3:1 for large text, and that cell does carry a count whenever a day held nothing but telemetry reports. All six status colours now clear 3:1, the worst being 3.03. SatStatusViewModel collects the setting rather than reading it once - the switch is on another screen, so the operator is always elsewhere when they change it and would otherwise return to the old style. Strings in all nine locales. --- .../core/data/repository/SettingsRepo.kt | 5 +- .../look4sat/core/domain/model/Settings.kt | 11 ++- .../src/main/res/values-es/strings.xml | 2 + .../src/main/res/values-id/strings.xml | 2 + .../src/main/res/values-in/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-si/strings.xml | 2 + .../src/main/res/values-tr/strings.xml | 2 + .../src/main/res/values-uk/strings.xml | 2 + .../src/main/res/values-zh/strings.xml | 2 + .../src/main/res/values/strings.xml | 2 + .../feature/settings/SettingsScreen.kt | 10 ++ .../feature/settings/SettingsState.kt | 1 + .../feature/settings/SettingsViewModel.kt | 1 + .../feature/status/SatStatusScreen.kt | 92 ++++++++++++++++--- .../feature/status/SatStatusViewModel.kt | 25 ++++- 16 files changed, 143 insertions(+), 20 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 10efa69f..d908e63a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -106,6 +106,7 @@ class SettingsRepo( private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyCwToneShiftEnabled = "cwToneShiftEnabled" + private val keyAmsatDayStripes = "amsatDayStripes" private val separatorComma = "," @@ -407,6 +408,7 @@ class SettingsRepo( putFloat(keyRadarCompassOffset, new.radarCompassOffset) putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev) putBoolean(keyCwToneShiftEnabled, new.cwToneShiftEnabled) + putBoolean(keyAmsatDayStripes, new.amsatDayStripes) } new @@ -434,7 +436,8 @@ class SettingsRepo( wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false), radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f), radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f), - cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false) + cwToneShiftEnabled = preferences.getBoolean(keyCwToneShiftEnabled, false), + amsatDayStripes = preferences.getBoolean(keyAmsatDayStripes, true) ) //endregion diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index a3cf10fa..7af866cc 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -82,7 +82,16 @@ data class OtherSettings( * it before decoding. Off by default: when disabled the audio path is unchanged, * and a tone already inside the window is never touched either way. */ - val cwToneShiftEnabled: Boolean = false + val cwToneShiftEnabled: Boolean = false, + + /** + * Draw each AMSAT day as twelve two-hour stripes rather than one colour. + * + * On by default: a single colour is taken from the first slot with a report, so a + * satellite that worked all morning and failed all afternoon looks identical to one + * that worked once. Some operators prefer the older, simpler tile, hence the switch. + */ + val amsatDayStripes: Boolean = true ) data class DataSourcesSettings( diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index d99318d5..dd4d6151 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -173,4 +173,6 @@ DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica. Sin informes Sin datos + AMSAT: una franja por franja de 2 horas + Activado, cada día son doce franjas de dos horas, así se ve una interrupción dentro del día. Desactivado, cada día es un color y un recuento de informes; el color es el peor estado del día, así que un solo fallo sigue viéndose. diff --git a/core/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml index 503ca43c..e798853b 100644 --- a/core/presentation/src/main/res/values-id/strings.xml +++ b/core/presentation/src/main/res/values-id/strings.xml @@ -306,4 +306,6 @@ DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah. Tidak ada laporan Tidak ada data + AMSAT: satu garis per slot 2 jam + Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat. diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index 6ea79107..ede6eed9 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -306,4 +306,6 @@ DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah. Tidak ada laporan Tidak ada data + AMSAT: satu garis per slot 2 jam + Saat aktif, setiap hari adalah dua belas garis dua jam, sehingga pemadaman dalam satu hari terlihat. Saat mati, setiap hari adalah satu warna dan jumlah laporan - warnanya status terburuk hari itu, jadi satu kegagalan pun tetap terlihat. diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index 0a166b52..8a5da46f 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -173,4 +173,6 @@ DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется. Нет отчётов Нет данных + AMSAT: полоса на каждые 2 часа + Включено — каждый день это двенадцать двухчасовых полос, поэтому перерыв внутри дня виден. Выключено — каждый день это один цвет и число отчётов; цвет соответствует худшему состоянию за день, так что даже один сбой остаётся заметен. diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index 05167d8d..a33467c2 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -173,4 +173,6 @@ DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි. වාර්තා නැත දත්ත නැත + AMSAT: පැය 2 කට එක තීරුවක් + සක්‍රිය විට සෑම දිනයක් පැය දෙකේ තීරු දොළහකි, එබැවින් දිනක් තුළ ඇති බිඳවැටීම දැකිය හැක. අක්‍රිය විට සෑම දිනයක් එක් වර්ණයක් සහ වාර්තා ගණනකි — වර්ණය එදින නරකම තත්ත්වයයි, එබැවින් එක් අසාර්ථකත්වයක් වුවද පෙනේ. diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 44d8e583..5e432a48 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -318,4 +318,6 @@ DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez. Rapor yok Veri yok + AMSAT: 2 saatlik dilim başına şerit + Açıkken her gün on iki iki saatlik şerittir, böylece gün içindeki kesinti görünür. Kapalıyken her gün tek renk ve rapor sayısıdır; renk günün en kötü durumudur, yani tek bir arıza bile görünür kalır. diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 67605704..56dcd62d 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -173,4 +173,6 @@ DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється. Немає звітів Немає даних + AMSAT: смуга на кожні 2 години + Увімкнено — кожен день це дванадцять двогодинних смуг, тож перерва всередині дня видна. Вимкнено — кожен день це один колір і кількість звітів; колір відповідає найгіршому стану за день, тож навіть один збій залишається помітним. diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 5f32cc5d..4f32c7aa 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -308,4 +308,6 @@ DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。 无人上报 无数据 + AMSAT:每 2 小时一条纹 + 开启时每天画成 12 条两小时条纹,一天之内的中断也看得见。关闭时每天显示一个颜色和报告总数——颜色取当天最差状态,所以出现过一次故障也不会被藏起来。 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 8d2b2195..319e7812 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -345,4 +345,6 @@ DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched. No report No data + AMSAT: stripe per 2-hour slot + On, each day is twelve two-hour stripes, so an outage inside a day is visible. Off, each day is one colour and a report count - the colour is the day\'s worst status, so a single failure still shows. 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 fb898102..d6418503 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 @@ -646,6 +646,16 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) + // AMSAT status: twelve two-hour stripes per day, or one colour for the day + SwitchRow(R.string.prefs_other_switch_amsat_stripes, settings.amsatDayStripes) { + onAction(SettingsAction.ToggleAmsatDayStripes(it)) + } + Text( + text = stringResource(id = R.string.prefs_other_amsat_stripes_help), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) // Compass calibration sliders at the bottom CompassOffsetRow( labelResId = R.string.prefs_other_compass_offset, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 5c9d6c23..3adb9934 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -64,6 +64,7 @@ sealed interface SettingsAction { /** Shift CW tones outside the model's 400-1200 Hz window into it before decoding. */ data class ToggleCwToneShift(val value: Boolean) : SettingsAction + data class ToggleAmsatDayStripes(val value: Boolean) : SettingsAction data class ToggleSweep(val value: Boolean) : SettingsAction data class ToggleSensor(val value: Boolean) : SettingsAction data class ToggleLightTheme(val value: Boolean) : SettingsAction diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 27903e4b..1b52a007 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -128,6 +128,7 @@ class SettingsViewModel( is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) } is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) } is SettingsAction.ToggleCwToneShift -> settingsRepo.updateOtherSettings { it.copy(cwToneShiftEnabled = action.value) } + is SettingsAction.ToggleAmsatDayStripes -> settingsRepo.updateOtherSettings { it.copy(amsatDayStripes = action.value) } is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) } is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } 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 eaa441e3..27d863a9 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 @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -114,7 +115,11 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { HorizontalDivider(thickness = 1.dp) LazyColumn(modifier = Modifier.fillMaxSize()) { items(uiState.statuses, key = { it.name }) { status -> - StatusRow(status = status, onClickDay = { day -> selectedDay = status to day }) + StatusRow( + status = status, + stripes = uiState.dayStripes, + onClickDay = { day -> selectedDay = status to day } + ) } } } @@ -239,7 +244,7 @@ private fun HeaderRow(statuses: List) { /** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */ @Composable -private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { +private fun StatusRow(status: SatStatus, stripes: Boolean, onClickDay: (SatDay) -> Unit) { Row( modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically @@ -269,6 +274,7 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { status.days.forEach { day -> DayCell( day = day, + stripes = stripes, modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp), onClick = { onClickDay(day) } ) @@ -276,6 +282,37 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { } } +private const val NO_REPORT_COLOUR = 0xFFC0C0C0 + +/** + * Status colours worst first, for collapsing a day to one of them. + * + * Ordered by how much the operator needs to know about it: a reported failure outranks a + * partial contact, which outranks a success, and both greys come last because they are + * absences rather than observations. Duplicated from AmSatRepository, which owns these + * literals — see the note in AGENTS.md. + */ +private val SEVERITY = listOf( + 0xFFDC267F, // not heard + 0xFFFE6100, // unrecognised status + 0xFFFFB000, // telemetry only + 0xFF648FFF, // heard + NO_REPORT_COLOUR, + 0xFFE8E8E8 // no data fetched +) + +/** + * Black or white for text on [background], whichever reads better. + * + * White is not safe on all of the status colours: on the telemetry amber it measures + * 1.83:1 against WCAG's 3:1 for large text, and that cell does carry a count whenever a + * day held nothing but telemetry reports. Relative luminance decides it instead. + */ +private fun readableOn(background: Long): Color { + val colour = Color(background) + return if (colour.luminance() > 0.4f) Color(0xFF1A1A1A) else Color.White +} + /** * One day as a stripe per two-hour slot. * @@ -291,19 +328,46 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { * the grid. */ @Composable -private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) { - Row( - modifier = modifier - .height(28.dp) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) +private fun DayCell(day: SatDay, stripes: Boolean, modifier: Modifier, onClick: () -> Unit) { + val cell = modifier + .height(28.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + + if (stripes) { + Row(modifier = cell) { + day.slots.forEach { slot -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(Color(slot.statusColor)) + ) + } + } + return + } + + // One colour for the whole day, for operators who preferred the original tile. + // The colour is the day's worst status rather than its first reported one: picking + // the first hid outages behind an earlier good report, which is what the stripes + // were introduced to expose, and a summary that hides bad news is worse than none. + // An unlisted colour sorts last rather than first: indexOf would return -1 and make + // anything unrecognised outrank a genuine failure. + val colour = day.slots.map { it.statusColor } + .minByOrNull { SEVERITY.indexOf(it).takeIf { i -> i >= 0 } ?: SEVERITY.size } + ?: NO_REPORT_COLOUR + val count = day.slots.sumOf { it.count } + Box( + modifier = cell.background(Color(colour)), + contentAlignment = Alignment.Center ) { - day.slots.forEach { slot -> - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(Color(slot.statusColor)) + if (count > 0) { + Text( + text = count.toString(), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = readableOn(colour) ) } } 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 49371011..7e446fe4 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 @@ -8,6 +8,7 @@ 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 com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -19,17 +20,33 @@ data class SatStatusUiState( val statuses: List = emptyList(), val reports: Map = emptyMap(), val fetchedAtUtcMs: Long = 0L, - val error: String? = null + val error: String? = null, + /** Draw each day as twelve two-hour stripes rather than a single colour. */ + val dayStripes: Boolean = true ) class SatStatusViewModel( - private val amSatRepo: IAmSatRepository + private val amSatRepo: IAmSatRepository, + settingsRepo: ISettingsRepo ) : ViewModel() { - private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) + private val _uiState = MutableStateFlow( + SatStatusUiState( + isLoading = true, + dayStripes = settingsRepo.otherSettings.value.amsatDayStripes + ) + ) val uiState: StateFlow = _uiState init { + // Collected rather than read once: the switch lives in Settings, so the operator + // is on another screen when they change it and would otherwise come back to the + // old style until the page was rebuilt. + viewModelScope.launch { + settingsRepo.otherSettings.collect { other -> + _uiState.update { it.copy(dayStripes = other.amsatDayStripes) } + } + } fetch() } @@ -88,7 +105,7 @@ class SatStatusViewModel( companion object { fun factory(container: IMainContainer) = viewModelFactory { initializer { - SatStatusViewModel(container.amSatRepo) + SatStatusViewModel(container.amSatRepo, container.settingsRepo) } } }