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.
This commit is contained in:
mckero committed 2026-08-23 02:56:05 +00:00
1 parent 4cb03111bc
commit 984a139a81
16 files changed
+135 -12

No files matched your search

@@ -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
@@ -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(
@@ -173,4 +173,6 @@
<string name="prefs_other_cw_tone_shift_help">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.</string>
<string name="amsat_no_report_legend">Sin informes</string>
<string name="amsat_no_data_legend">Sin datos</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: una franja por franja de 2 horas</string>
<string name="prefs_other_amsat_stripes_help">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.</string>
</resources>
@@ -306,4 +306,6 @@
<string name="prefs_other_cw_tone_shift_help">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.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
<string name="prefs_other_amsat_stripes_help">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.</string>
</resources>
@@ -306,4 +306,6 @@
<string name="prefs_other_cw_tone_shift_help">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.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: satu garis per slot 2 jam</string>
<string name="prefs_other_amsat_stripes_help">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.</string>
</resources>
@@ -173,4 +173,6 @@
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
<string name="amsat_no_report_legend">Нет отчётов</string>
<string name="amsat_no_data_legend">Нет данных</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: полоса на каждые 2 часа</string>
<string name="prefs_other_amsat_stripes_help">Включено — каждый день это двенадцать двухчасовых полос, поэтому перерыв внутри дня виден. Выключено — каждый день это один цвет и число отчётов; цвет соответствует худшему состоянию за день, так что даже один сбой остаётся заметен.</string>
</resources>
@@ -173,4 +173,6 @@
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
<string name="amsat_no_report_legend">වාර්තා නැත</string>
<string name="amsat_no_data_legend">දත්ත නැත</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: පැය 2 කට එක තීරුවක්</string>
<string name="prefs_other_amsat_stripes_help">සක්‍රිය විට සෑම දිනයක් පැය දෙකේ තීරු දොළහකි, එබැවින් දිනක් තුළ ඇති බිඳවැටීම දැකිය හැක. අක්‍රිය විට සෑම දිනයක් එක් වර්ණයක් සහ වාර්තා ගණනකි — වර්ණය එදින නරකම තත්ත්වයයි, එබැවින් එක් අසාර්ථකත්වයක් වුවද පෙනේ.</string>
</resources>
@@ -318,4 +318,6 @@
<string name="prefs_other_cw_tone_shift_help">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.</string>
<string name="amsat_no_report_legend">Rapor yok</string>
<string name="amsat_no_data_legend">Veri yok</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: 2 saatlik dilim başına şerit</string>
<string name="prefs_other_amsat_stripes_help">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.</string>
</resources>
@@ -173,4 +173,6 @@
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
<string name="amsat_no_report_legend">Немає звітів</string>
<string name="amsat_no_data_legend">Немає даних</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: смуга на кожні 2 години</string>
<string name="prefs_other_amsat_stripes_help">Увімкнено — кожен день це дванадцять двогодинних смуг, тож перерва всередині дня видна. Вимкнено — кожен день це один колір і кількість звітів; колір відповідає найгіршому стану за день, тож навіть один збій залишається помітним.</string>
</resources>
@@ -308,4 +308,6 @@
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
<string name="amsat_no_report_legend">无人上报</string>
<string name="amsat_no_data_legend">无数据</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT:每 2 小时一条纹</string>
<string name="prefs_other_amsat_stripes_help">开启时每天画成 12 条两小时条纹,一天之内的中断也看得见。关闭时每天显示一个颜色和报告总数——颜色取当天最差状态,所以出现过一次故障也不会被藏起来。</string>
</resources>
@@ -345,4 +345,6 @@
<string name="prefs_other_cw_tone_shift_help">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.</string>
<string name="amsat_no_report_legend">No report</string>
<string name="amsat_no_data_legend">No data</string>
<string name="prefs_other_switch_amsat_stripes">AMSAT: stripe per 2-hour slot</string>
<string name="prefs_other_amsat_stripes_help">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.</string>
</resources>
@@ -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,
@@ -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
@@ -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) }
@@ -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<SatStatus>) {
/** 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,13 +328,14 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
* the grid.
*/
@Composable
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
Row(
modifier = modifier
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
@@ -307,6 +345,32 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
)
}
}
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
) {
if (count > 0) {
Text(
text = count.toString(),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = readableOn(colour)
)
}
}
}
/** Report detail dialog (callsign / date / time / grid) */
@@ -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<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = 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<SatStatusUiState> = _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)
}
}
}