feat(cw): draw the whole audio band so an out-of-window tone is visible

The waterfall showed only the model's 400-1200 Hz window, so a tone outside it
was absent from the picture entirely. Measured on keyed audio, the brightest
column in that narrow view swings 1.01x between key-down and key-up against
13.76x for a tone in range - it carries no keying at all, so the operator could
not tell a signal was present, let alone where it was. Markers alone could not
fix that: they pointed at a frequency with nothing drawn there.

compute() now takes an optional bin range, defaulting to the model's own, so the
decoder path is byte-identical and the golden-vector test still holds. The
display asks for DC to Nyquist, 129 bins against 65. The FFT already computed
every bin - this only changes which are kept - so the cost is a wider copy.

The decoder window is framed and faintly lifted, since half the picture is now
outside what the model reads and nothing said which half.

Marker fixes found while reviewing the render: the tone marker was orange, which
is a colour the inferno ramp itself passes through, so a marker sitting on the
trace it pointed at was indistinguishable from the keying gaps in that trace -
invisible in exactly the case it existed for. It is cyan now, and both markers
are pips in a gutter above the spectrum rather than lines across it.

Also from the release audit:

- compute()'s bin-count guard was written as a three-term disjunction, which any
  custom range satisfies regardless of bin count, leaving the model invariant
  unenforced for the caller most able to break it. Rewritten as an implication,
  with a Nyquist bound so no range can index past the FFT output.
- signalStrength was gated on a confirmed out-of-window tone, which is false when
  detection fails - and it fails for a slow fist, measured at prominence 2.5
  against a 4.5 threshold for 15% duty. So the meter still read half scale beside
  an empty transcript. It now requires a tone confirmed decodable: 11 flow
  combinations, 3 wrong before, 0 wrong after.
- detectedToneHz never expired, so after retuning into the band the hint kept
  naming the frequency the operator had left, indefinitely. It now clears after
  10 s without a tone, which is clear of any real gap - the longest being 1.7 s
  between words at 5 WPM.
- The waterfall label read estimatedPitch while the hint read detectedToneHz, two
  numbers up to 800 Hz apart both claiming to be the tone. Both read the latter.
- Removed a redundant toFloat() that the compiler warned about.

Accessibility, untouched until now: the waterfall was a bare Canvas and the AMSAT
day cells bare Boxes, so both announced nothing at all - on the status page that
is the entire content of the screen. Both now carry a contentDescription naming
the tone or the day's worst status and report count. The AMSAT tap target goes
from 28 dp to 48 dp with the coloured tile still 28 dp, so the grid keeps its
density. Strings in all nine locales for both modules.
This commit is contained in:
mckero committed 2026-08-23 05:50:59 +00:00
1 parent 984a139a81
commit 10c415fabd
24 files changed
+364 -141

No files matched your search

@@ -98,7 +98,6 @@ fun CwDecodeScreen() {
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val detectedToneHz by decoder.detectedToneHz.collectAsState()
val activeShiftHz by decoder.activeShiftHz.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
@@ -177,7 +176,7 @@ fun CwDecodeScreen() {
CwWaterfallView(
state = waterfall,
signalStrength = signalStrength,
estimatedPitch = estimatedPitch,
detectedToneHz = detectedToneHz,
toneShiftHz = activeShiftHz
)
}
@@ -34,6 +34,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.stringResource
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwToneShifter
import kotlinx.coroutines.flow.MutableStateFlow
@@ -93,7 +96,13 @@ class CwWaterfallState(private val historyRows: Int = 96) {
}
// FFT outside the lock; only the append below needs exclusivity.
val computed = CwDeepSpectrogram.compute(audio)
// The whole band, not just the model's window: a tone outside the window leaves no
// usable trace inside it, so the narrow view showed the operator nothing at all.
val computed = CwDeepSpectrogram.compute(
audio,
CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ,
CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ
)
synchronized(lock) {
// Drop the result when the user cleared the display while this FFT
// was running: those samples belong to the discarded history.
@@ -124,23 +133,38 @@ class CwWaterfallState(private val historyRows: Int = 96) {
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
*
* When [toneShiftHz] is non-zero the decoder is moving a tone into the model's window,
* and two markers say so — otherwise the operator has no way to tell, because this
* picture is of the *raw* audio and an out-of-window tone simply is not in it.
* Green marks where the tone is being delivered to the model; orange marks where it
* actually is, or which edge it lies beyond when that is off-picture.
* Spans the whole audio band, not just the model's window, so a tone the decoder cannot
* read is still in the picture — inside the window such a tone leaves no usable trace at
* all, and the operator could not even tell a signal was present. The window itself is
* framed and the rest dimmed, so it stays clear which part is being decoded.
*
* When [toneShiftHz] is non-zero a tone is being moved into that window: green marks
* where it is being delivered, orange marks [detectedToneHz] where the tone really is.
*/
@Composable
internal fun CwWaterfallView(
state: CwWaterfallState,
signalStrength: Float,
estimatedPitch: Float? = null,
detectedToneHz: Float? = null,
toneShiftHz: Float = 0f,
modifier: Modifier = Modifier
) {
val revision by state.revision.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
// A Canvas announces nothing, so the whole spectrum was silent to a screen reader.
// The tone and whether the decoder can reach it are the facts the picture conveys,
// so they are what the description says.
val hz = detectedToneHz?.roundToInt()
val toneDesc = when {
hz == null || hz <= 0 -> stringResource(R.string.cw_waterfall_idle)
toneShiftHz != 0f -> stringResource(R.string.cw_waterfall_shifted, hz)
CwToneShifter.isInsideWindow(hz.toFloat()) ->
stringResource(R.string.cw_waterfall_inside, hz)
else -> stringResource(R.string.cw_waterfall_outside, hz)
}
val description = stringResource(R.string.cw_waterfall_desc, toneDesc)
Box(modifier = modifier.fillMaxSize().semantics { contentDescription = description }) {
Canvas(modifier = Modifier.fillMaxSize()) {
// Touch the revision inside the draw scope so a new spectrum triggers a
// redraw; without this read the canvas would only ever render once.
@@ -155,11 +179,13 @@ internal fun CwWaterfallView(
if (peak > 0f) {
val rowHeight = size.height / rows.size
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
// From the row itself, not the model's bin count: the display spans the
// whole band and so carries more bins than the model reads.
val binWidth = size.width / rows.first().size
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
// Linear interpolation between adjacent bins via a horizontal
// gradient removes the blocky "pixel" look of 65 discrete columns.
// gradient removes the blocky "pixel" look of discrete columns.
for (bin in 0 until row.size - 1) {
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
@@ -177,7 +203,8 @@ internal fun CwWaterfallView(
// branch above because a shift stays applied through key-up gaps: the
// markers must hold still through them, not blink out whenever the
// picture goes momentarily quiet.
drawToneShiftMarkers(estimatedPitch, toneShiftHz)
drawDecoderWindow()
drawToneShiftMarkers(detectedToneHz, toneShiftHz)
if (signalStrength > 0f) {
drawRect(
@@ -193,13 +220,13 @@ internal fun CwWaterfallView(
// Suppressed for a non-positive pitch, where the readout is an artefact of the
// loudest bin drifting below the shift and printing it would just show nonsense —
// the marker itself still shows the low edge.
if (toneShiftHz != 0f && estimatedPitch != null && estimatedPitch > 0f) {
if (toneShiftHz != 0f && detectedToneHz != null && detectedToneHz > 0f) {
// Halfway is the tipping point, so the text sits nearer the marker it belongs
// to wherever that is — including a pitch on the upper edge, whose line is
// drawn hard against the right of the picture.
val onHighSide = estimatedPitch > TONE_SHIFT_TARGET_HZ
val onHighSide = detectedToneHz > CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ / 2
Text(
text = "${estimatedPitch.roundToInt()} Hz",
text = "${detectedToneHz.roundToInt()} Hz",
fontSize = 9.sp,
color = TONE_ORIGIN_COLOUR,
modifier = Modifier
@@ -220,88 +247,95 @@ internal fun CwWaterfallView(
private val TONE_SHIFT_TARGET_HZ = CwToneShifter.TARGET_HZ.toFloat()
private val TONE_TARGET_COLOUR = Color(0xFF4CD964)
private val TONE_ORIGIN_COLOUR = Color(0xFFFF9500)
/**
* Marker colour for the tone's own frequency.
*
* Cyan, not the orange it used to be: the inferno ramp runs black through purple and
* orange to pale yellow, so an orange marker sitting on the very trace it points at was
* the same hue as that trace and could not be told apart from it. Cyan appears nowhere in
* the ramp.
*/
private val TONE_ORIGIN_COLOUR = Color(0xFF00E5FF)
/**
* Shades the part of the band the model does not read, and marks the tone within it.
*
* The picture spans the whole band while the decoder reads only a window of it, so without
* this the operator cannot tell which half of what they are looking at is being decoded.
*/
private fun DrawScope.drawDecoderWindow() {
val loX = hzToX(CwDeepSpectrogram.MIN_FREQ_HZ.toFloat()) * size.width
val hiX = hzToX(CwDeepSpectrogram.MAX_FREQ_HZ.toFloat()) * size.width
// Lift the readable band rather than darken the rest. The background is already almost
// black, so a dim wash over it moves only a couple of levels and reads as nothing; a
// faint lift inside is visible against it while leaving the trace itself untouched.
drawRect(
color = Color(0xFF7FA8D8).copy(alpha = 0.16f),
topLeft = Offset(loX, 0f),
size = Size(hiX - loX, size.height)
)
val edge = Color(0xFF8FA6C4).copy(alpha = 0.8f)
drawRect(color = edge, topLeft = Offset(loX, 0f), size = Size(1.5f, size.height))
drawRect(color = edge, topLeft = Offset(hiX - 1.5f, 0f), size = Size(1.5f, size.height))
}
/**
* Marks where the shifter is delivering the tone, and where the tone really is.
*
* Draws nothing when no shift is applied: the tone is then inside the window, visible in
* the spectrum on its own, and a marker would only add clutter.
* Draws nothing when no shift is applied: the tone is then inside the window, plainly
* visible in the spectrum on its own, and a marker would only add clutter.
*/
private fun DrawScope.drawToneShiftMarkers(estimatedPitch: Float?, toneShiftHz: Float) {
private fun DrawScope.drawToneShiftMarkers(detectedToneHz: Float?, toneShiftHz: Float) {
if (toneShiftHz == 0f) return
val minHz = CwDeepSpectrogram.MIN_FREQ_HZ.toFloat()
val maxHz = CwDeepSpectrogram.MAX_FREQ_HZ.toFloat()
// The target line is drawn on the strength of the shift alone. A shift being applied
// is the fact worth showing, and it must not depend on the pitch readout: shifting a
// 100 Hz tone up reports a negative pitch whenever the loudest bin drifts low, and
// gating on pitch there put the display straight back to showing nothing at all.
dashedMarkerColumn(hzToX(TONE_SHIFT_TARGET_HZ, minHz, maxHz), TONE_TARGET_COLOUR)
// is the fact worth showing, and it must not depend on the tone readout, which can be
// absent for a weak or slow fist even while a shift stays latched from an earlier scan.
markerBracket(hzToX(TONE_SHIFT_TARGET_HZ), TONE_TARGET_COLOUR)
// A pitch we cannot place: draw only the target line rather than guess a side.
if (estimatedPitch == null || estimatedPitch.isNaN()) return
// No usable tone estimate: the target line alone, rather than a guessed position.
if (detectedToneHz == null || detectedToneHz.isNaN() || detectedToneHz <= 0f) return
// estimatedPitch is already the real pitch — the spectrogram measures the shifted
// audio and the decoder subtracts the shift back out before publishing it. Adding
// the shift again here would land this marker on top of the target one.
if (estimatedPitch in minHz..maxHz) {
dashedMarkerColumn(hzToX(estimatedPitch, minHz, maxHz), TONE_ORIGIN_COLOUR)
return
}
// The tone is genuinely in the picture now, so mark it where it is.
markerBracket(hzToX(detectedToneHz), TONE_ORIGIN_COLOUR)
}
// Beyond the picture, so mark the edge it lies past. Everything is drawn INWARD:
// anything placed outside the canvas is clipped away, which would hide the marker in
// exactly the case it exists for. A non-positive pitch counts as the low side — it
// means the loudest bin landed below the shift, so the tone is at the bottom end.
val onLowSide = estimatedPitch < minHz
val barWidth = 3f
val chevron = 7f
val inward = if (onLowSide) 1f else -1f
val tipX = if (onLowSide) barWidth else size.width - barWidth
/** Height of the strip along the top reserved for frequency markers. */
private const val MARKER_GUTTER_PX = 7f
/**
* A marker pip in the gutter above the spectrum, at [fraction] across.
*
* Kept out of the spectrum rather than drawn across it. A line laid over a CW trace cannot
* be told apart from the keying gaps in that trace, and the marker that matters most sits
* exactly on the tone it points at - so it was invisible in the one place it was needed.
* A pip in its own strip is clear of the signal and still reads against the axis.
*/
private fun DrawScope.markerBracket(fraction: Float, colour: Color) {
val x = (fraction * size.width).coerceIn(1f, size.width - 3f)
drawRect(
color = TONE_ORIGIN_COLOUR.copy(alpha = 0.85f),
topLeft = Offset(if (onLowSide) 0f else size.width - barWidth, 0f),
size = Size(barWidth, size.height)
color = colour,
topLeft = Offset(x - 1f, 0f),
size = Size(3f, MARKER_GUTTER_PX)
)
// Arms open inward from a tip on the bar, so it reads as pointing off-picture.
val midY = size.height / 2f
drawLine(
color = TONE_ORIGIN_COLOUR,
start = Offset(tipX, midY),
end = Offset(tipX + inward * chevron, midY - chevron),
strokeWidth = 2f
)
drawLine(
color = TONE_ORIGIN_COLOUR,
start = Offset(tipX, midY),
end = Offset(tipX + inward * chevron, midY + chevron),
strokeWidth = 2f
// A short stub reaching into the spectrum, so the pip reads as pointing at a
// frequency rather than floating above one, without masking the trace below.
drawRect(
color = colour.copy(alpha = 0.55f),
topLeft = Offset(x, MARKER_GUTTER_PX),
size = Size(1f, MARKER_GUTTER_PX * 0.7f)
)
}
private fun hzToX(hz: Float, minHz: Float, maxHz: Float): Float =
(hz - minHz) / (maxHz - minHz)
/** A dotted vertical line at [fraction] of the width, 0..1 spanning the visible band. */
private fun DrawScope.dashedMarkerColumn(fraction: Float, colour: Color) {
val dashLen = 4f
// Ceiling, not floor: flooring leaves the bottom of the column undrawn.
val dashCount = ceil(size.height / (dashLen * 2)).toInt()
val x = (fraction * size.width).coerceIn(0f, size.width)
for (i in 0 until dashCount) {
val top = i * dashLen * 2
drawLine(
color = colour.copy(alpha = 0.55f),
start = Offset(x, top),
end = Offset(x, (top + dashLen).coerceAtMost(size.height)),
strokeWidth = 1.5f
)
}
/** Fraction across the display for [hz], 0..1 spanning the visible band. */
private fun hzToX(hz: Float): Float {
val lo = CwDeepSpectrogram.DISPLAY_MIN_FREQ_HZ.toFloat()
val hi = CwDeepSpectrogram.DISPLAY_MAX_FREQ_HZ.toFloat()
return (hz - lo) / (hi - lo)
}
/**
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
@@ -2,4 +2,9 @@
<resources>
<string name="cw_tone_shifted_hint">Tono de %1$d Hz trasladado a la ventana de decodificación de 400-1200 Hz</string>
<string name="cw_tone_outside_hint">El tono de %1$d Hz está fuera de la ventana de decodificación de 400-1200 Hz. Active el desplazamiento de tono en Ajustes.</string>
<string name="cw_waterfall_desc">Espectro de cascada, %1$s</string>
<string name="cw_waterfall_idle">aún sin señal</string>
<string name="cw_waterfall_shifted">tono de %1$d hercios, trasladado al rango de decodificación</string>
<string name="cw_waterfall_outside">tono de %1$d hercios, fuera del rango de decodificación</string>
<string name="cw_waterfall_inside">tono de %1$d hercios</string>
</resources>
@@ -8,4 +8,9 @@
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
<string name="cw_waterfall_idle">belum ada sinyal</string>
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string>
</resources>
@@ -8,4 +8,9 @@
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
<string name="cw_tone_shifted_hint">Nada %1$d Hz dipindahkan ke rentang dekode 400-1200 Hz</string>
<string name="cw_tone_outside_hint">Nada %1$d Hz di luar rentang dekode 400-1200 Hz. Aktifkan geser nada di Pengaturan.</string>
<string name="cw_waterfall_desc">Spektrum air terjun, %1$s</string>
<string name="cw_waterfall_idle">belum ada sinyal</string>
<string name="cw_waterfall_shifted">nada %1$d hertz, dipindahkan ke rentang dekode</string>
<string name="cw_waterfall_outside">nada %1$d hertz, di luar rentang dekode</string>
<string name="cw_waterfall_inside">nada %1$d hertz</string>
</resources>
@@ -2,4 +2,9 @@
<resources>
<string name="cw_tone_shifted_hint">Тон %1$d Гц перенесён в окно декодирования 400-1200 Гц</string>
<string name="cw_tone_outside_hint">Тон %1$d Гц находится вне окна декодирования 400-1200 Гц. Включите сдвиг тона в настройках.</string>
<string name="cw_waterfall_desc">Водопадный спектр, %1$s</string>
<string name="cw_waterfall_idle">сигнала пока нет</string>
<string name="cw_waterfall_shifted">тон %1$d герц, перенесён в диапазон декодирования</string>
<string name="cw_waterfall_outside">тон %1$d герц, вне диапазона декодирования</string>
<string name="cw_waterfall_inside">тон %1$d герц</string>
</resources>
@@ -2,4 +2,9 @@
<resources>
<string name="cw_tone_shifted_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවට ගෙන ගියා</string>
<string name="cw_tone_outside_hint">%1$d Hz ස්වරය 400-1200 Hz විකේතන කවුළුවෙන් පිටත. සැකසුම් තුළ ස්වර මාරුව සක්‍රිය කරන්න.</string>
<string name="cw_waterfall_desc">දිය ඇලි වර්ණාවලිය, %1$s</string>
<string name="cw_waterfall_idle">තවම සංඥාවක් නැත</string>
<string name="cw_waterfall_shifted">ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයට ගෙන ගියා</string>
<string name="cw_waterfall_outside">ස්වරය %1$d හර්ට්ස්, විකේතන පරාසයෙන් පිටත</string>
<string name="cw_waterfall_inside">ස්වරය %1$d හර්ට්ස්</string>
</resources>
@@ -8,4 +8,9 @@
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
<string name="cw_tone_shifted_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığına taşındı</string>
<string name="cw_tone_outside_hint">%1$d Hz tonu 400-1200 Hz kod çözme aralığının dışında. Ayarlar\'dan ton kaydırmayı açın.</string>
<string name="cw_waterfall_desc">Şelale spektrumu, %1$s</string>
<string name="cw_waterfall_idle">henüz sinyal yok</string>
<string name="cw_waterfall_shifted">ton %1$d hertz, kod çözme aralığına taşındı</string>
<string name="cw_waterfall_outside">ton %1$d hertz, kod çözme aralığının dışında</string>
<string name="cw_waterfall_inside">ton %1$d hertz</string>
</resources>
@@ -2,4 +2,9 @@
<resources>
<string name="cw_tone_shifted_hint">Тон %1$d Гц перенесено у вікно декодування 400-1200 Гц</string>
<string name="cw_tone_outside_hint">Тон %1$d Гц перебуває поза вікном декодування 400-1200 Гц. Увімкніть зсув тону в налаштуваннях.</string>
<string name="cw_waterfall_desc">Водоспадний спектр, %1$s</string>
<string name="cw_waterfall_idle">сигналу ще немає</string>
<string name="cw_waterfall_shifted">тон %1$d герц, перенесено в діапазон декодування</string>
<string name="cw_waterfall_outside">тон %1$d герц, поза діапазоном декодування</string>
<string name="cw_waterfall_inside">тон %1$d герц</string>
</resources>
@@ -8,4 +8,9 @@
<string name="cw_open_settings">打开应用设置</string>
<string name="cw_tone_shifted_hint">已将 %1$d Hz 音调搬入 400-1200 Hz 解码范围</string>
<string name="cw_tone_outside_hint">%1$d Hz 音调在 400-1200 Hz 解码范围外,请在设置中开启音调搬移。</string>
<string name="cw_waterfall_desc">瀑布频谱图,%1$s</string>
<string name="cw_waterfall_idle">暂无信号</string>
<string name="cw_waterfall_shifted">音调 %1$d 赫兹,已搬入解码范围</string>
<string name="cw_waterfall_outside">音调 %1$d 赫兹,在解码范围外</string>
<string name="cw_waterfall_inside">音调 %1$d 赫兹</string>
</resources>
@@ -8,4 +8,9 @@
<string name="cw_open_settings">Open app settings</string>
<string name="cw_tone_shifted_hint">Tone %1$d Hz moved into the 400-1200 Hz decoder window</string>
<string name="cw_tone_outside_hint">Tone %1$d Hz is outside the 400-1200 Hz decoder window. Enable tone shift in Settings.</string>
<string name="cw_waterfall_desc">Waterfall spectrum, %1$s</string>
<string name="cw_waterfall_idle">no signal yet</string>
<string name="cw_waterfall_shifted">tone at %1$d hertz, moved into the decoder range</string>
<string name="cw_waterfall_outside">tone at %1$d hertz, outside the decoder range</string>
<string name="cw_waterfall_inside">tone at %1$d hertz</string>
</resources>
@@ -40,6 +40,8 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -313,6 +315,22 @@ private fun readableOn(background: Long): Color {
return if (colour.luminance() > 0.4f) Color(0xFF1A1A1A) else Color.White
}
/** The day's worst status colour, used for both the flat tile and the spoken summary. */
private fun worstStatusColour(day: SatDay): Long =
day.slots.map { it.statusColor }
.minByOrNull { SEVERITY.indexOf(it).takeIf { i -> i >= 0 } ?: SEVERITY.size }
?: NO_REPORT_COLOUR
/** Legend string for a status colour, so the grid speaks the same words the legend shows. */
private fun statusLabel(colour: Long): Int = when (colour) {
0xFF648FFF -> R.string.amsat_active
0xFFFFB000 -> R.string.amsat_tlm
0xFFDC267F -> R.string.amsat_not_heard
0xFFFE6100 -> R.string.amsat_conflict
0xFFE8E8E8 -> R.string.amsat_no_data_legend
else -> R.string.amsat_no_report_legend
}
/**
* One day as a stripe per two-hour slot.
*
@@ -329,46 +347,59 @@ private fun readableOn(background: Long): Color {
*/
@Composable
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 }
// Coloured Boxes announce nothing, so the grid - the entire content of this page -
// was silent to a screen reader. The worst status and the report count are what the
// cell conveys either way, and they are also what the tap dialog then expands on.
val worst = worstStatusColour(day)
val total = day.slots.sumOf { it.count }
val description = stringResource(
R.string.amsat_day_desc, day.dateLabel, stringResource(statusLabel(worst)), total
)
// Two layers: the tap target is 48 dp to meet the minimum, while the coloured part
// stays 28 dp so the grid keeps its density. The extra height is transparent padding,
// which is why the row spacing does not change.
Box(
modifier = cell.background(Color(colour)),
modifier = modifier
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) { contentDescription = description }
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (count > 0) {
Text(
text = count.toString(),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = readableOn(colour)
)
val tile = Modifier
.fillMaxWidth()
.height(28.dp)
.clip(RoundedCornerShape(4.dp))
if (stripes) {
Row(modifier = tile) {
day.slots.forEach { slot ->
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(Color(slot.statusColor))
)
}
}
return@Box
}
// 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.
Box(
modifier = tile.background(Color(worst)),
contentAlignment = Alignment.Center
) {
if (total > 0) {
Text(
text = total.toString(),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = readableOn(worst)
)
}
}
}
}