mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-26 08:07:14 +01:00
* test: make stylesheet tests platform-independent * fix: address Compose UI regressions * fix: complete Compose UI workflow corrections * feat: improve manual QTF heading control
378 lines
13 KiB
Kotlin
378 lines
13 KiB
Kotlin
package kst4contest.view.compose
|
|
|
|
import androidx.compose.foundation.Canvas
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.TooltipArea
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.offset
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.alpha
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.Path
|
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
import androidx.compose.ui.graphics.drawscope.scale
|
|
|
|
import androidx.compose.foundation.onClick
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.rememberTextMeasurer
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.IntOffset
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import kst4contest.model.ContestSked
|
|
import kst4contest.view.i18n.LocalStrings
|
|
import kotlin.math.roundToInt
|
|
|
|
/** The strip is read like a radar screen, so it stays dark in both designs. */
|
|
private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
|
|
private val AXIS_COLOR = Color.Gray
|
|
|
|
/** The glow that marks a target the antenna is centred on. */
|
|
private val GLOW_COLOR = Color(0xFF32CD32)
|
|
|
|
private val MARKER_SIZE = 12.dp
|
|
private val LABEL_GAP = 4.dp
|
|
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
|
|
private val CHIP_BACKGROUND = Color(0xA6000000)
|
|
private val LANE_LABEL_WIDTH = 68.dp
|
|
private val AXIS_LABEL_PADDING = 6.dp
|
|
|
|
/**
|
|
* The strip above the send field: what is coming in the next half hour.
|
|
*
|
|
* The one view in this client that is read as a picture rather than as text. Skeds and
|
|
* priority candidates are placed by time, coloured by how promising the aircraft
|
|
* reflection is, and faded when the antenna points elsewhere — so one glance answers
|
|
* both "what is next" and "could I even hear it right now".
|
|
*
|
|
* Time grows to the right, which means the imminent things are on the LEFT. That is the
|
|
* opposite of a progress bar and it is the original's choice, kept because operators
|
|
* have learned it.
|
|
*
|
|
* Markers are drawn rather than built from widgets: this is a diagram, with no rows and
|
|
* nothing to select. Only the candidates answer a click.
|
|
*/
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun TimelineView(
|
|
state: TimelineState,
|
|
onCandidateClicked: (TimelineCandidate) -> Unit,
|
|
skedTooltipExtra: (ContestSked) -> String? = { null },
|
|
modifier: Modifier = Modifier,
|
|
) {
|
|
val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
|
|
|
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
|
while (true) {
|
|
kotlinx.coroutines.delay(1000)
|
|
nowMillis.value = System.currentTimeMillis()
|
|
}
|
|
}
|
|
|
|
val density = LocalDensity.current
|
|
val measurer = rememberTextMeasurer()
|
|
val strings = LocalStrings.current
|
|
|
|
BoxWithConstraints(
|
|
modifier
|
|
.height(TimelineGeometry.HEIGHT.dp)
|
|
.background(STRIP_BACKGROUND),
|
|
) {
|
|
val widthPx = with(density) { maxWidth.toPx() }
|
|
val labelGutterPx = with(density) { LANE_LABEL_WIDTH.toPx() }.coerceAtMost(widthPx)
|
|
val axisWidthPx = (widthPx - labelGutterPx).coerceAtLeast(0f)
|
|
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
|
|
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
|
|
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
|
|
val tickLabels = TimelineGeometry.minuteTicks(
|
|
axisWidth = axisWidthPx,
|
|
minimumLabelSpacing = measurer.measure("+30 ${strings.timelineMinuteSuffix}", AXIS_TEXT_STYLE)
|
|
.size.width.toFloat() + with(density) { AXIS_LABEL_PADDING.toPx() },
|
|
).map { tick ->
|
|
tick to if (tick.minutes == 0) {
|
|
"0 ${strings.timelineMinuteSuffix}"
|
|
} else {
|
|
"+${tick.minutes} ${strings.timelineMinuteSuffix}"
|
|
}
|
|
}
|
|
|
|
Canvas(Modifier.fillMaxSize()) {
|
|
drawLine(AXIS_COLOR, Offset(labelGutterPx, axisY), Offset(size.width, axisY))
|
|
tickLabels.forEach { (tick, _) ->
|
|
val x = labelGutterPx + tick.x
|
|
drawLine(AXIS_COLOR, Offset(x, axisY - 2.dp.toPx()), Offset(x, axisY + 3.dp.toPx()))
|
|
}
|
|
}
|
|
|
|
val laneLabels = listOf(
|
|
strings.timelineInBeam1,
|
|
strings.timelineInBeam2,
|
|
strings.timelinePriority1,
|
|
strings.timelinePriority2,
|
|
)
|
|
laneLabels.forEachIndexed { index, label ->
|
|
TimelineAxisLabel(label, TimelineGeometry.laneY(index).dp)
|
|
}
|
|
TimelineAxisLabel(strings.timelineSkeds, TimelineGeometry.SKED_Y.dp)
|
|
|
|
tickLabels.forEach { (tick, label) ->
|
|
val labelWidthPx = measurer.measure(label, AXIS_TEXT_STYLE).size.width.toFloat()
|
|
val x = (labelGutterPx + tick.x - labelWidthPx / 2f)
|
|
.coerceIn(labelGutterPx, (widthPx - labelWidthPx).coerceAtLeast(labelGutterPx))
|
|
Text(
|
|
text = label,
|
|
modifier = Modifier.offset {
|
|
IntOffset(x.roundToInt(), with(density) { TimelineGeometry.TICK_LABEL_Y.dp.toPx() }.roundToInt())
|
|
},
|
|
style = AXIS_TEXT_STYLE,
|
|
maxLines = 1,
|
|
)
|
|
}
|
|
|
|
// The candidate lanes, above the axis.
|
|
state.candidates.forEach { candidate ->
|
|
val x = TimelineGeometry.xFor(candidate.timeUntilMs, axisWidthPx, candidateMargin)
|
|
?.plus(labelGutterPx)
|
|
?: return@forEach
|
|
|
|
TimelineMarker(
|
|
centerXPx = x,
|
|
topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
|
|
stripWidthPx = widthPx,
|
|
label = candidate.displayCallSign,
|
|
tooltip = candidate.tooltipText,
|
|
level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
|
|
emphasis = state.emphasisFor(candidate.targetAzimuth),
|
|
shape = MarkerShape.CANDIDATE,
|
|
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
|
|
onClick = { onCandidateClicked(candidate) },
|
|
)
|
|
}
|
|
|
|
// The skeds, in their own lane below the axis. In JavaFX they shared the space
|
|
// above it, which is why their labels sat on top of the candidates'.
|
|
val now = nowMillis.value
|
|
state.skeds.forEach { sked ->
|
|
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin)
|
|
?.plus(labelGutterPx)
|
|
?: return@forEach
|
|
|
|
TimelineMarker(
|
|
centerXPx = x,
|
|
topDp = TimelineGeometry.SKED_Y.dp,
|
|
stripWidthPx = widthPx,
|
|
label = "SKED: ${sked.targetChatCallsign}",
|
|
tooltip = skedTooltip(sked, skedTooltipExtra),
|
|
level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
|
|
emphasis = state.emphasisFor(sked.targetAzimuth),
|
|
shape = MarkerShape.SKED,
|
|
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
|
|
onClick = null,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The sked tooltip: who, on what band, in which direction — plus whatever the caller can
|
|
* add about the chance itself.
|
|
*/
|
|
private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
|
|
val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
|
|
val extra = extraProvider(sked)
|
|
return if (extra.isNullOrBlank()) base else "$base\n$extra"
|
|
}
|
|
|
|
private enum class MarkerShape { SKED, CANDIDATE }
|
|
|
|
private val CHIP_TEXT_STYLE = TextStyle(
|
|
color = Color.White,
|
|
fontSize = 9.sp,
|
|
fontWeight = FontWeight.Bold,
|
|
)
|
|
|
|
private val AXIS_TEXT_STYLE = TextStyle(
|
|
color = Color(0xFFB8B8B8),
|
|
fontSize = 8.sp,
|
|
)
|
|
|
|
@Composable
|
|
private fun TimelineAxisLabel(text: String, top: Dp) {
|
|
Text(
|
|
text = text,
|
|
modifier = Modifier
|
|
.offset(x = 4.dp, y = top)
|
|
.width(LANE_LABEL_WIDTH - 8.dp),
|
|
style = AXIS_TEXT_STYLE,
|
|
maxLines = 1,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One marker: the symbol on the time axis and its callsign beside it.
|
|
*
|
|
* The label is never faded, only the symbol is. A ghost marker still has to be
|
|
* readable — that is the whole point of showing a chance in a direction the antenna is
|
|
* not pointing.
|
|
*/
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
private fun TimelineMarker(
|
|
centerXPx: Float,
|
|
topDp: Dp,
|
|
stripWidthPx: Float,
|
|
label: String,
|
|
tooltip: String?,
|
|
level: PotentialLevel,
|
|
emphasis: AntennaEmphasis,
|
|
shape: MarkerShape,
|
|
labelWidthPx: (String) -> Float,
|
|
onClick: (() -> Unit)?,
|
|
) {
|
|
val density = LocalDensity.current
|
|
val markerSizePx = with(density) { MARKER_SIZE.toPx() }
|
|
val gapPx = with(density) { LABEL_GAP.toPx() }
|
|
|
|
val labelWidth = labelWidthPx(label)
|
|
val labelX = TimelineGeometry.labelStartX(
|
|
markerX = centerXPx + markerSizePx / 2f,
|
|
labelWidth = labelWidth,
|
|
stripWidth = stripWidthPx,
|
|
gap = gapPx,
|
|
)
|
|
|
|
val symbol: @Composable () -> Unit = {
|
|
Canvas(
|
|
Modifier
|
|
.size(MARKER_SIZE)
|
|
.alpha(emphasis.iconAlpha),
|
|
) {
|
|
scale(emphasis.scale) {
|
|
if (emphasis.glowing) drawGlow()
|
|
when (shape) {
|
|
MarkerShape.SKED -> drawDiamond(markerColor(level))
|
|
MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Box(
|
|
Modifier.offset {
|
|
IntOffset(
|
|
(centerXPx - markerSizePx / 2f).roundToInt(),
|
|
with(density) { topDp.toPx() }.roundToInt(),
|
|
)
|
|
},
|
|
) {
|
|
val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
|
|
if (tooltip.isNullOrBlank()) {
|
|
Box(clickable) { symbol() }
|
|
} else {
|
|
TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
|
|
Box(clickable) { symbol() }
|
|
}
|
|
}
|
|
}
|
|
|
|
// The label is placed on its own so it can flip sides without moving the symbol.
|
|
Box(
|
|
Modifier.offset {
|
|
IntOffset(
|
|
labelX.roundToInt(),
|
|
with(density) { topDp.toPx() }.roundToInt(),
|
|
)
|
|
},
|
|
) {
|
|
Box(
|
|
Modifier
|
|
.background(CHIP_BACKGROUND, CHIP_SHAPE)
|
|
.padding(horizontal = 4.dp, vertical = 1.dp),
|
|
) {
|
|
Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
|
|
private fun markerColor(level: PotentialLevel): Color = when (level) {
|
|
PotentialLevel.HIGHEST -> Color.Magenta
|
|
PotentialLevel.HIGH -> Color.Red
|
|
PotentialLevel.MEDIUM -> Color.Yellow
|
|
PotentialLevel.LOW -> Color(0xFF00BFFF)
|
|
}
|
|
|
|
/** The diamond of a sked: an appointment, not a chance. */
|
|
private fun DrawScope.drawDiamond(color: Color) {
|
|
val w = size.width
|
|
val h = size.height
|
|
val path = Path().apply {
|
|
moveTo(w / 2f, 0f)
|
|
lineTo(w, h / 2f)
|
|
lineTo(w / 2f, h)
|
|
lineTo(0f, h / 2f)
|
|
close()
|
|
}
|
|
drawPath(path, color)
|
|
}
|
|
|
|
/** A candidate: a downward triangle over a dot, so it reads even at this size. */
|
|
private fun DrawScope.drawCandidate(color: Color) {
|
|
val w = size.width
|
|
val h = size.height
|
|
drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
|
|
val path = Path().apply {
|
|
moveTo(0f, 0f)
|
|
lineTo(w, 0f)
|
|
lineTo(w / 2f, h * 0.85f)
|
|
close()
|
|
}
|
|
drawPath(path, color)
|
|
}
|
|
|
|
/**
|
|
* The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
|
|
* three fading rings, which costs nothing and reads the same at this size.
|
|
*/
|
|
private fun DrawScope.drawGlow() {
|
|
val center = Offset(size.width / 2f, size.height / 2f)
|
|
listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
|
|
drawCircle(
|
|
color = GLOW_COLOR.copy(alpha = alpha),
|
|
radius = size.width / 2f * factor,
|
|
center = center,
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TimelineTooltip(text: String) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
shape = Density.BUTTON_SHAPE,
|
|
tonalElevation = 4.dp,
|
|
) {
|
|
Text(
|
|
text = text,
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
}
|