Apply live beacon settings and improve station map (#164)

Apply beacon checkbox changes at runtime and keep the aggregate status chip synchronized.

Improve explicit station-map navigation, spatial filter overlays, overlay legend, selected-path contrast, and the documented map screenshot.
This commit is contained in:
Praktimarc authored and GitHub committed 2026-11-05 00:52:17 +01:00
1 parent 4d96822c0c
commit d925c83412
61 files changed
+2686 -299

No files matched your search

@@ -319,6 +319,9 @@ map.showPathAnalysis=Streckenanalyse zeigen
map.hidePathAnalysis=Streckenanalyse ausblenden
map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet.
map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen.
map.overlayAntenna=Antenne
map.overlayFilters=Aktive Filter
map.overlayLegendTooltip=Rot: tatsächliche Antennenrichtung, Öffnungswinkel und maximaler QRB. Blau: aktive QTF- und QRB-Filter.
path.title=Strecken- und Geländeanalyse
path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten.
path.fromLocator=Vom Locator:
@@ -328,6 +328,9 @@ map.showPathAnalysis=Show path analysis
map.hidePathAnalysis=Hide path analysis
map.pathAnalysisHidden=Path analysis is hidden.
map.tilesUnavailable=Map tiles unavailable — check the network connection.
map.overlayAntenna=Antenna
map.overlayFilters=Active filters
map.overlayLegendTooltip=Red: actual antenna heading, beamwidth and maximum QRB. Blue: active QTF and QRB filters.
path.title=Path / terrain analysis
path.selectStation=Select a station to prepare path and terrain analysis.
path.fromLocator=From locator:
@@ -3278,6 +3278,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}),
newJoined,
reachability);
composeMainWindowState.getThreadButtons().registerAction(
ChatController.BEACON_STATUS_SOURCE,
() -> {
chatcontroller.toggleUsableBeacons();
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.getThreadButtons().registerStatusProvider(
ChatController.BEACON_STATUS_SOURCE,
chatcontroller::currentBeaconStatus);
chatcontroller.refreshBeaconStatus();
/*
* The feed, not the window construction, owns the timeline's data from here.
@@ -4136,6 +4146,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
});
}
@Override
public void refreshMapDisplaySettings() {
uiDispatcher.runOnUi(() -> {
if (composeMainWindowState == null) {
return;
}
composeMainWindowState.refreshMapDisplaySettings();
forceChatMemberFilterRefresh();
});
}
/**
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
*
@@ -10,6 +10,8 @@ import androidx.compose.runtime.setValue
import kst4contest.controller.On4KstConnectionState
import kst4contest.model.ChatPreferences
import kst4contest.observe.SimpleRoster
import kst4contest.view.compose.map.MapDisplaySettingsState
import kst4contest.view.compose.map.MapNavigationState
/**
* Everything the main window draws, in one object.
@@ -67,6 +69,19 @@ class MainWindowState(
val reachability: ReachabilityState = ReachabilityState(activeBands),
) {
internal val mapNavigation = MapNavigationState()
internal val mapDisplaySettings = MapDisplaySettingsState(prefs)
init {
stationSelection.explicitSelectionListener = { member, source ->
when {
source == StationSelectionSource.RESET_VIEW -> mapNavigation.resetToHome()
member != null -> StationIdentity.of(member)?.let(mapNavigation::fitSelectedPath)
}
}
}
/** Explicit map analysis requested from either station context menu. */
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
@@ -111,9 +126,15 @@ class MainWindowState(
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
fun applyExplicitQtfFilterDirection(qtf: Double) {
mapNavigation.centerOnQtf(qtf)
applyExplicitQtfHeading(prefs, qtf)
}
/** Refreshes map geometry after the Station settings draft has been applied. */
fun refreshMapDisplaySettings() {
mapDisplaySettings.refreshFromPreferences()
}
fun requestLayoutSave() {
layoutSaveRequested?.invoke()
}
@@ -1,7 +1,11 @@
package kst4contest.view.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kst4contest.observe.ObservableValue
import java.util.function.Consumer
@@ -35,3 +39,19 @@ class ObservedValue<T>(private val source: ObservableValue<T>) {
source.removeListener(follower)
}
}
/** Observes a domain value only for the lifetime of the current composition. */
@Composable
internal fun <T> ObservableValue<T>.observeAsState(): State<T?> {
val state = remember(this) { mutableStateOf(get()) }
DisposableEffect(this) {
val follower = Consumer<T> { updated -> state.value = updated }
addListener(follower)
// Close the gap between the initial read and listener registration.
state.value = get()
onDispose { removeListener(follower) }
}
return state
}
@@ -62,6 +62,9 @@ interface SettingsHost {
*/
fun refreshSo2rState()
/** Pushes applied antenna-beam geometry into the open Compose map. */
fun refreshMapDisplaySettings()
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
fun refreshTextSnippetContextMenusFromSettings()
@@ -148,6 +151,8 @@ fun buildSettingsTabs(
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
connectionState = connectionState,
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
refreshBeaconStatus = controller::refreshBeaconStatus,
applyMapDisplaySettings = host::refreshMapDisplaySettings,
)
val notificationState = NotificationTabState(
@@ -185,6 +190,8 @@ fun buildSettingsTabs(
draft,
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
restartBeaconTimer = { controller.restartBeaconTimer() },
setMainBeaconEnabled = controller::setMainBeaconEnabled,
setSecondBeaconEnabled = controller::setSecondBeaconEnabled,
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback),
)
@@ -90,6 +90,13 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
/** Changes only for deliberate station choices, including repeated choices. */
var explicitSelectionRevision: Long by mutableStateOf(0L)
private set
/** Receives deliberate station choices without observing roster reconciliation. */
internal var explicitSelectionListener: ((ChatMember?, StationSelectionSource) -> Unit)? = null
val identity: StationIdentity?
get() = StationIdentity.of(current)
@@ -101,6 +108,10 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
current = row
lastSource = source
selectionRevision++
if (source.isExplicitSelection()) {
explicitSelectionRevision++
explicitSelectionListener?.invoke(row, source)
}
if (row != null && source.requestsStationTableScroll()) {
scrollRequestRevision++
}
@@ -118,6 +129,11 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
}
}
private fun StationSelectionSource.isExplicitSelection(): Boolean = when (this) {
StationSelectionSource.PROGRAMMATIC -> false
else -> true
}
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
StationSelectionSource.STATION_TABLE,
StationSelectionSource.STATION_CONTEXT_MENU,
@@ -7,6 +7,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -25,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -39,11 +41,9 @@ import kst4contest.controller.On4KstConnectionState
* only the indicators are left here — which is also what finally makes the bar wrap
* properly on a narrow window instead of pushing the badges out of sight.
*
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
* status badge must never take the keyboard. The background-thread buttons were ordinary
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
* which is what they always behaved like.
* The connection and notice indicators remain non-clickable. A background-thread status is
* actionable only when the application explicitly registers an action for that source; the
* beacon uses this for its quick switch, while every other worker remains a plain label.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -206,10 +206,16 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
val actionModifier = if (button.hasAction) {
Modifier.clickable(role = Role.Button) { button.activate() }
} else {
Modifier
}
Box(
modifier = Modifier
.defaultMinSize(minHeight = 22.dp)
.background(background, NOTICE_SHAPE)
.then(actionModifier)
.padding(NOTICE_PADDING),
contentAlignment = Alignment.Center,
) {
@@ -176,20 +176,34 @@ object StatusIndicators {
class ThreadStatusButtons {
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
private val actions = LinkedHashMap<String, () -> Unit>()
private val statusProviders = LinkedHashMap<String, () -> ThreadStateMessage>()
private val ordered = mutableStateListOf<ThreadStatusButton>()
val buttons: List<ThreadStatusButton> get() = ordered
fun update(sourceName: String, message: ThreadStateMessage) {
val effectiveMessage = statusProviders[sourceName]?.invoke() ?: message
val button = bySource.getOrPut(sourceName) {
ThreadStatusButton(sourceName).also { ordered.add(it) }
ThreadStatusButton(sourceName, actions[sourceName]).also { ordered.add(it) }
}
button.report(
label = "$sourceName: ${message.runningInformationTextDescription}",
tooltip = message.runningInformation.orEmpty(),
label = "$sourceName: ${effectiveMessage.runningInformationTextDescription}",
tooltip = effectiveMessage.runningInformation.orEmpty(),
)
}
/** Gives one status source an explicit action without changing any other source. */
fun registerAction(sourceName: String, action: () -> Unit) {
actions[sourceName] = action
bySource[sourceName]?.setAction(action)
}
/** Resolves a status only when its queued UI update is actually applied. */
fun registerStatusProvider(sourceName: String, provider: () -> ThreadStateMessage) {
statusProviders[sourceName] = provider
}
companion object {
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
const val HIGHLIGHT_MILLIS = 200L
@@ -197,7 +211,10 @@ class ThreadStatusButtons {
}
/** One background thread's button. */
class ThreadStatusButton(val sourceName: String) {
class ThreadStatusButton(
val sourceName: String,
initialAction: (() -> Unit)? = null,
) {
var label: String by mutableStateOf(sourceName)
private set
@@ -212,6 +229,12 @@ class ThreadStatusButton(val sourceName: String) {
var revision: Int by mutableStateOf(0)
private set
var action: (() -> Unit)? by mutableStateOf(initialAction)
private set
val hasAction: Boolean
get() = action != null
internal fun report(label: String, tooltip: String) {
this.label = label
this.tooltip = tooltip
@@ -222,4 +245,15 @@ class ThreadStatusButton(val sourceName: String) {
fun clearHighlight() {
highlighted = false
}
internal fun setAction(action: () -> Unit) {
this.action = action
}
/** Runs the registered action and reports whether this status is actionable. */
fun activate(): Boolean {
val current = action ?: return false
current()
return true
}
}
@@ -112,10 +112,15 @@ fun ComposeStationMap(
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
selectedTarget: MapCallsignRawSnapshot? = markers.firstOrNull {
it.callSignRaw() == selectedMarkerCallsign
},
spatialFilterOverlay: SpatialFilterOverlayState? = null,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
onCanvasSizeChanged: (Size) -> Unit = { },
modifier: Modifier = Modifier,
) {
val imageCache = remember { TileBitmapCache() }
@@ -147,6 +152,13 @@ fun ComposeStationMap(
* one linear pass over a few hundred markers.
*/
val home = remember(ownLocator6) { homePosition(ownLocator6) }
val selectedTargetOutsideMarkers by remember(markers, selectedTarget) {
derivedStateOf {
selectedTarget?.takeIf { target ->
target.hasUsablePosition() && markers.none { it.callSignRaw() == target.callSignRaw() }
}
}
}
val clusterResult by remember(markers, groupingEnabled, canvasSize) {
derivedStateOf {
StationMapClusterer.cluster(
@@ -205,17 +217,28 @@ fun ComposeStationMap(
Canvas(
modifier = modifier
.clipToBounds()
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
.onSizeChanged {
canvasSize = Size(it.width.toFloat(), it.height.toFloat())
onCanvasSizeChanged(canvasSize)
}
.onGloballyPositioned { canvasCoordinates = it }
.onPointerEvent(PointerEventType.Press) { event ->
if (event.buttons.isSecondaryPressed) {
val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent
val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height)
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
val selectedOutside = selectedTargetOutsideMarkers?.takeIf { marker ->
within(
local,
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val cluster = result.clusters().firstOrNull { candidate ->
within(
local,
viewport.screenX(candidate.centerLon).toFloat(),
viewport.screenXWrapped(candidate.centerLon).toFloat(),
viewport.screenY(candidate.centerLat).toFloat(),
clusterRadiusPx(candidate.size()),
)
@@ -223,12 +246,14 @@ fun ComposeStationMap(
val individual = result.individual().firstOrNull { marker ->
within(
local,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
val targets = selectedOutside?.let(::listOf)
?: cluster?.getMarkers()
?: individual?.let(::listOf)
val window = canvasCoordinates?.localToWindow(local)
if (!targets.isNullOrEmpty() && window != null) {
onMarkerContextRequested?.invoke(
@@ -281,15 +306,28 @@ fun ComposeStationMap(
}
}
}
.pointerInput(markers, groupingEnabled, onMarkerClicked) {
.pointerInput(markers, selectedTargetOutsideMarkers, groupingEnabled, onMarkerClicked) {
detectTapGestures { offset ->
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
val selectedOutside = selectedTargetOutsideMarkers?.takeIf { marker ->
within(
offset,
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
if (selectedOutside != null) {
onMarkerClicked?.invoke(selectedOutside)
return@detectTapGestures
}
val hitCluster = result.clusters().firstOrNull { cluster ->
within(
offset,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenXWrapped(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
@@ -312,14 +350,12 @@ fun ComposeStationMap(
val hit = result.individual().firstOrNull { marker ->
within(
offset,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
if (hit != null) {
onMarkerClicked?.invoke(hit)
}
if (hit != null) onMarkerClicked?.invoke(hit)
}
}
) {
@@ -335,11 +371,34 @@ fun ComposeStationMap(
drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
drawTiles(viewport, imageCache, darkMode)
if (home != null && spatialFilterOverlay != null) {
drawSpatialFilterOverlay(
viewport = viewport,
homeLatitudeDeg = home.first,
homeLongitudeDeg = home.second,
state = spatialFilterOverlay,
darkMode = darkMode,
)
}
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
drawWorkedGrossFields(viewport, workedGrossFields)
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
drawBeamAndPath(
viewport,
home,
antennaAzimuthDeg,
antennaBeamWidthDeg,
maxQrbKm,
selectedTarget,
darkMode,
)
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
selectedTargetOutsideMarkers?.let { target ->
val x = viewport.screenXWrapped(target.longitudeDeg()).toFloat()
val y = viewport.screenY(target.latitudeDeg()).toFloat()
drawStationDot(x, y, selected = true, worked = target.worked(), warning = target.warningToMyDirection())
drawStationLabel(x, y, target.markerLabel(), target.warningToMyDirection(), textMeasurer, darkMode)
}
drawHover(viewport, hoverPosition, clusterResult, selectedTargetOutsideMarkers, textMeasurer)
}
}
@@ -474,8 +533,8 @@ private fun DrawScope.drawMaidenheadGrid(
val verticals = sortedSetOf<Float>()
val horizontals = sortedSetOf<Float>()
for (cell in cells) {
verticals.add(viewport.screenX(cell.westLon()).toFloat())
verticals.add(viewport.screenX(cell.eastLon()).toFloat())
verticals.add(viewport.screenXWrapped(cell.westLon()).toFloat())
verticals.add(viewport.screenXWrapped(cell.eastLon()).toFloat())
horizontals.add(viewport.screenY(cell.northLat()).toFloat())
horizontals.add(viewport.screenY(cell.southLat()).toFloat())
}
@@ -516,7 +575,7 @@ private fun DrawScope.drawMaidenheadGrid(
}
/* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
val centerX = viewport.screenXWrapped((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
@@ -540,8 +599,7 @@ private fun DrawScope.drawBeamAndPath(
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
markers: List<MapCallsignRawSnapshot>,
selectedMarkerCallsign: String?,
selectedTarget: MapCallsignRawSnapshot?,
darkMode: Boolean,
) {
if (home == null) {
@@ -549,32 +607,40 @@ private fun DrawScope.drawBeamAndPath(
}
val (homeLat, homeLon) = home
if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
if (isAntennaSectorDrawable(home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)) {
val polygon = Path()
buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
.forEachIndexed { index, point ->
val x = viewport.screenX(point.second).toFloat()
val x = viewport.screenXWrapped(point.second).toFloat()
val y = viewport.screenY(point.first).toFloat()
if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
}
polygon.close()
drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
drawPath(path = polygon, color = ANTENNA_OVERLAY_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
drawPath(path = polygon, color = ANTENNA_OVERLAY_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
}
val selected = markers.firstOrNull {
it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
}
val selected = selectedTarget?.takeIf(MapCallsignRawSnapshot::hasUsablePosition)
if (selected != null) {
val start = Offset(viewport.screenXWrapped(homeLon).toFloat(), viewport.screenY(homeLat).toFloat())
val end = Offset(
viewport.screenXWrapped(selected.longitudeDeg()).toFloat(),
viewport.screenY(selected.latitudeDeg()).toFloat(),
)
drawLine(
color = if (darkMode) CONNECTION_HALO_DARK else CONNECTION_HALO_LIGHT,
alpha = CONNECTION_HALO_ALPHA,
start = start,
end = end,
strokeWidth = CONNECTION_HALO_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
)
drawLine(
color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
alpha = CONNECTION_ALPHA,
start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
end = Offset(
viewport.screenX(selected.longitudeDeg()).toFloat(),
viewport.screenY(selected.latitudeDeg()).toFloat(),
),
start = start,
end = end,
strokeWidth = CONNECTION_STROKE_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
)
@@ -585,9 +651,9 @@ private fun DrawScope.drawBeamAndPath(
* home is, and with the beam switched off there was none. A small dot is kept here
* deliberately.
*/
val homeX = viewport.screenX(homeLon).toFloat()
val homeX = viewport.screenXWrapped(homeLon).toFloat()
val homeY = viewport.screenY(homeLat).toFloat()
drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
drawCircle(color = ANTENNA_OVERLAY_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
}
@@ -602,7 +668,7 @@ private fun DrawScope.drawStations(
) {
for (cluster in clusterResult.clusters()) {
drawClusterBubble(
x = viewport.screenX(cluster.centerLon).toFloat(),
x = viewport.screenXWrapped(cluster.centerLon).toFloat(),
y = viewport.screenY(cluster.centerLat).toFloat(),
count = cluster.size(),
containsWorked = cluster.getMarkers().any { it.worked() },
@@ -611,7 +677,7 @@ private fun DrawScope.drawStations(
}
for (marker in clusterResult.individual()) {
val x = viewport.screenX(marker.longitudeDeg()).toFloat()
val x = viewport.screenXWrapped(marker.longitudeDeg()).toFloat()
val y = viewport.screenY(marker.latitudeDeg()).toFloat()
val selected = marker.callSignRaw() == selectedMarkerCallsign
@@ -729,14 +795,23 @@ private fun DrawScope.drawHover(
viewport: MapViewport,
hoverPosition: Offset?,
clusterResult: StationMapClusterer.Result,
selectedTargetOutsideMarkers: MapCallsignRawSnapshot?,
textMeasurer: TextMeasurer,
) {
val position = hoverPosition ?: return
val selectedTargetUnderPointer = selectedTargetOutsideMarkers?.takeIf { marker ->
within(
position,
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
within(
position,
viewport.screenX(cluster.centerLon).toFloat(),
viewport.screenXWrapped(cluster.centerLon).toFloat(),
viewport.screenY(cluster.centerLat).toFloat(),
clusterRadiusPx(cluster.size()),
)
@@ -744,13 +819,14 @@ private fun DrawScope.drawHover(
val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
within(
position,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val text = when {
selectedTargetUnderPointer != null -> selectedTargetUnderPointer.tooltipText()
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
markerUnderPointer != null -> markerUnderPointer.tooltipText()
@@ -804,8 +880,8 @@ private fun DrawScope.drawWorkedGrossFields(
workedGrossFields: Set<String>,
) {
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
val left = viewport.screenX(cell.westLon()).toFloat()
val right = viewport.screenX(cell.eastLon()).toFloat()
val left = viewport.screenXWrapped(cell.westLon()).toFloat()
val right = viewport.screenXWrapped(cell.eastLon()).toFloat()
val top = viewport.screenY(cell.northLat()).toFloat()
val bottom = viewport.screenY(cell.southLat()).toFloat()
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
@@ -863,7 +939,7 @@ private fun clusterFontSizePx(count: Int): Float = when {
}
/** Null rather than a guess when the operator has no usable locator configured. */
private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
internal fun homePosition(ownLocator6: String): Pair<Double, Double>? {
if (ownLocator6.length < 4) {
return null
}
@@ -875,6 +951,15 @@ private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
}
}
internal fun isAntennaSectorDrawable(
home: Pair<Double, Double>?,
antennaAzimuthDeg: Double,
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
): Boolean = home != null && antennaAzimuthDeg.isFinite() &&
antennaBeamWidthDeg.isFinite() && antennaBeamWidthDeg > 0.0 &&
maxQrbKm.isFinite() && maxQrbKm > 0.0
fun calculateDestinationPoint(
startLatDeg: Double,
startLonDeg: Double,
@@ -982,16 +1067,20 @@ private const val CLUSTER_RADIUS_SMALL_PX = 16f
private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
private const val CLUSTER_RADIUS_LARGE_PX = 21f
private val BEAM_COLOR = Color(0xFFFF4D4D)
internal val ANTENNA_OVERLAY_COLOR = Color(0xFFFF4D4D)
private const val BEAM_FILL_ALPHA = 0.12f
private const val BEAM_STROKE_WIDTH_PX = 2f
private const val HOME_RADIUS_PX = 6f
private const val HOME_INNER_RADIUS_PX = 2f
private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
private const val CONNECTION_ALPHA = 0.85f
private const val CONNECTION_STROKE_WIDTH_PX = 2f
private val CONNECTION_COLOR_DARK = Color(0xFF52E7FF)
private val CONNECTION_COLOR_LIGHT = Color(0xFF007A9E)
private val CONNECTION_HALO_DARK = Color.White
private val CONNECTION_HALO_LIGHT = Color.Black
private const val CONNECTION_ALPHA = 0.98f
private const val CONNECTION_HALO_ALPHA = 0.82f
private const val CONNECTION_STROKE_WIDTH_PX = 3f
private const val CONNECTION_HALO_WIDTH_PX = 5f
private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
@@ -0,0 +1,21 @@
package kst4contest.view.compose.map
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatPreferences
/** Compose-observable map geometry read from the active profile. */
internal class MapDisplaySettingsState(private val prefs: ChatPreferences) {
var antennaBeamWidthDeg: Double by mutableStateOf(prefs.stn_antennaBeamWidthDeg)
private set
var maxQrbKm: Double by mutableStateOf(prefs.stn_maxQRBDefault)
private set
fun refreshFromPreferences() {
antennaBeamWidthDeg = prefs.stn_antennaBeamWidthDeg
maxQrbKm = prefs.stn_maxQRBDefault
}
}
@@ -0,0 +1,48 @@
package kst4contest.view.compose.map
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.view.compose.StationIdentity
/** A deliberate operator action that may move the station-map camera. */
internal sealed interface MapNavigationRequest {
data class FitSelectedPath(val identity: StationIdentity) : MapNavigationRequest
data class CenterOnQtf(val qtfDegrees: Double) : MapNavigationRequest
data object ResetToHome : MapNavigationRequest
}
/**
* The latest deliberate map-navigation request.
*
* Ordinary model refreshes never write this state. Keeping one monotonically increasing
* revision makes repeated selections observable and gives the most recent station or QTF
* action deterministic precedence when several actions happen before the map draws.
*/
internal class MapNavigationState {
var request: MapNavigationRequest? by mutableStateOf(null)
private set
var requestRevision: Long by mutableStateOf(0L)
private set
fun fitSelectedPath(identity: StationIdentity) {
publish(MapNavigationRequest.FitSelectedPath(identity))
}
fun centerOnQtf(qtfDegrees: Double) {
if (!qtfDegrees.isFinite() || qtfDegrees !in 0.0..360.0) return
publish(MapNavigationRequest.CenterOnQtf(qtfDegrees))
}
fun resetToHome() {
publish(MapNavigationRequest.ResetToHome)
}
private fun publish(next: MapNavigationRequest) {
request = next
requestRevision++
}
}
@@ -0,0 +1,79 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.TooltipCard
import kst4contest.view.i18n.LocalStrings
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
internal fun MapOverlayLegend(
showAntenna: Boolean,
showSpatialFilters: Boolean,
darkMode: Boolean,
modifier: Modifier = Modifier,
) {
if (!showAntenna && !showSpatialFilters) return
val strings = LocalStrings.current
TooltipArea(
tooltip = { TooltipCard(strings.mapOverlayLegendTooltip) },
modifier = modifier,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
if (showAntenna) {
LegendItem(
color = ANTENNA_OVERLAY_COLOR,
label = strings.mapOverlayAntenna,
tag = MAP_ANTENNA_LEGEND_TAG,
)
}
if (showSpatialFilters) {
LegendItem(
color = spatialFilterOverlayLineColor(darkMode),
label = strings.mapOverlayFilters,
tag = MAP_FILTER_LEGEND_TAG,
)
}
}
}
}
@Composable
private fun LegendItem(color: Color, label: String, tag: String) {
Row(
modifier = Modifier.testTag(tag),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.foundation.layout.Box(
Modifier
.size(9.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(color),
)
Text(text = label, style = MaterialTheme.typography.bodySmall)
}
}
internal const val MAP_ANTENNA_LEGEND_TAG = "station-map-antenna-legend"
internal const val MAP_FILTER_LEGEND_TAG = "station-map-filter-legend"
@@ -0,0 +1,273 @@
package kst4contest.view.compose.map
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import kst4contest.locatorUtils.DirectionUtils
import kst4contest.view.map.MapViewport
import kst4contest.view.map.PathGeometryUtils
import kotlin.math.ceil
/** Immutable effective QTF/QRB filter values drawn by the station map. */
data class SpatialFilterOverlayState(
val qtfEnabled: Boolean,
val qtfDegrees: Double,
val beamWidthDeg: Double,
val minQrbEnabled: Boolean,
val minQrbKm: Double,
val maxQrbEnabled: Boolean,
val maxQrbKm: Double,
) {
val isActive: Boolean
get() = qtfEnabled || minQrbEnabled || maxQrbEnabled
val hasEmptyDistanceRange: Boolean
get() = minQrbEnabled && maxQrbEnabled && minQrbKm >= maxQrbKm
val hasRenderableValues: Boolean
get() = (!qtfEnabled || qtfDegrees.isFinite() && beamWidthDeg.isFinite()) &&
(!minQrbEnabled || minQrbKm.isFinite()) &&
(!maxQrbEnabled || maxQrbKm.isFinite())
/** True when the current values produce at least one visible fill or boundary. */
val hasDrawableGeometry: Boolean
get() {
if (!isActive || !hasRenderableValues) return false
if (minQrbEnabled && !maxQrbEnabled) return true
if (minQrbEnabled && minQrbKm > 0.0) return true
if (maxQrbEnabled && maxQrbKm > 0.0) return true
return qtfEnabled && !maxQrbEnabled
}
/** Mirrors the filter predicate: minimum exclusive, maximum inclusive. */
fun accepts(distanceKm: Double, bearingDeg: Double): Boolean {
if (!distanceKm.isFinite() || !bearingDeg.isFinite()) return false
if (minQrbEnabled && distanceKm <= minQrbKm) return false
if (maxQrbEnabled && distanceKm > maxQrbKm) return false
if (qtfEnabled && !DirectionUtils.isAngleInRange(bearingDeg, qtfDegrees, beamWidthDeg)) return false
return true
}
}
internal fun DrawScope.drawSpatialFilterOverlay(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
state: SpatialFilterOverlayState,
darkMode: Boolean,
) {
if (!state.hasDrawableGeometry ||
!homeLatitudeDeg.isFinite() || !homeLongitudeDeg.isFinite()
) return
val fillColor = if (darkMode) FILTER_FILL_DARK else FILTER_FILL_LIGHT
val lineColor = spatialFilterOverlayLineColor(darkMode)
val angular = state.qtfEnabled && state.beamWidthDeg.isFinite()
val validAngularArea = !angular || state.beamWidthDeg > 0.0 && state.beamWidthDeg < 360.0
val visibleRadius = visibleRadiusKm(viewport, homeLatitudeDeg, homeLongitudeDeg)
val outerRadius = if (state.maxQrbEnabled) state.maxQrbKm else visibleRadius
val innerRadius = if (state.minQrbEnabled) state.minQrbKm else 0.0
if (!state.hasEmptyDistanceRange && validAngularArea && outerRadius > innerRadius && outerRadius > 0.0) {
val fillPath = when {
angular -> annularSectorPath(
viewport,
homeLatitudeDeg,
homeLongitudeDeg,
state.qtfDegrees,
state.beamWidthDeg,
innerRadius.coerceAtLeast(0.0),
outerRadius,
)
state.minQrbEnabled && !state.maxQrbEnabled -> outsideCirclePath(
viewport,
homeLatitudeDeg,
homeLongitudeDeg,
innerRadius.coerceAtLeast(0.0),
)
else -> ringPath(
viewport,
homeLatitudeDeg,
homeLongitudeDeg,
innerRadius.coerceAtLeast(0.0),
outerRadius,
)
}
drawPath(fillPath, fillColor, style = Fill)
}
val stroke = Stroke(
width = FILTER_LINE_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(FILTER_DASH_PX),
)
if (state.minQrbEnabled && state.minQrbKm > 0.0) {
drawPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, state.minQrbKm), lineColor, style = stroke)
}
if (state.maxQrbEnabled && state.maxQrbKm > 0.0) {
drawPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, state.maxQrbKm), lineColor, style = stroke)
}
if (angular) {
val boundaryRadius = if (state.maxQrbEnabled) state.maxQrbKm else visibleRadius
val halfWidth = state.beamWidthDeg.coerceAtLeast(0.0) / 2.0
listOf(state.qtfDegrees - halfWidth, state.qtfDegrees + halfWidth).forEach { bearing ->
val endpoint = calculateDestinationPoint(
homeLatitudeDeg,
homeLongitudeDeg,
bearing,
boundaryRadius,
)
drawLine(
color = lineColor,
start = Offset(
viewport.screenXWrapped(homeLongitudeDeg).toFloat(),
viewport.screenY(homeLatitudeDeg).toFloat(),
),
end = Offset(
viewport.screenXWrapped(endpoint.second).toFloat(),
viewport.screenY(endpoint.first).toFloat(),
),
strokeWidth = FILTER_LINE_WIDTH_PX,
pathEffect = PathEffect.dashPathEffect(FILTER_DASH_PX),
)
}
}
}
private fun outsideCirclePath(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
radiusKm: Double,
): Path = Path().apply {
fillType = PathFillType.EvenOdd
addRect(Rect(0f, 0f, viewport.widthPx().toFloat(), viewport.heightPx().toFloat()))
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, radiusKm))
}
private fun ringPath(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
innerRadiusKm: Double,
outerRadiusKm: Double,
): Path = Path().apply {
fillType = PathFillType.EvenOdd
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, outerRadiusKm))
if (innerRadiusKm > 0.0) {
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, innerRadiusKm))
}
}
private fun annularSectorPath(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
centerBearingDeg: Double,
beamWidthDeg: Double,
innerRadiusKm: Double,
outerRadiusKm: Double,
): Path {
val halfWidth = beamWidthDeg / 2.0
val outer = arcPoints(
homeLatitudeDeg,
homeLongitudeDeg,
centerBearingDeg - halfWidth,
beamWidthDeg,
outerRadiusKm,
)
val inner = if (innerRadiusKm > 0.0) {
arcPoints(
homeLatitudeDeg,
homeLongitudeDeg,
centerBearingDeg - halfWidth,
beamWidthDeg,
innerRadiusKm,
).asReversed()
} else {
listOf(homeLatitudeDeg to homeLongitudeDeg)
}
return pathThrough(viewport, outer + inner, close = true)
}
private fun circlePath(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
radiusKm: Double,
): Path = pathThrough(
viewport,
arcPoints(homeLatitudeDeg, homeLongitudeDeg, 0.0, 360.0, radiusKm),
close = true,
)
private fun arcPoints(
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
startBearingDeg: Double,
sweepDeg: Double,
radiusKm: Double,
): List<Pair<Double, Double>> {
val segmentCount = maxOf(24, ceil(kotlin.math.abs(sweepDeg) / 3.0).toInt())
return (0..segmentCount).map { segment ->
calculateDestinationPoint(
homeLatitudeDeg,
homeLongitudeDeg,
startBearingDeg + sweepDeg * segment / segmentCount,
radiusKm,
)
}
}
private fun pathThrough(
viewport: MapViewport,
points: List<Pair<Double, Double>>,
close: Boolean,
): Path = Path().apply {
points.forEachIndexed { index, point ->
val x = viewport.screenXWrapped(point.second).toFloat()
val y = viewport.screenY(point.first).toFloat()
if (index == 0) moveTo(x, y) else lineTo(x, y)
}
if (close && points.isNotEmpty()) close()
}
private fun visibleRadiusKm(
viewport: MapViewport,
homeLatitudeDeg: Double,
homeLongitudeDeg: Double,
): Double {
val corners = listOf(
viewport.northLat() to viewport.westLon(),
viewport.northLat() to viewport.eastLon(),
viewport.southLat() to viewport.westLon(),
viewport.southLat() to viewport.eastLon(),
)
val farthestCorner = corners.maxOfOrNull { (latitude, longitude) ->
PathGeometryUtils.calculateGreatCircleDistanceKm(
homeLatitudeDeg,
homeLongitudeDeg,
latitude,
longitude,
)
} ?: 0.0
return farthestCorner * VIEWPORT_RADIUS_MARGIN
}
private val FILTER_FILL_LIGHT = Color(0xFF5B4BC4).copy(alpha = 0.16f)
private val FILTER_FILL_DARK = Color(0xFF78A9FF).copy(alpha = 0.18f)
private val FILTER_LINE_LIGHT = Color(0xFF4937B8).copy(alpha = 0.88f)
private val FILTER_LINE_DARK = Color(0xFF8CB8FF).copy(alpha = 0.92f)
internal fun spatialFilterOverlayLineColor(darkMode: Boolean): Color =
if (darkMode) FILTER_LINE_DARK else FILTER_LINE_LIGHT
private const val FILTER_LINE_WIDTH_PX = 2f
private val FILTER_DASH_PX = floatArrayOf(7f, 5f)
private const val VIEWPORT_RADIUS_MARGIN = 1.12
@@ -11,6 +11,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
@@ -26,6 +28,7 @@ import kst4contest.view.compose.StationContextOverlays
import kst4contest.view.compose.StationSelectionSource
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.MapViewportNavigation
import kst4contest.view.map.StationMapStatusText
import kst4contest.view.map.TileFetcher
import java.util.EnumSet
@@ -108,9 +111,8 @@ object StationMapWindow {
* @param chatMembers the roster a marker click looks its station up in; a marker stands
* for a raw callsign, and this is where its category variants come from
* @param mapState where the map is looking
* @param pathAnalysis asked each time the selection changes; null means there is no
* running application, and then the map neither pans nor analyses, exactly as
* when no chat controller existed before the split
* @param pathAnalysis asked for an explicit selection; null disables only the terrain
* analysis while viewport navigation remains available
*/
@Composable
internal fun Content(
@@ -151,8 +153,8 @@ object StationMapWindow {
val ownLocator6 by remember { androidx.compose.runtime.mutableStateOf(mainWindowState.prefs.stn_loginLocatorMainCat) }
val antennaAzimuthDeg = mainWindowState.antennaQtf.value
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
val antennaBeamWidthDeg = mainWindowState.mapDisplaySettings.antennaBeamWidthDeg
val maxQrbKm = mainWindowState.mapDisplaySettings.maxQrbKm
/*
* The markers are the stations the list shows, not all of them (#21): the
@@ -184,20 +186,37 @@ object StationMapWindow {
}
}
/*
* Selection is independent of the filtered marker set. A selected station
* remains a target even when an active filter removes its normal marker; it is
* drawn in a separate selection layer and never enters marker counts or clusters.
*/
val selectedTarget by remember(
selectedMember,
mainWindowState.stations.contentRevision,
mainWindowState.activeBands,
) {
derivedStateOf {
selectedMember?.let { selected ->
val variants = chatMembers().filter { it.callSignRaw == selected.callSignRaw }
.ifEmpty { listOf(selected) }
val bands = EnumSet.noneOf(Band::class.java).apply {
addAll(mainWindowState.activeBands)
}
MapCallsignRawSnapshotBuilder()
.buildSnapshots(variants, selected, bands)
.firstOrNull()
}
}
}
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
var automaticAnalysisRevision by remember { mutableStateOf(0L) }
// Read through updated state so a caller passing a new lambda is never served a stale one.
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
/*
* Whether the selected station has a marker. A filter can hide it (#21), and a
* station without a locator never has one; then there is nothing to analyse.
*/
val selectedHasMarker = selectedMember != null &&
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) {
LaunchedEffect(mainWindowState.stationSelection.explicitSelectionRevision) {
val automaticRequest = ++automaticAnalysisRevision
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
if (selectedMember == null) {
@@ -205,34 +224,15 @@ object StationMapWindow {
return@LaunchedEffect
}
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
val selectedSnapshot = selectedTarget
if (selectedSnapshot == null) {
// No marker: no request, and no "Loading" for a station the map does not show.
// No valid locator: no request and no misleading loading state.
pathAnalysisResult = null
return@LaunchedEffect
}
val analysis = currentPathAnalysis()
if (analysis != null) {
if (selectedSnapshot.hasUsablePosition()) {
/*
* Pan to the station and leave the zoom alone. The Leaflet map
* did exactly this (focusCallsignRaw) and touched the zoom only
* when the station was hidden inside a bubble, and then only far
* enough to break the bubble open. An earlier Compose version
* recomputed a zoom from a bounding box on every selection and
* clamped it into 4..12, which threw an operator working at zoom
* 15 out to about 7 every time they picked the next station.
* AGENTS.md names zoom first among the things not to change as
* an incidental side effect.
*/
mapState.centerLon = selectedSnapshot.longitudeDeg()
mapState.centerLat = selectedSnapshot.latitudeDeg()
mapState.zoom = kst4contest.view.map.StationMapClusterer
.zoomToRevealStations(mapState.zoom.toDouble(), groupingEnabled)
.toFloat()
}
pathAnalysisResult = PathAnalysisResult.loading(
prefs.stn_loginLocatorMainCat,
selectedSnapshot.locator6(),
@@ -249,6 +249,72 @@ object StationMapWindow {
}
}
var mapCanvasSize by remember { mutableStateOf(Size.Zero) }
var consumedNavigationRevision by remember(mapState) { mutableStateOf(Long.MIN_VALUE) }
val navigationRevision = mainWindowState.mapNavigation.requestRevision
val navigationRequest = mainWindowState.mapNavigation.request
val navigationPaddingPx = with(LocalDensity.current) { 40.dp.toPx().toDouble() }
LaunchedEffect(
navigationRevision,
navigationRequest,
mapCanvasSize,
selectedTarget,
ownLocator6,
) {
if (navigationRevision == consumedNavigationRevision ||
mapCanvasSize.width <= 0f || mapCanvasSize.height <= 0f
) {
return@LaunchedEffect
}
val home = runCatching { kst4contest.locatorUtils.Location(ownLocator6) }.getOrNull()
val current = mapState.viewportFor(mapCanvasSize.width, mapCanvasSize.height)
val next = when (val request = navigationRequest) {
is MapNavigationRequest.FitSelectedPath -> {
val target = selectedTarget
if (home == null || target == null || !target.hasUsablePosition() ||
request.identity != StationIdentity.of(selectedMember)
) {
null
} else {
MapViewportNavigation.fitPath(
current,
home.latitude.toDegrees(),
home.longitude.toDegrees(),
target.latitudeDeg(),
target.longitudeDeg(),
navigationPaddingPx,
)
}
}
is MapNavigationRequest.CenterOnQtf -> {
if (home == null) null else MapViewportNavigation.centreOnBearing(
current,
home.latitude.toDegrees(),
home.longitude.toDegrees(),
request.qtfDegrees,
QTF_FORWARD_CENTRE_FRACTION,
)
}
MapNavigationRequest.ResetToHome -> {
if (home == null) null else MapViewportNavigation.centreOnHome(
current,
home.latitude.toDegrees(),
home.longitude.toDegrees(),
)
}
null -> null
}
consumedNavigationRevision = navigationRevision
if (next != null) {
mapState.applyFrom(next)
}
}
val explicitReachabilityResult = mainWindowState.mapReachability.result
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
if (explicitReachabilityResult != null) {
@@ -264,6 +330,24 @@ object StationMapWindow {
} else {
emptySet()
}
val spatialFilterOverlay = SpatialFilterOverlayState(
qtfEnabled = mainWindowState.stationFilter.qtfEnabled,
qtfDegrees = mainWindowState.stationFilter.qtfDegrees,
beamWidthDeg = antennaBeamWidthDeg,
minQrbEnabled = mainWindowState.stationFilter.minQrbEnabled,
minQrbKm = mainWindowState.stationFilter.minQrbKm,
maxQrbEnabled = mainWindowState.stationFilter.maxQrbEnabled,
maxQrbKm = mainWindowState.stationFilter.maxQrbKm,
)
val mapHome = remember(ownLocator6) { homePosition(ownLocator6) }
val showAntennaOverlay = isAntennaSectorDrawable(
mapHome,
antennaAzimuthDeg ?: 0.0,
antennaBeamWidthDeg,
maxQrbKm,
)
val showSpatialFilterOverlay = mapHome != null && spatialFilterOverlay.hasDrawableGeometry
val isDarkTheme = isDarkThemeState.value
androidx.compose.material3.Surface(
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
@@ -288,7 +372,7 @@ object StationMapWindow {
markers.size,
mainWindowState.stationFilter.activeFilters.isNotEmpty() ||
mainWindowState.stationFilter.searchText.isNotBlank(),
markers.firstOrNull { it.callSignRaw() == selectedMember?.callSignRaw },
selectedTarget,
)
/*
@@ -308,6 +392,12 @@ object StationMapWindow {
)
}
MapOverlayLegend(
showAntenna = showAntennaOverlay,
showSpatialFilters = showSpatialFilterOverlay,
darkMode = isDarkTheme,
)
if (selectedMember != null) {
kst4contest.view.compose.Form.button(strings.mapTriggerClusterSpot) {
ApplicationRuntimeLauncher.getCurrent()?.chatController
@@ -317,12 +407,6 @@ object StationMapWindow {
kst4contest.view.compose.Form.button(strings.mapResetView) {
mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW)
val home = runCatching {
kst4contest.locatorUtils.Location(ownLocator6)
}.getOrNull()
mapState.centerLon = home?.longitude?.toDegrees() ?: 10.0
mapState.centerLat = home?.latitude?.toDegrees() ?: 51.0
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
}
kst4contest.view.compose.Form.button(strings.filterReset) {
@@ -388,7 +472,6 @@ object StationMapWindow {
val leftPane: @Composable () -> Unit = {
Column(Modifier.fillMaxHeight()) {
val isDarkTheme = isDarkThemeState.value
androidx.compose.foundation.layout.Box(Modifier.weight(1f).fillMaxWidth()) {
ComposeStationMap(
markers = markers,
@@ -400,8 +483,11 @@ object StationMapWindow {
antennaAzimuthDeg = antennaAzimuthDeg ?: 0.0,
antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm,
selectedTarget = selectedTarget,
spatialFilterOverlay = spatialFilterOverlay,
darkMode = isDarkTheme,
workedGrossFields = workedGrossFields,
onCanvasSizeChanged = { mapCanvasSize = it },
onMarkerClicked = { snapshot ->
val variants = chatMembers()
.filter { it.callSignRaw == snapshot.callSignRaw() }
@@ -501,6 +587,7 @@ object StationMapWindow {
internal const val MAP_CANVAS_TAG = "station-map-canvas"
internal const val GROUPING_TAG = "station-map-grouping"
internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top"
private const val QTF_FORWARD_CENTRE_FRACTION = 0.25
@JvmStatic
fun applyDarkMode(darkMode: Boolean) {
@@ -4,9 +4,11 @@ import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Form
import kst4contest.view.compose.observeAsState
/**
* The beacon tab: one CQ template per chat category and the interval they share.
@@ -18,12 +20,14 @@ import kst4contest.view.compose.Form
@Composable
fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
val strings = LocalStrings.current
val mainEnabled by state.mainBeaconEnabled.observeAsState()
val secondEnabled by state.secondBeaconEnabled.observeAsState()
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Form.section(strings.beaconSectionFor(state.mainCategoryName)) {
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledMainCat) {
Form.check(strings.beaconEnabled, mainEnabled == true) {
state.bcn_beaconsEnabledMainCat = it
}
Form.committed(
@@ -36,7 +40,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
}
Form.section(strings.beaconSectionFor(state.secondCategoryName)) {
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledSecondCat) {
Form.check(strings.beaconEnabled, secondEnabled == true) {
state.bcn_beaconsEnabledSecondCat = it
}
Form.committed(
@@ -12,8 +12,9 @@ import kst4contest.view.compose.setting
* The two commit methods are the ports of `applyBeaconTextSetting` and
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
* return the refusal text instead, so the tab decides how to show it and a test can
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
* when the operator applies it; a refused one never gets there.
* read it. Accepted templates and intervals are staged in the window's [SettingsDraft]
* and take effect when the operator applies them. The two enable switches deliberately
* bypass the draft so the running timer sees their values immediately.
*
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
* the contract of `ChatController.validateBeaconTemplate`.
@@ -27,15 +28,20 @@ class BeaconTabState(
private val restartBeaconTimer: () -> Unit,
val mainCategoryName: String,
val secondCategoryName: String,
private val setMainBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledMainCat,
private val setSecondBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledSecondCat,
) {
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledMainCat() },
) { prefs.setBcn_beaconsEnabledMainCat(it) }
val mainBeaconEnabled = prefs.bcnBeaconsEnabledMainCatProperty()
val secondBeaconEnabled = prefs.bcnBeaconsEnabledSecondCatProperty()
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
{ prefs.isBcn_beaconsEnabledSecondCat() },
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
var bcn_beaconsEnabledMainCat: Boolean
get() = prefs.isBcn_beaconsEnabledMainCat()
set(value) = setMainBeaconEnabled(value)
var bcn_beaconsEnabledSecondCat: Boolean
get() = prefs.isBcn_beaconsEnabledSecondCat()
set(value) = setSecondBeaconEnabled(value)
private var textMainCat: String by draft.setting(
{ prefs.getBcn_beaconTextMainCat() },
@@ -69,7 +75,7 @@ class BeaconTabState(
*
* The clamp is not cosmetic: a stored zero would otherwise be displayed as a
* valid setting while `ChatController` sends at the minimum anyway
* (ChatController:3425). The JavaFX field applied the same `Math.max` when it
* when scheduling the timer. The JavaFX field applied the same `Math.max` when it
* restored the value after a rejected entry.
*/
val beaconIntervalMinutes: Int
@@ -44,6 +44,10 @@ class StationTabState(
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
*/
private val applySecondRotator: () -> Unit = {},
/** Recomputes status whose available-count depends on the second-chat switch. */
private val refreshBeaconStatus: () -> Unit = {},
/** Repaints applied beam geometry and dependent QTF results in the running UI. */
private val applyMapDisplaySettings: () -> Unit = {},
) {
var loginChatCategoryMain: ChatCategory?
@@ -55,7 +59,11 @@ class StationTabState(
get() = prefs.getLoginChatCategorySecond()
set(value) { prefs.setLoginChatCategorySecond(value) }
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
var stn_antennaBeamWidthDeg: Double by draft.setting(
{ prefs.getStn_antennaBeamWidthDeg() },
effectKey = MAP_DISPLAY_SETTINGS,
effect = applyMapDisplaySettings,
) { prefs.setStn_antennaBeamWidthDeg(it) }
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
@@ -95,7 +103,11 @@ class StationTabState(
get() = prefs.getStn_loginPassword()
set(value) { prefs.setStn_loginPassword(value) }
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
var stn_maxQRBDefault: Double by draft.setting(
{ prefs.getStn_maxQRBDefault() },
effectKey = MAP_DISPLAY_SETTINGS,
effect = applyMapDisplaySettings,
) { prefs.setStn_maxQRBDefault(it) }
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
@@ -234,7 +246,10 @@ class StationTabState(
* pickers it governs. It decides what the Connect button does, so it belongs with
* the login settings and not with the display options.
*/
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
var loginToSecondChatEnabled: Boolean by draft.setting(
{ prefs.isLoginToSecondChatEnabled() },
effect = { refreshBeaconStatus() },
) { prefs.setLoginToSecondChatEnabled(it) }
/**
* Whether the login fields may be edited. The JavaFX window disabled callsign,
@@ -289,6 +304,9 @@ class StationTabState(
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
const val SECOND_ROTATOR: String = "secondRotator"
/** Shared effect for the two settings that define the map's antenna sector. */
const val MAP_DISPLAY_SETTINGS: String = "mapDisplaySettings"
/**
* Whether two PSTRotator ports collide: the same port, or one apart because
* PSTRotator answers on port + 1, which would be the other client's local port.
@@ -35,7 +35,9 @@ class SettingsFieldCoverageTest {
""
}
val missing = expected.filterNot { composeSources.contains("$it(") }
val missing = expected.filterNot {
composeSources.contains("$it(") || composeSources.contains("::$it")
}
assertTrue(missing.isEmpty(),
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
@@ -70,12 +70,27 @@ class StationSelectionStateTest {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
val revision = state.scrollRequestRevision
val explicitRevision = state.explicitSelectionRevision
val refreshed = station("DL1ABC", 2)
state.reconcile(refreshed)
assertSame(refreshed, state.selected)
assertEquals(revision, state.scrollRequestRevision)
assertEquals(explicitRevision, state.explicitSelectionRevision)
}
@Test
fun `roster reconciliation does not publish explicit navigation`() {
val state = StationSelectionState()
val requests = mutableListOf<Pair<ChatMember?, StationSelectionSource>>()
state.explicitSelectionListener = { member, source -> requests += member to source }
state.select(station("DL1ABC", 2), StationSelectionSource.PUBLIC_MESSAGE)
state.reconcile(station("DL1ABC", 2))
assertEquals(1, requests.size)
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, requests.single().second)
}
@Test
@@ -0,0 +1,48 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHasNoClickAction
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import kst4contest.model.ThreadStateMessage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class StatusBarScreenTest {
@Test
fun onlyAThreadWithARegisteredActionIsClickable() = runComposeUiTest {
val buttons = ThreadStatusButtons()
var beaconClicks = 0
buttons.registerAction("MyBeacon") { beaconClicks++ }
buttons.update("MyBeacon", status("off"))
buttons.update("Cluster", status("on"))
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
StatusBar(
connectionState = null,
connectionDetail = null,
skedNotice = BlinkingNotice(),
bandUpgradeNotice = BlinkingNotice(),
threadButtons = buttons,
showConnectionBadge = false,
)
}
}
waitForIdle()
onNodeWithText("MyBeacon: off").assertHasClickAction().performClick()
waitForIdle()
assertEquals(1, beaconClicks)
onNodeWithText("Cluster: on").assertHasNoClickAction()
}
private fun status(description: String): ThreadStateMessage =
ThreadStateMessage("worker", true, description, false).apply {
runningInformationTextDescription = description
}
}
@@ -407,6 +407,52 @@ class StatusIndicatorStateTest {
assertEquals("Cluster: b", button.label)
}
@Test
fun `an action registered before the first report reaches the created button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.registerAction("MyBeacon") { clicks++ }
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `an action registered after a report reaches the existing button`() {
val buttons = ThreadStatusButtons()
var clicks = 0
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
buttons.registerAction("MyBeacon") { clicks++ }
buttons.buttons.single().activate()
assertEquals(1, clicks)
}
@Test
fun `a thread without an action cannot be activated`() {
val buttons = ThreadStatusButtons()
buttons.update("Cluster", threadState(nick = "cluster", description = "on", info = "running"))
assertFalse(buttons.buttons.single().activate())
}
@Test
fun `a status provider replaces a stale worker snapshot when the UI applies it`() {
val buttons = ThreadStatusButtons()
var current = threadState(nick = "beacon", description = "on", info = "enabled")
buttons.registerStatusProvider("MyBeacon") { current }
val queuedSnapshot = current
current = threadState(nick = "beacon", description = "off", info = "disabled")
buttons.update("MyBeacon", queuedSnapshot)
assertEquals("MyBeacon: off", buttons.buttons.single().label)
assertEquals("disabled", buttons.buttons.single().tooltip)
}
private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
ThreadStateMessage(nick, true, info, false).apply {
setRunningInformationTextDescription(description)
@@ -49,6 +49,8 @@ class ComposeStationMapDrawingTest {
private val workedGridBorder = 0x000000
private val selectedDotBorder = 0xFF9900
/*
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
* construction. The default fetcher read the user's disk cache and the real tile
@@ -71,6 +73,10 @@ class ComposeStationMapDrawingTest {
ownLocator6: String = "",
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
selectedTarget: MapCallsignRawSnapshot? = null,
spatialFilterOverlay: SpatialFilterOverlayState? = null,
antennaBeamWidthDeg: Double = 0.0,
maxQrbKm: Double = 0.0,
): BufferedImage {
return onEventThread {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
@@ -82,8 +88,10 @@ class ComposeStationMapDrawingTest {
selectedMarkerCallsign = null,
ownLocator6 = ownLocator6,
antennaAzimuthDeg = 90.0,
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm,
selectedTarget = selectedTarget,
spatialFilterOverlay = spatialFilterOverlay,
onMarkerClicked = null,
workedGrossFields = workedGrossFields,
darkMode = darkMode,
@@ -108,6 +116,16 @@ class ComposeStationMapDrawingTest {
return count
}
private fun changedPixels(before: BufferedImage, after: BufferedImage): Int {
var changed = 0
for (x in 0 until before.width) {
for (y in 0 until before.height) {
if (before.getRGB(x, y) != after.getRGB(x, y)) changed++
}
}
return changed
}
/** The widest horizontal run of a colour, which for a ring is its outer diameter. */
private fun widestRun(image: BufferedImage, rgb: Int): Int {
var widest = 0
@@ -282,6 +300,54 @@ class ComposeStationMapDrawingTest {
}
}
@Test
fun `active spatial filters add a distinct overlay in both themes`() {
val overlay = SpatialFilterOverlayState(
qtfEnabled = true,
qtfDegrees = 90.0,
beamWidthDeg = 60.0,
minQrbEnabled = true,
minQrbKm = 50.0,
maxQrbEnabled = true,
maxQrbKm = 400.0,
)
for (darkMode in listOf(false, true)) {
val without = render(6f, markers = emptyList(), ownLocator6 = "JO50JP", darkMode = darkMode)
val with = render(
6f,
markers = emptyList(),
ownLocator6 = "JO50JP",
darkMode = darkMode,
spatialFilterOverlay = overlay,
)
assertTrue(changedPixels(without, with) > 1_000) {
"the spatial filter overlay was not visible in darkMode=$darkMode"
}
}
}
@Test
fun `a filtered selected target is drawn without joining the normal marker list`() {
val targetLocation = kst4contest.locatorUtils.Location("JO51AA")
val target = station(
"DL1HIDE",
"JO51AA",
targetLocation.latitude.toDegrees(),
targetLocation.longitude.toDegrees(),
)
val image = render(
6f,
markers = emptyList(),
ownLocator6 = "JO50JP",
selectedTarget = target,
)
assertTrue(countColour(image, selectedDotBorder) > 0, "the separate selected marker is missing")
}
/**
* Every grid line has to be drawn once.
*
@@ -0,0 +1,29 @@
package kst4contest.view.compose.map
import kst4contest.model.ChatPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class MapDisplaySettingsStateTest {
@TempDir
lateinit var directory: Path
@Test
fun `refresh publishes applied beam geometry from preferences`() {
val preferences = ChatPreferences(directory.resolve("preferences.xml")).apply {
stn_antennaBeamWidthDeg = 50.0
stn_maxQRBDefault = 750.0
}
val state = MapDisplaySettingsState(preferences)
preferences.stn_antennaBeamWidthDeg = 72.0
preferences.stn_maxQRBDefault = 1_100.0
state.refreshFromPreferences()
assertEquals(72.0, state.antennaBeamWidthDeg)
assertEquals(1_100.0, state.maxQrbKm)
}
}
@@ -0,0 +1,42 @@
package kst4contest.view.compose.map
import kst4contest.view.compose.StationIdentity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MapNavigationStateTest {
@Test
fun `repeated station choices remain observable`() {
val state = MapNavigationState()
val identity = StationIdentity("DL1ABC", 2)
state.fitSelectedPath(identity)
val firstRevision = state.requestRevision
state.fitSelectedPath(identity)
assertTrue(state.requestRevision > firstRevision)
assertEquals(MapNavigationRequest.FitSelectedPath(identity), state.request)
}
@Test
fun `latest explicit action replaces an older one`() {
val state = MapNavigationState()
state.fitSelectedPath(StationIdentity("DL1ABC", 2))
state.centerOnQtf(45.0)
assertEquals(MapNavigationRequest.CenterOnQtf(45.0), state.request)
}
@Test
fun `invalid QTF values do not create requests`() {
val state = MapNavigationState()
listOf(Double.NaN, Double.POSITIVE_INFINITY, -1.0, 360.1).forEach(state::centerOnQtf)
assertEquals(0L, state.requestRevision)
assertEquals(null, state.request)
}
}
@@ -0,0 +1,60 @@
package kst4contest.view.compose.map
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import kst4contest.view.compose.Kst4ContestTheme
import kst4contest.view.i18n.LanguageStore
import kst4contest.view.i18n.LocalStrings
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.Locale
@OptIn(ExperimentalTestApi::class)
class MapOverlayLegendScreenTest {
@Test
fun germanEveningLegendWrapsBothItemsInsideANarrowHeader() = runComposeUiTest {
var storedLanguage = "de"
val languages = LanguageStore(
storedLanguage = { storedLanguage },
storeLanguage = { storedLanguage = it },
systemDefault = Locale.GERMANY,
)
try {
setContent {
CompositionLocalProvider(LocalStrings provides languages.strings) {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Box(Modifier.width(110.dp)) {
MapOverlayLegend(
showAntenna = true,
showSpatialFilters = true,
darkMode = true,
)
}
}
}
}
onNodeWithText("Antenne").assertIsDisplayed()
onNodeWithText("Aktive Filter").assertIsDisplayed()
val antenna = onNodeWithTag(MAP_ANTENNA_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
val filters = onNodeWithTag(MAP_FILTER_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
val widthLimit = with(density) { 110.dp.toPx() }
assertTrue(antenna.right <= widthLimit + 0.5f)
assertTrue(filters.right <= widthLimit + 0.5f)
assertTrue(filters.top >= antenna.bottom, "the legend items did not wrap at narrow width")
} finally {
runOnIdle { languages.use("en") }
}
}
}
@@ -0,0 +1,91 @@
package kst4contest.view.compose.map
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SpatialFilterOverlayStateTest {
@Test
fun `maximum QRB is inclusive and minimum QRB is exclusive`() {
val state = overlay(minEnabled = true, minKm = 200.0, maxEnabled = true, maxKm = 500.0)
assertFalse(state.accepts(200.0, 90.0))
assertTrue(state.accepts(200.1, 90.0))
assertTrue(state.accepts(500.0, 90.0))
assertFalse(state.accepts(500.1, 90.0))
}
@Test
fun `QTF uses the same wrapped beamwidth semantics as the station filter`() {
val state = overlay(qtf = true, direction = 0.0, beamWidth = 60.0)
assertTrue(state.accepts(300.0, 350.0))
assertTrue(state.accepts(300.0, 30.0))
assertFalse(state.accepts(300.0, 31.0))
}
@Test
fun `unusual full-circle beamwidth retains the existing station filter semantics`() {
val state = overlay(qtf = true, direction = 0.0, beamWidth = 360.0)
assertTrue(state.accepts(300.0, 180.0))
assertFalse(state.accepts(300.0, 0.0))
}
@Test
fun `minimum above maximum is an empty distance range`() {
val state = overlay(minEnabled = true, minKm = 500.0, maxEnabled = true, maxKm = 200.0)
assertTrue(state.hasEmptyDistanceRange)
assertFalse(state.accepts(100.0, 0.0))
assertFalse(state.accepts(300.0, 0.0))
assertFalse(state.accepts(600.0, 0.0))
}
@Test
fun `non-finite active values cannot be rendered as a misleading area`() {
assertFalse(overlay(qtf = true, direction = Double.NaN).hasRenderableValues)
assertFalse(overlay(minEnabled = true, minKm = Double.POSITIVE_INFINITY).hasRenderableValues)
assertFalse(overlay(maxEnabled = true, maxKm = Double.NaN).hasRenderableValues)
}
@Test
fun `zero distance boundaries report only actually drawable geometry`() {
assertFalse(overlay(maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertFalse(overlay(qtf = true, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertFalse(
overlay(minEnabled = true, minKm = 0.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry
)
assertTrue(overlay(minEnabled = true, minKm = 0.0).hasDrawableGeometry)
assertTrue(overlay(qtf = true).hasDrawableGeometry)
assertTrue(overlay(minEnabled = true, minKm = 100.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
assertTrue(overlay(maxEnabled = true, maxKm = 500.0).hasDrawableGeometry)
}
@Test
fun `no spatial filter accepts finite positions`() {
val state = overlay()
assertFalse(state.isActive)
assertTrue(state.accepts(1000.0, 275.0))
}
private fun overlay(
qtf: Boolean = false,
direction: Double = 0.0,
beamWidth: Double = 60.0,
minEnabled: Boolean = false,
minKm: Double = 0.0,
maxEnabled: Boolean = false,
maxKm: Double = 0.0,
) = SpatialFilterOverlayState(
qtfEnabled = qtf,
qtfDegrees = direction,
beamWidthDeg = beamWidth,
minQrbEnabled = minEnabled,
minQrbKm = minKm,
maxQrbEnabled = maxEnabled,
maxQrbKm = maxKm,
)
}
@@ -4,7 +4,6 @@ import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.click
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.onAllNodesWithText
@@ -42,7 +41,6 @@ import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState
import kst4contest.view.map.StationMapClusterer
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.PathAnalysisResult
import kst4contest.view.map.TileFetcher
@@ -231,7 +229,10 @@ class StationMapWindowScreenTest {
val dl1abc = station("DL1ABC", 2)
state.selectedStation.select(dl1abc)
val selectorCalls = mutableListOf<ChatMember?>()
state.stationSelector = { member, _ -> selectorCalls += member }
state.stationSelector = { member, source ->
selectorCalls += member
state.selectedStation.select(member, source)
}
val mapState = mapLookingAt("JO50JP", zoom = 15f)
show(state, listOf(dl1abc), mapState)
@@ -240,11 +241,7 @@ class StationMapWindowScreenTest {
waitForIdle()
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
assertSame(
dl1abc,
state.selectedStation.selected,
"reset cleared the panel's selection behind the selector's back",
)
assertEquals(null, state.selectedStation.selected)
assertEquals(15f, mapState.zoom, "reset changed the zoom")
val home = Location(ownLocator)
@@ -369,12 +366,9 @@ class StationMapWindowScreenTest {
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
}
/**
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
*/
/** An explicit selection fits both ends and still starts the existing path analysis. */
@Test
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
fun selectingAStationFitsTheCompletePath() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val analysis = RecordingPathAnalysis()
@@ -382,53 +376,49 @@ class StationMapWindowScreenTest {
show(state, listOf(dl1abc), mapState, analysis)
runOnIdle { state.selectedStation.select(dl1abc) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
waitForIdle()
val home = Location(ownLocator)
val target = Location("JO50JP")
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
assertTrue(mapState.centerLon in home.longitude.toDegrees()..target.longitude.toDegrees())
assertTrue(mapState.centerLat in home.latitude.toDegrees()..target.latitude.toDegrees())
assertTrue(mapState.zoom < 15f, "a path too long for zoom 15 was not fitted")
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
}
/**
* With grouping on, a selection zooms in only as far as it takes to break the station out
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
*/
/** Grouping never forces a path which already fits to zoom in. */
@Test
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
fun groupingDoesNotZoomInWhileFittingASelectedPath() = runComposeUiTest {
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
val state = windowState(prefs)
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val far = mapLookingAt(ownLocator, zoom = 5f)
val dl1abc = station("DL1ABC", 2, locator = "JO40HB")
val mapState = mapLookingAt(ownLocator, zoom = 5f)
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
show(state, listOf(dl1abc), mapState, RecordingPathAnalysis())
runOnIdle { state.selectedStation.select(dl1abc) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
waitForIdle()
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
assertEquals(5f, mapState.zoom)
}
/** Without a running application there is nothing to ask, and the map stays put. */
/** Camera navigation is independent of the optional path-analysis service. */
@Test
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
fun withoutAPathAnalysisASelectionStillFitsThePath() = runComposeUiTest {
val state = windowState(prefs())
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 15f)
val home = Location(ownLocator)
var asked = 0
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
runOnIdle { state.selectedStation.select(dl1abc) }
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
waitForIdle()
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
assertEquals(15f, mapState.zoom)
assertTrue(mapState.zoom < 15f)
}
/** The two header switches write the profile, so the map opens the same way next time. */
@@ -477,6 +467,54 @@ class StationMapWindowScreenTest {
assertEquals(listOf(true, false), applied)
}
@Test
fun headerLegendFollowsDrawableAntennaAndSpatialFilterOverlays() = runComposeUiTest {
val prefs = prefs()
val state = windowState(prefs)
show(state, emptyList(), MapState())
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertDoesNotExist()
runOnIdle {
state.stationFilter.maxQrbKm = 666.0
state.stationFilter.maxQrbEnabled = true
}
waitForIdle()
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertExists()
runOnIdle {
prefs.stn_antennaBeamWidthDeg = 0.0
prefs.stn_maxQRBDefault = 0.0
state.refreshMapDisplaySettings()
state.stationFilter.maxQrbEnabled = false
}
waitForIdle()
onNodeWithText("Antenna").assertDoesNotExist()
onNodeWithText("Active filters").assertDoesNotExist()
}
@Test
fun invalidSpatialFilterDoesNotClaimAVisibleOverlay() = runComposeUiTest {
val state = windowState(prefs())
state.stationFilter.maxQrbKm = 0.0
state.stationFilter.maxQrbEnabled = true
show(state, emptyList(), MapState())
onNodeWithText("Antenna").assertExists()
onNodeWithText("Active filters").assertDoesNotExist()
runOnIdle { state.stationFilter.maxQrbKm = Double.NaN }
waitForIdle()
onNodeWithText("Active filters").assertDoesNotExist()
}
@Test
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
val state = windowState(prefs())
@@ -553,13 +591,9 @@ class StationMapWindowScreenTest {
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
}
/**
* A selected station the filters hide has no marker, and so nothing to analyse: no
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
* The visible station beside it is the control: selecting it does ask and does load.
*/
/** A filtered-out selection remains a separate target without entering the marker count. */
@Test
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
fun aSelectedStationTheFiltersHideKeepsItsTargetAndPathAnalysis() = runComposeUiTest {
val state = windowState(prefs(pathAnalysisVisible = true))
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
@@ -568,21 +602,128 @@ class StationMapWindowScreenTest {
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val analysis = RecordingPathAnalysis()
val mapState = mapLookingAt(ownLocator, zoom = 9f)
val home = Location(ownLocator)
show(state, listOf(hidden, shown), mapState, analysis)
runOnIdle { state.selectedStation.select(hidden) }
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
waitForIdle()
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
assertEquals(listOf(hidden), analysis.requested, "the selected hidden target was not analysed")
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
onNodeWithText("Showing 1 visible stations | filtered view active", substring = true).assertExists()
runOnIdle { state.selectedStation.select(shown) }
runOnIdle { state.selectStation(shown, StationSelectionSource.PRIORITY) }
waitForIdle()
assertEquals(listOf(shown), analysis.requested)
assertEquals(listOf(hidden, shown), analysis.requested)
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
}
@Test
fun filteredSelectedMarkerWinsHitTestingWhenAnotherMarkerOverlapsIt() = runComposeUiTest {
val state = windowState(prefs())
val hidden = station("DL1HIDE", 2, locator = ownLocator).apply { qrb = 100.0 }
val shown = station("DL2SHOW", 2, locator = ownLocator).apply { qrb = 800.0 }
state.stationFilter.minQrbKm = 300.0
state.stationFilter.minQrbEnabled = true
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
val selections = mutableListOf<ChatMember?>()
state.stationSelector = { member, source ->
selections += member
state.selectedStation.select(member, source)
}
show(state, listOf(hidden, shown), mapLookingAt(ownLocator, zoom = 12f))
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
waitForIdle()
selections.clear()
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { click() }
waitForIdle()
assertEquals(listOf(hidden), selections)
}
@Test
fun rosterReconciliationDoesNotOverwriteManualCameraMovement() = runComposeUiTest {
val state = windowState(prefs())
val selected = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 10f)
show(state, listOf(selected), mapState, RecordingPathAnalysis())
runOnIdle { state.selectStation(selected, StationSelectionSource.PUBLIC_MESSAGE) }
waitForIdle()
runOnIdle {
mapState.centerLon = 3.0
mapState.centerLat = 48.0
mapState.zoom = 11f
state.stations.replaceRows(listOf(station("DL1ABC", 2, locator = "JO50JP")))
}
waitForIdle()
assertEquals(3.0, mapState.centerLon, 1e-9)
assertEquals(48.0, mapState.centerLat, 1e-9)
assertEquals(11f, mapState.zoom)
}
@Test
fun explicitQtfNavigationPreservesZoomAndFilterResetDoesNotNavigate() = runComposeUiTest {
val state = windowState(prefs())
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
show(state, emptyList(), mapState)
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
waitForIdle()
val qtfLon = mapState.centerLon
val qtfLat = mapState.centerLat
assertEquals(9.5f, mapState.zoom)
assertTrue(qtfLon > Location(ownLocator).longitude.toDegrees())
assertTrue(qtfLat > Location(ownLocator).latitude.toDegrees())
runOnIdle { state.resetStationFilters() }
waitForIdle()
assertEquals(qtfLon, mapState.centerLon, 1e-9)
assertEquals(qtfLat, mapState.centerLat, 1e-9)
assertEquals(9.5f, mapState.zoom)
}
@Test
fun QtfNavigationDoesNotRequireOrChangeManualAntennaHeading() = runComposeUiTest {
val prefs = prefs().apply {
setStn_useQtfFilterDirectionAsAntennaHeading(false)
actualQTF.set(123.0)
}
val state = windowState(prefs)
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
show(state, emptyList(), mapState)
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
waitForIdle()
assertTrue(mapState.centerLon > Location(ownLocator).longitude.toDegrees())
assertTrue(mapState.centerLat > Location(ownLocator).latitude.toDegrees())
assertEquals(9.5f, mapState.zoom)
assertEquals(123.0, prefs.actualQTF.get(), 1e-9)
}
@Test
fun selectingTheSameStationAgainRepeatsPathNavigation() = runComposeUiTest {
val state = windowState(prefs())
val selected = station("DL1ABC", 2, locator = "JO50JP")
val mapState = mapLookingAt(ownLocator, zoom = 10f)
show(state, listOf(selected), mapState, RecordingPathAnalysis())
runOnIdle { state.selectStation(selected, StationSelectionSource.TIMELINE) }
waitForIdle()
val firstCentre = mapState.centerLon to mapState.centerLat
runOnIdle {
mapState.centerLon = 2.0
mapState.centerLat = 45.0
state.selectStation(selected, StationSelectionSource.TIMELINE)
}
waitForIdle()
assertEquals(firstCentre.first, mapState.centerLon, 1e-9)
assertEquals(firstCentre.second, mapState.centerLat, 1e-9)
}
}
@@ -41,7 +41,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
}
@Test
fun `beacon edits wait for apply, and the timer restarts only then`() {
fun `enable changes immediately while template and interval wait for apply`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
s.prefs.setBcn_beaconsEnabledMainCat(false)
@@ -51,7 +51,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
assertNull(state.commitBeaconInterval("7"))
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat(), "the runtime switch did not take effect immediately")
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
@@ -65,6 +65,18 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
assertEquals(1, s.timerRestarts)
}
@Test
fun `enable changes do not become pending draft edits`() {
val s = Session(draft = SettingsDraft())
s.prefs.setBcn_beaconsEnabledMainCat(false)
val state = s.state()
state.bcn_beaconsEnabledMainCat = true
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
assertFalse(s.draft.hasPendingChanges)
}
@Test
fun `discarded beacon edits leave the timer and the templates alone`() {
val s = Session(draft = SettingsDraft())
@@ -162,8 +162,6 @@ class DraftedSettingsTableTest {
}, { it.prefs.getLst_txtSnipList().snapshot() }),
// Beacon
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
{ it.prefs.getBcn_beaconTextMainCat() }),
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
@@ -3,6 +3,7 @@ package kst4contest.view.compose.tabs
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.onAllNodesWithText
@@ -190,6 +191,19 @@ class MessagingTabsScreenTest {
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
}
@Test
fun anExternalBeaconToggleUpdatesTheOpenSettingsTab() = runComposeUiTest {
val s = Beacon(preferences())
s.prefs.isBcn_beaconsEnabledMainCat = false
showTab { BeaconTab(s.state) { s.refusals += it } }
checkBeside("Send the beacon in this category").assertIsOff()
runOnIdle { s.prefs.isBcn_beaconsEnabledMainCat = true }
waitForIdle()
checkBeside("Send the beacon in this category").assertIsOn()
}
/*
* The tab reads the interval from a plain preference getter, so nothing recomposes it
* after Enter commits. Compared with that stale value, leaving the field afterwards
@@ -107,14 +107,14 @@ class SettingsApplyScreenTest {
}
@Test
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
fun beaconEnableTakesEffectImmediatelyWhileTheIntervalWaitsForApply() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the runtime switch did not take effect immediately")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts, "the timer restarted before Apply")
@@ -178,19 +178,19 @@ class SettingsApplyScreenTest {
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
onNodeWithText(APPLY_AND_CLOSE).performClick()
waitForIdle()
assertFalse(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the valid edit went into effect despite the refusal")
assertTrue(w.draft.hasPendingChanges)
runOnIdle { w.notices.dismiss() }
runOnIdle { w.draft.discard() }
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
/*
@@ -264,7 +264,7 @@ class SettingsApplyScreenTest {
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog(PENDING_QUESTION))
@@ -273,7 +273,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(w.closed)
}
@@ -288,7 +288,7 @@ class SettingsApplyScreenTest {
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the immediate runtime switch was unexpectedly reverted")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertEquals(0, w.restarts)
assertTrue(w.closed)
@@ -299,7 +299,7 @@ class SettingsApplyScreenTest {
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.x() }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -307,7 +307,7 @@ class SettingsApplyScreenTest {
assertFalse(w.closed)
assertTrue(w.draft.hasPendingChanges)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
checkBeside("Send the beacon in this category").assertExists()
}
@@ -377,13 +377,13 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Apply")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
assertTrue(wentOn)
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
}
@@ -393,7 +393,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Back to the settings")).performClick()
@@ -408,14 +408,14 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog("Discard")).performClick()
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(wentOn)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
}
@Test
@@ -423,7 +423,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -432,7 +432,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertFalse(wentOn, "went on over a refused entry")
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "applied despite the refusal")
}
@Test
@@ -457,7 +457,7 @@ class SettingsApplyScreenTest {
val w = Window()
var wentOn = false
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
runOnIdle { w.switchOrExit { wentOn = true } }
awaitNode(inDialog(PENDING_QUESTION))
@@ -477,7 +477,7 @@ class SettingsApplyScreenTest {
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
val w = Window()
show(w)
toggle("Send the beacon in this category")
typeAndCommit("Interval in whole minutes", "9")
type("Server port", "23x")
runOnIdle { w.x() }
@@ -489,7 +489,7 @@ class SettingsApplyScreenTest {
awaitGone(inDialog(PENDING_QUESTION))
assertTrue(w.closed)
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "something was applied")
}
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
@@ -20,6 +20,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
var secondApplied: ChatCategory? = null
var secondWasApplied = false
val secondRotatorCalls = mutableListOf<Unit>()
var beaconStatusRefreshes = 0
var mapDisplayRefreshes = 0
val draft = SettingsDraft()
val state = StationTabState(
prefs,
@@ -27,6 +29,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
{ mainApplied = it },
{ secondApplied = it; secondWasApplied = true },
applySecondRotator = { secondRotatorCalls.add(Unit) },
refreshBeaconStatus = { beaconStatusRefreshes++ },
applyMapDisplaySettings = { mapDisplayRefreshes++ },
)
}
@@ -305,6 +309,34 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
assertEquals(true, state.loginToSecondChatEnabled)
}
@Test
fun `applying the second chat switch refreshes aggregate beacon status`() {
val session = Session()
session.state.loginToSecondChatEnabled = true
assertEquals(0, session.beaconStatusRefreshes)
session.draft.apply()
assertEquals(1, session.beaconStatusRefreshes)
}
@Test
fun `beam width and maximum QRB refresh the map once after both are applied`() {
val session = Session()
session.state.stn_antennaBeamWidthDeg = 72.0
session.state.stn_maxQRBDefault = 1_250.0
assertEquals(0, session.mapDisplayRefreshes)
val outcome = session.draft.apply()
assertTrue(outcome.isClean, outcome.failures.toString())
assertEquals(72.0, session.prefs.stn_antennaBeamWidthDeg, 0.0001)
assertEquals(1_250.0, session.prefs.stn_maxQRBDefault, 0.0001)
assertEquals(1, session.mapDisplayRefreshes)
}
@Test
fun `the own height above sea level reads empty while the elevation model decides`() {
val s = Session()