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) } } }