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:
1 parent
4cb03111bc
commit
984a139a81
16 files changed
+143
-20
No files matched your search
+78
-14
@@ -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,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user