fix: complete Compose UI workflow corrections

This commit is contained in:
Marc Froehlich committed 2026-10-24 17:33:57 +02:00
1 parent 971fb35109
commit 4be6c178f8
57 files changed
+3082 -463

No files matched your search

@@ -256,9 +256,14 @@ filter.onlyNewGrids=Nur neue Felder
filter.gridColour=Feldfarbe
filter.newBands=Neue Bänder
filter.tropo=Tropo >=0dB
filter.hide=Ausblenden
filter.show=Anzeigen
filter.reachability=Erreichbarkeit
filter.calcSelected=Auswahl berechnen
table.empty=Keine Einträge
selected.none=Keine Station gewählt.
selected.priority=Priorität:
selected.newJoined=Neu hinzugekommen:
selected.more=mehr
selected.showMessages=Nachrichten zeigen
selected.createSked=Sked anlegen
@@ -430,6 +435,16 @@ dialog.apply=Übernehmen
chat.clear=leeren
selected.showOnMap=🗺 Auf der Karte zeigen
selected.notQrvAll=alle
context.messageSnippets=Textbausteine
context.createSked=Sked erstellen…
context.tagNotQrv=Als NOT QRV markieren
context.markWorked=Als gearbeitet markieren
context.calculateReachability=Erreichbarkeit berechnen
context.chooseStation=Station auswählen
context.back=Zurück
context.category=Kategorie {0}
context.skedTitle=Sked für {0} erstellen
context.cancel=Abbrechen
column.category=Kategorie
column.sender=Absender
column.receiver=Empfänger
@@ -265,9 +265,14 @@ filter.onlyNewGrids=Only new grids
filter.gridColour=Grid color
filter.newBands=New bands
filter.tropo=Tropo >=0dB
filter.hide=Hide
filter.show=Show
filter.reachability=Reachability
filter.calcSelected=Calc selected
table.empty=No entries
selected.none=No station selected.
selected.priority=Priority:
selected.newJoined=New joined:
selected.more=more
selected.showMessages=Show messages
selected.createSked=Create sked
@@ -439,6 +444,16 @@ dialog.apply=Apply
chat.clear=clear
selected.showOnMap=🗺 Show on map
selected.notQrvAll=all
context.messageSnippets=Message snippets
context.createSked=Create sked…
context.tagNotQrv=Tag NOT QRV
context.markWorked=Mark as worked
context.calculateReachability=Calculate reachability
context.chooseStation=Choose station
context.back=Back
context.category=Category {0}
context.skedTitle=Create sked for {0}
context.cancel=Cancel
column.category=Category
column.sender=Sender
column.receiver=Receiver
@@ -27,6 +27,8 @@ import kst4contest.view.compose.StationSelectionSource;
import kst4contest.view.compose.StationSelectionState;
import kst4contest.view.compose.TopPriorityCandidatesWindow;
import kst4contest.view.compose.TopPriorityState;
import kst4contest.view.compose.NewJoinedState;
import kst4contest.view.compose.SkedRequest;
import kst4contest.view.compose.TimelineState;
import kst4contest.view.compose.ChatInputState;
import kst4contest.view.compose.MainMenuState;
@@ -83,6 +85,9 @@ import kst4contest.controller.OperatorProfilePaths;
import kst4contest.model.OperatorProfile;
import kst4contest.model.OperatorProfileSelection;
import kst4contest.view.map.OfflineDemImportService;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
import kst4contest.view.map.PathAnalysisResult;
import kst4contest.controller.WorkedGrossFieldCache;
@@ -3023,6 +3028,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
*/
private kst4contest.view.i18n.LanguageStore languageStore;
private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener;
private java.util.function.Consumer<java.util.List<RecentChatJoin>> composeRecentJoinListener;
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
@@ -3182,6 +3188,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
},
surroundings::getSecondAntennaOffered,
stationSelection);
selectedStation.setSkedRequestSubmitter(this::createSkedFromCompose);
selectedStation.setMessageFilter(
SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences()));
@@ -3189,6 +3196,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
TopPriorityState topPriority = new TopPriorityState(
this::composeTopStations,
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY));
NewJoinedState newJoined = new NewJoinedState(
System::currentTimeMillis,
member -> selectStationInCompose(
selectedStation, member, StationSelectionSource.NEW_JOINED));
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
@@ -3241,6 +3252,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave();
return kotlin.Unit.INSTANCE;
}),
new SplitterState(
MainWindowState.SELECTED_STATION_PANE_COUNT,
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition(),
SplitterState.DEFAULT_MIN_PANE_PX,
positions -> {
chatcontroller.getChatPreferences()
.setGUIselectedCallSignSplitPane_dividerposition(positions);
requestLayoutSave();
return kotlin.Unit.INSTANCE;
}),
newJoined,
reachability);
/*
@@ -3269,8 +3291,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
* Subscribed first and seeded afterwards, so a message arriving in between is not
* missed — the order RosterListBinding uses for the same reason.
*/
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> {
stations.replaceRows(rows);
newJoined.updateActiveMembers(rows);
});
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
composeRecentJoinListener = rows -> uiDispatcher.runOnUi(() -> newJoined.updateEvents(rows));
chatcontroller.getRecentChatJoins().addListener(composeRecentJoinListener);
composeMainWindowState.setStationSelector((member, source) -> {
selectStationInCompose(selectedStation, member, source);
@@ -3284,6 +3311,36 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
requestLayoutSave();
return kotlin.Unit.INSTANCE;
});
composeMainWindowState.setMarkStationWorked(
(member, band) -> chatcontroller.markStationWorkedOnBand(member, band));
composeMainWindowState.setWorkedGrossFieldsProvider(
() -> chatcontroller.getWorkedGrossFieldCache().snapshotWorkedGrossFieldsAny());
composeMainWindowState.getMapReachability().setRequester((member, band, callback) -> {
java.util.List<ChatMember> variants = chatcontroller.getLst_chatMemberList().snapshot()
.stream()
.filter(candidate -> candidate != null
&& java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw()))
.toList();
java.util.EnumSet<Band> bands = java.util.EnumSet.noneOf(Band.class);
bands.addAll(enabledStationBands);
MapCallsignRawSnapshot snapshot = new MapCallsignRawSnapshotBuilder()
.buildSnapshots(variants, member, bands)
.stream()
.findFirst()
.orElse(null);
if (snapshot == null) {
callback.invoke(PathAnalysisResult.waitingForValidTarget(
chatcontroller.getChatPreferences().getStn_loginLocatorMainCat(),
member.getQra()));
return kotlin.Unit.INSTANCE;
}
chatcontroller.getReachabilityService().requestPathAnalysisForMap(
member,
snapshot,
band,
result -> callback.invoke(result));
return kotlin.Unit.INSTANCE;
});
composeChatListener = rows -> uiDispatcher.runOnUi(() -> {
directed.replaceRows(chatcontroller.toMeMessages());
@@ -3300,7 +3357,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
}
uiDispatcher.runOnUi(() -> topPriority.refresh());
stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot());
java.util.List<ChatMember> initialMembers = chatcontroller.getLst_chatMemberList().snapshot();
stations.replaceRows(initialMembers);
newJoined.updateActiveMembers(initialMembers);
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
directed.replaceRows(chatcontroller.toMeMessages());
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
@@ -3410,6 +3470,29 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
final int minutes,
final Band band,
final String mode) {
boolean remindPm = false;
String reminderOffsets = SelectedStationState.Companion.getREMINDER_OFFSETS().get(0);
if (composeMainWindowState != null) {
SelectedStationState state = composeMainWindowState.getSelectedStation();
remindPm = state.getRemindPm();
reminderOffsets = state.getReminderOffsets();
}
return createSkedFromCompose(new SkedRequest(
selectedMember,
minutes,
band,
mode,
remindPm,
reminderOffsets));
}
private boolean createSkedFromCompose(final SkedRequest request) {
if (request == null) {
return false;
}
final ChatMember selectedMember = request.getTarget();
final int minutes = request.getMinutes();
final Band band = request.getBand();
if (selectedMember == null || band == null) {
return false;
@@ -3431,17 +3514,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getScoreService().requestRecompute("sked-created");
// Arm PM reminders, exactly as the JavaFX Create sked handler does
if (composeMainWindowState != null) {
SelectedStationState ssState = composeMainWindowState.getSelectedStation();
if (ssState.getRemindPm()) {
List<Integer> offsets = parseMinuteOffsets(ssState.getReminderOffsets());
if (request.getRemindPm()) {
List<Integer> offsets = parseMinuteOffsets(request.getReminderOffsets());
chatcontroller.getSkedReminderService().armReminders(
sked.getTargetChatCallsign(),
sked.getTargetChatCategory(),
skedTime,
offsets
);
}
}
return true;
@@ -3624,6 +3704,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
composeMemberListener = null;
}
if (composeRecentJoinListener != null) {
chatcontroller.getRecentChatJoins().removeListener(composeRecentJoinListener);
composeRecentJoinListener = null;
}
if (composeChatListener != null) {
chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
composeChatListener = null;
@@ -29,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.material3.Surface
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextOverflow
import java.awt.Cursor
@@ -43,14 +46,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import kst4contest.view.i18n.LocalStrings
import kotlin.math.roundToInt
/**
* A multi-column table.
@@ -72,13 +80,15 @@ import kst4contest.view.i18n.LocalStrings
* the supported way to reach the hover text the JavaFX cells had, and losing that
* loses the only way to read a value that does not fit its column.
*/
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun <T> DataTableView(
state: DataTableState<T>,
modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null,
emphasizeCell: ((T, DataColumn<T>) -> Boolean)? = null,
onRowClick: ((T) -> Unit)? = null,
onRowContextMenu: ((T, IntOffset) -> Unit)? = null,
selectOnRowClick: Boolean = true,
) {
val density = LocalDensity.current
@@ -321,6 +331,9 @@ fun <T> DataTableView(
val accent = rowStyle?.invoke(row)
val isEven = index % 2 == 0
val _forceRedraw = state.contentRevision
var rowCoordinates by remember(state.keyOf(row)) {
mutableStateOf<LayoutCoordinates?>(null)
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -341,6 +354,20 @@ fun <T> DataTableView(
}
onRowClick?.invoke(row)
}
.onGloballyPositioned { rowCoordinates = it }
.onPointerEvent(PointerEventType.Press) { event ->
if (event.buttons.isSecondaryPressed) {
val local = event.changes.firstOrNull()?.position
val window = local?.let { rowCoordinates?.localToWindow(it) }
if (window != null) {
onRowContextMenu?.invoke(
row,
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
)
event.changes.forEach { it.consume() }
}
}
}
.heightIn(min = Density.ROW_MIN_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -377,6 +404,11 @@ fun <T> DataTableView(
Text(
value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (emphasizeCell?.invoke(row, column) == true) {
FontWeight.Bold
} else {
null
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = cell?.foreground
@@ -38,11 +38,11 @@ internal object Density {
/** Two-letter compass labels need only a small inset inside their fixed width. */
val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp
/** A compact, independently scrollable message table in the selected-station pane. */
val SELECTED_MESSAGE_TABLE_HEIGHT = 104.dp
/** Enough room for the NOT-QRV and sked groups to remain usable side by side. */
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 520.dp
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 450.dp
/** Keeps the station actions usable when a stored splitter position is too low. */
val SELECTED_ACTION_AREA_MIN_HEIGHT = 160.dp
val TAB_MIN_HEIGHT = 30.dp
@@ -246,6 +246,10 @@ private fun StationColumn(
onFiltersChanged: () -> Unit,
onShowMorePriorities: () -> Unit,
) {
val stationContextMenu = remember(state) {
StationContextMenuState(state::selectStation)
}
SplitterPane(
state = state.rightSplitter,
orientation = SplitterOrientation.VERTICAL,
@@ -264,19 +268,28 @@ private fun StationColumn(
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(thickness = Density.HAIRLINE)
SnippetContextMenu(state.snippets.entries, state.chatInput) {
DataTableView(
state = state.stations,
modifier = Modifier.fillMaxSize(),
onRowClick = onStationSelected,
selectOnRowClick = false,
)
}
DataTableView(
state = state.stations,
modifier = Modifier.fillMaxSize(),
emphasizeCell = { member, column ->
column.id == "callsign" && state.newJoined.isRecent(member)
},
onRowClick = onStationSelected,
onRowContextMenu = { member, position ->
stationContextMenu.open(
listOf(member),
StationSelectionSource.STATION_CONTEXT_MENU,
position,
)
},
selectOnRowClick = false,
)
}
},
{
TopPriorityBar(
state = state.topPriority,
newJoined = state.newJoined,
onShowMore = onShowMorePriorities,
modifier = Modifier.fillMaxWidth(),
)
@@ -286,6 +299,7 @@ private fun StationColumn(
state = state.selectedStation,
messages = state.selectedStationMessages,
activeBands = state.activeBands,
contentSplitter = state.selectedStationSplitter,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
@@ -294,4 +308,5 @@ private fun StationColumn(
},
),
)
StationContextOverlays(stationContextMenu, state, onShowOnMap)
}
@@ -55,13 +55,21 @@ class MainWindowState(
val bandUpgradeNotice: BlinkingNotice,
val threadButtons: ThreadStatusButtons,
/** The three splitters, whose positions the operator has arranged and stored. */
/** The splitters whose positions the operator has arranged and stored. */
val outerSplitter: SplitterState,
val messageSplitter: SplitterState,
val rightSplitter: SplitterState,
val selectedStationSplitter: SplitterState = SplitterState(
SELECTED_STATION_PANE_COUNT,
doubleArrayOf(0.55),
) {},
val newJoined: NewJoinedState = NewJoinedState(onSelect = {}),
val reachability: ReachabilityState = ReachabilityState(activeBands),
) {
/** Explicit map analysis requested from either station context menu. */
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
/** The shortcut buttons above the send line. */
/**
* The one path that selects a station for the whole application (panel, table, score
@@ -76,11 +84,18 @@ class MainWindowState(
/** Debounced persistence hook shared by the main and map windows. */
var layoutSaveRequested: (() -> Unit)? = null
/** Contactinfo-equivalent manual Worked action supplied by the controller host. */
var markStationWorked: ((ChatMember, kst4contest.model.Band) -> Boolean)? = null
/** Persistent any-band Worked gross fields used by the optional map overlay. */
var workedGrossFieldsProvider: (() -> Set<String>) = { emptySet() }
val stationSelection: StationSelectionState
get() = selectedStation.stationSelection
/** Routes every explicit station choice through the application-level action. */
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
mapReachability.selectionChanged(StationIdentity.of(member))
val selector = stationSelector
if (selector != null) {
selector(member, source)
@@ -132,6 +147,9 @@ class MainWindowState(
/** Station list, top priorities, selected station. */
const val RIGHT_PANE_COUNT = 3
/** Selected-station message table and its scrollable action area. */
const val SELECTED_STATION_PANE_COUNT = 2
}
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.view.map.PathAnalysisResult
/**
* Explicit map reachability request shared by station-list and map context menus.
* A monotonically increasing request ID prevents older callbacks from replacing a
* result for a newer station or band.
*/
class MapReachabilityState(
private val ownLocator: String,
requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> },
) {
var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester
var result: PathAnalysisResult? by mutableStateOf(null)
private set
var running: Boolean by mutableStateOf(false)
private set
var requestRevision: Long by mutableStateOf(0L)
private set
var targetIdentity: StationIdentity? by mutableStateOf(null)
private set
var requestedBand: Band? by mutableStateOf(null)
private set
fun calculate(member: ChatMember?, band: Band?): Boolean {
if (member == null || band == null) return false
val identity = StationIdentity.of(member) ?: return false
val requestId = ++requestRevision
targetIdentity = identity
requestedBand = band
running = true
result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw)
try {
requester(member, band) { completed ->
if (requestRevision == requestId
&& targetIdentity == identity
&& requestedBand == band
) {
result = completed
running = false
}
}
} catch (exception: RuntimeException) {
if (requestRevision == requestId) {
result = PathAnalysisResult.noProfile(
"Failed",
ownLocator,
member.qra,
member.callSignRaw,
Double.NaN,
member.getQTFdirection() ?: Double.NaN,
Double.NaN,
Double.NaN,
band.defaultAnalysisFrequencyMHz,
exception.message ?: "Reachability calculation failed.",
)
running = false
}
}
return true
}
fun selectionChanged(identity: StationIdentity?) {
if (targetIdentity == null || targetIdentity == identity) return
requestRevision++
targetIdentity = null
requestedBand = null
result = null
running = false
}
}
@@ -0,0 +1,100 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
/** One selectable station in the transient New joined group. */
class NewJoinedEntry internal constructor(
val identity: StationIdentity,
val label: String,
val joinedAtEpochMillis: Long,
private val member: ChatMember,
private val onSelect: (ChatMember) -> Unit,
) {
fun select() = onSelect(member)
}
/**
* Compose-facing view of live ON4KST join events.
*
* Initial and resynchronized user lists never reach this state. It receives the core's
* live-event roster and intersects it with the currently active station roster, so a
* departed station disappears immediately. The clock is injected to make expiry exact
* and testable without waiting ten minutes.
*/
class NewJoinedState(
private val clock: () -> Long = System::currentTimeMillis,
private val onSelect: (ChatMember) -> Unit,
) {
private var events: List<RecentChatJoin> = emptyList()
private var activeMembers: List<ChatMember> = emptyList()
private var liveEntries by mutableStateOf<List<NewJoinedEntry>>(emptyList())
val entries: List<NewJoinedEntry>
get() = liveEntries
val visibleEntries: List<NewJoinedEntry>
get() = liveEntries.take(SHOWN)
val overflowEntries: List<NewJoinedEntry>
get() = liveEntries.drop(SHOWN)
fun updateEvents(newEvents: List<RecentChatJoin>) {
events = newEvents.toList()
refresh()
}
fun updateActiveMembers(newMembers: List<ChatMember>) {
activeMembers = newMembers.toList()
refresh()
}
/** Removes expired or departed entries and resolves each event to the latest row instance. */
fun refresh() {
val now = clock()
val activeByIdentity = activeMembers.mapNotNull { member ->
StationIdentity.of(member)?.let { identity -> identity to member }
}.toMap()
val firstJoinByIdentity = linkedMapOf<StationIdentity, RecentChatJoin>()
events.forEach { event ->
val identity = StationIdentity.of(event.callSign, event.categoryNumber)
?: return@forEach
val existing = firstJoinByIdentity[identity]
if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) {
firstJoinByIdentity[identity] = event
}
}
liveEntries = firstJoinByIdentity
.mapNotNull { (identity, event) ->
val active = activeByIdentity[identity] ?: return@mapNotNull null
val ageMillis = now - event.joinedAtEpochMillis
if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) {
return@mapNotNull null
}
NewJoinedEntry(
identity = identity,
label = identity.callSign,
joinedAtEpochMillis = event.joinedAtEpochMillis,
member = active,
onSelect = onSelect,
)
}
.sortedByDescending(NewJoinedEntry::joinedAtEpochMillis)
}
fun isRecent(member: ChatMember): Boolean {
val identity = StationIdentity.of(member) ?: return false
return liveEntries.any { it.identity == identity }
}
companion object {
const val VALIDITY_MILLIS = 10L * 60L * 1000L
const val SHOWN = 5
}
}
@@ -12,9 +12,11 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.rememberScrollbarAdapter
import androidx.compose.foundation.verticalScroll
@@ -22,7 +24,14 @@ import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.foundation.selection.selectable
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,10 +46,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag
import kst4contest.model.Band
import java.util.function.Consumer
import kotlinx.coroutines.delay
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
internal const val SELECTED_MESSAGES_TABLE_TAG = "selected-messages-table"
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group"
internal const val NEW_JOINED_MORE_TAG = "new-joined-more"
internal const val NEW_JOINED_GROUP_TAG = "new-joined-group"
internal const val PRIORITY_GROUP_TAG = "priority-group"
internal fun skedBandOptions(activeBands: Set<Band>): List<Band> {
val options = ArrayList<Band>(activeBands.size)
@@ -67,6 +81,7 @@ fun SelectedStationPanel(
messages: DataTableState<kst4contest.model.ChatMessage>,
/** My own station's enabled bands; used for NOT-QRV and sked controls. */
activeBands: Set<Band>,
contentSplitter: SplitterState? = null,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
@@ -74,8 +89,9 @@ fun SelectedStationPanel(
) {
val strings = LocalStrings.current
val member = state.selected
val scroll = rememberScrollState()
val splitter = contentSplitter ?: remember {
SplitterState(2, doubleArrayOf(DEFAULT_SELECTED_MESSAGE_FRACTION)) {}
}
Box(
modifier = modifier
@@ -84,22 +100,60 @@ fun SelectedStationPanel(
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, top = 3.dp, end = Density.SCROLLBAR_GUTTER, bottom = 4.dp)
.verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Column(modifier = Modifier.fillMaxSize()) {
if (member == null) {
Text(
strings.selectedNone,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(4.dp),
)
return@Column
}
SelectedStationHeaderAndFilter(state, member)
SplitterPane(
state = splitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxWidth().weight(1f),
minimumPaneSizes = listOf(
Density.ROW_MIN_HEIGHT * 2,
Density.SELECTED_ACTION_AREA_MIN_HEIGHT,
),
panes = listOf(
{
DataTableView(
state = messages,
modifier = Modifier.fillMaxSize().testTag(SELECTED_MESSAGES_TABLE_TAG),
)
},
{
SelectedStationFunctions(
state = state,
activeBands = activeBands,
openInBrowser = openInBrowser,
onShowOnMap = onShowOnMap,
onShowPathInAirScout = onShowPathInAirScout,
)
},
),
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationHeaderAndFilter(
state: SelectedStationState,
member: kst4contest.model.ChatMember,
) {
val strings = LocalStrings.current
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
val detectedBands = state.detectedBands()
val metadata = buildList {
@@ -142,10 +196,11 @@ fun SelectedStationPanel(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
itemVerticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
Text(
"${strings.selectedShowMessages}:",
@@ -166,20 +221,51 @@ fun SelectedStationPanel(
)
}
}
}
}
DataTableView(
state = messages,
modifier = Modifier
.fillMaxWidth()
.height(Density.SELECTED_MESSAGE_TABLE_HEIGHT),
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationFunctions(
state: SelectedStationState,
activeBands: Set<Band>,
openInBrowser: Consumer<String>,
onShowOnMap: () -> Unit,
onShowPathInAirScout: () -> Unit,
) {
val strings = LocalStrings.current
val scroll = rememberScrollState()
Column(
modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = Density.SCROLLBAR_GUTTER)
.verticalScroll(scroll),
) {
SelectedStationActionGroups(
state = state,
activeBands = activeBands,
)
}
SelectedStationActionGroups(
state = state,
activeBands = activeBands,
)
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
Form.section(strings.selectedLookUpAndShow) {
CompactFunctionGroup(
title = strings.selectedLookUpAndShow,
modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER),
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
@@ -202,18 +288,10 @@ fun SelectedStationPanel(
}
}
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(scroll),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
style = LocalScrollbarStyle.current.copy(
unhoverColor = MaterialTheme.colorScheme.outline,
hoverColor = MaterialTheme.colorScheme.onSurface,
),
)
}
}
private const val DEFAULT_SELECTED_MESSAGE_FRACTION = 0.55
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SelectedStationActionGroups(
@@ -294,7 +372,7 @@ private fun CompactFunctionGroup(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(4.dp),
.padding(3.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text(
@@ -310,6 +388,47 @@ private fun CompactFunctionGroup(
@Composable
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
val strings = LocalStrings.current
SkedEditorControls(
activeBands = activeBands,
minutes = state.skedMinutes,
onMinutesChanged = { state.skedMinutes = it },
band = state.skedBand,
onBandChanged = { state.skedBand = it },
mode = state.skedMode,
onModeChanged = { state.skedMode = it },
remindPm = state.remindPm,
onRemindPmChanged = { state.remindPm = it },
reminderOffsets = state.reminderOffsets,
onReminderOffsetsChanged = { state.reminderOffsets = it },
canCreate = state.canCreateSked,
onCreate = { state.createSked() },
additionalActions = {
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
},
)
}
/** Shared compact sked editor used by the station panel and the context popup. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SkedEditorControls(
activeBands: Set<Band>,
minutes: Int,
onMinutesChanged: (Int) -> Unit,
band: Band?,
onBandChanged: (Band) -> Unit,
mode: String?,
onModeChanged: (String) -> Unit,
remindPm: Boolean,
onRemindPmChanged: (Boolean) -> Unit,
reminderOffsets: String,
onReminderOffsetsChanged: (String) -> Unit,
canCreate: Boolean,
onCreate: () -> Unit,
additionalActions: @Composable () -> Unit = {},
) {
val strings = LocalStrings.current
FlowRow(
modifier = Modifier.fillMaxWidth(),
@@ -319,32 +438,32 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
Form.inlineChoice(
label = strings.selectedSkedIn,
items = SelectedStationState.SKED_MINUTES,
selected = state.skedMinutes,
selected = minutes,
describe = { it.toString() },
trailingText = strings.selectedSkedMinutesAt,
onSelect = { state.skedMinutes = it },
onSelect = onMinutesChanged,
)
Form.inlineChoice(
label = "",
items = skedBandOptions(activeBands),
selected = state.skedBand,
selected = band,
describe = { it.prefix + " MHz" },
trailingText = strings.selectedSkedModeLink,
onSelect = { state.skedBand = it },
onSelect = onBandChanged,
)
Form.inlineChoice(
label = "",
items = SelectedStationState.SKED_MODES,
selected = state.skedMode,
selected = mode,
describe = { it },
trailingText = ".",
onSelect = { state.skedMode = it },
onSelect = onModeChanged,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
CompactCheckbox(state.remindPm) { state.remindPm = it }
CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged)
Text(
strings.selectedRemindPmIn,
style = compactControlTextStyle(),
@@ -353,11 +472,11 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
Form.inlineChoice(
label = "",
items = SelectedStationState.REMINDER_OFFSETS,
selected = state.reminderOffsets,
selected = reminderOffsets,
describe = { it },
enabled = state.remindPm,
enabled = remindPm,
trailingText = strings.selectedMinutes,
onSelect = { state.reminderOffsets = it },
onSelect = onReminderOffsetsChanged,
)
}
}
@@ -367,9 +486,8 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate)
additionalActions()
}
}
@@ -380,29 +498,111 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
@Composable
fun TopPriorityBar(
state: TopPriorityState,
newJoined: NewJoinedState? = null,
onShowMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val strings = LocalStrings.current
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry ->
Form.button(entry.label, onClick = entry.select)
LaunchedEffect(newJoined) {
while (newJoined != null) {
delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS)
newJoined.refresh()
}
}
Form.button(strings.selectedMore, onClick = onShowMore)
BoxWithConstraints(modifier = modifier) {
val allJoinedEntries = newJoined?.entries.orEmpty()
val directCount = when {
maxWidth >= 520.dp -> NewJoinedState.SHOWN
maxWidth >= 450.dp -> 4
maxWidth >= 380.dp -> 3
maxWidth >= 310.dp -> 2
else -> 1
}
val joinedEntries = allJoinedEntries.take(directCount)
val overflow = allJoinedEntries.drop(directCount)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
if (joinedEntries.isNotEmpty()) {
CandidateGroup(Modifier.testTag(NEW_JOINED_GROUP_TAG)) {
Text(strings.selectedNewJoined, style = MaterialTheme.typography.bodySmall)
joinedEntries.forEach { entry ->
Form.button(entry.label, horizontalPadding = 4.dp, onClick = entry::select)
}
if (overflow.isNotEmpty()) {
NewJoinedOverflow(overflow)
}
}
}
CandidateGroup(Modifier.testTag(PRIORITY_GROUP_TAG)) {
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
state.entries.forEach { entry ->
Form.button(entry.label, onClick = entry.select)
}
Form.button(strings.selectedMore, onClick = onShowMore)
}
}
}
}
@Composable
private fun CandidateGroup(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
FlowRow(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NewJoinedOverflow(entries: List<NewJoinedEntry>) {
val strings = LocalStrings.current
var expanded by remember { mutableStateOf(false) }
Box {
Form.button(
strings.selectedMore,
modifier = Modifier.testTag(NEW_JOINED_MORE_TAG),
horizontalPadding = 4.dp,
) { expanded = true }
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
entries.forEach { entry ->
DropdownMenuItem(
text = { Text(entry.label, style = compactControlTextStyle()) },
onClick = {
expanded = false
entry.select()
},
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
}
}
}
}
private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L
/**
* One of a set of mutually exclusive choices.
@@ -418,6 +618,7 @@ private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit)
Row(
modifier = Modifier
.selectable(selected = selected, onClick = onSelect)
.height(Density.CONTROL_HEIGHT)
.padding(end = Density.BUTTON_GAP),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -86,6 +86,9 @@ class SelectedStationState @JvmOverloads constructor(
val stationSelection: StationSelectionState = StationSelectionState(),
) {
/** Shared sked action used by both the panel and isolated context-menu drafts. */
var skedRequestSubmitter: ((SkedRequest) -> Boolean)? = null
val selected: ChatMember?
get() = stationSelection.selected
@@ -162,15 +165,22 @@ class SelectedStationState @JvmOverloads constructor(
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision
val member = selected ?: return false
return selected?.let { isTaggedNotQrv(it, band) } ?: false
}
fun isTaggedNotQrv(member: ChatMember, band: NotQrvBand): Boolean {
@Suppress("UNUSED_EXPRESSION") tagRevision
return !band.read(member)
}
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
val member = selected ?: return
tagNotQrv(member, band, tagged)
}
/** Applies a context action to its fixed target instead of the current selection. */
fun tagNotQrv(member: ChatMember, band: NotQrvBand, tagged: Boolean) {
band.write(member, !tagged)
tagRevision++
propagateNotQrv(member)
@@ -178,7 +188,10 @@ class SelectedStationState @JvmOverloads constructor(
fun tagNotQrvAll(tagged: Boolean) {
val member = selected ?: return
tagNotQrvAll(member, tagged)
}
fun tagNotQrvAll(member: ChatMember, tagged: Boolean) {
NotQrvBand.entries.forEach { it.write(member, !tagged) }
tagRevision++
propagateNotQrv(member)
@@ -188,9 +201,33 @@ class SelectedStationState @JvmOverloads constructor(
fun createSked(): Boolean {
val member = selected ?: return false
return createSked(member, skedMinutes, skedBand, skedMode)
val request = SkedRequest(
target = member,
minutes = skedMinutes,
band = skedBand,
mode = skedMode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
)
return submitSked(request)
}
/** Submits a fixed-target request through the shared application action. */
fun submitSked(request: SkedRequest): Boolean =
skedRequestSubmitter?.invoke(request)
?: createSked(request.target, request.minutes, request.band, request.mode)
/** Copies the panel values into an isolated draft for a fixed target. */
fun createSkedDraft(target: ChatMember): SkedEditorState = SkedEditorState(
target = target,
minutes = skedMinutes,
band = skedBand ?: resolveDefaultSkedBand(target),
mode = skedMode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
submitter = ::submitSked,
)
/**
* Marks the path to this station as failed.
*
@@ -0,0 +1,50 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
/** Complete immutable request handed to the shared sked creation action. */
data class SkedRequest(
val target: ChatMember,
val minutes: Int,
val band: Band?,
val mode: String?,
val remindPm: Boolean,
val reminderOffsets: String,
)
/**
* Isolated editor draft used by the station-context popup.
*
* The target is captured when the popup opens. Later station selections therefore do
* not redirect a partially edited sked.
*/
class SkedEditorState(
val target: ChatMember,
minutes: Int,
band: Band?,
mode: String?,
remindPm: Boolean,
reminderOffsets: String,
private val submitter: (SkedRequest) -> Boolean,
) {
var minutes by mutableStateOf(minutes)
var band by mutableStateOf(band)
var mode by mutableStateOf(mode)
var remindPm by mutableStateOf(remindPm)
var reminderOffsets by mutableStateOf(reminderOffsets)
fun request(): SkedRequest = SkedRequest(
target = target,
minutes = minutes,
band = band,
mode = mode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
)
fun submit(): Boolean = submitter(request())
}
@@ -119,7 +119,14 @@ class SplitterState(
}
val upperBound = totalPx - trailingMinimum
val wanted = livePositions[index] * totalPx
val position = wanted.coerceIn(lowerBound, upperBound)
// Scaling impossible minimums can leave the two bounds a few float ULPs
// apart. Treat that rounding-only empty range like drag() does instead of
// letting coerceIn throw during layout.
val position = if (lowerBound > upperBound) {
(lowerBound + upperBound) / 2f
} else {
wanted.coerceIn(lowerBound, upperBound)
}
effective[index] = position
previous = position
}
@@ -0,0 +1,314 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.view.i18n.LocalStrings
internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu"
internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser"
internal const val STATION_SKED_POPUP_TAG = "station-sked-popup"
private enum class StationContextPage {
ROOT,
SNIPPETS,
NOT_QRV,
WORKED,
REACHABILITY,
}
/** Shared station action popup used by the user list and the map. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StationContextOverlays(
menuState: StationContextMenuState,
mainWindowState: MainWindowState,
onShowMap: (() -> Unit)? = null,
) {
val strings = LocalStrings.current
var page by remember { mutableStateOf(StationContextPage.ROOT) }
var skedDraft by remember { mutableStateOf<SkedEditorState?>(null) }
var skedPosition by remember { mutableStateOf(IntOffset.Zero) }
LaunchedEffect(menuState.candidates, menuState.target) {
page = StationContextPage.ROOT
}
if (menuState.isOpen) {
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(menuState.position),
onDismissRequest = menuState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG),
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Column(
modifier = Modifier
.widthIn(min = 190.dp, max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
val target = menuState.target
if (target == null) {
ContextHeading(
strings.contextChooseStation,
Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG),
)
menuState.candidates.forEach { candidate ->
ContextItem(stationContextTargetLabel(candidate)) {
menuState.chooseTarget(candidate)
}
}
} else {
when (page) {
StationContextPage.ROOT -> {
ContextSubmenu(strings.contextMessageSnippets) {
page = StationContextPage.SNIPPETS
}
ContextItem(strings.contextCreateSked) {
skedPosition = menuState.position
skedDraft = mainWindowState.selectedStation.createSkedDraft(target)
menuState.close()
}
ContextSeparator()
ContextSubmenu(strings.contextTagNotQrv) {
page = StationContextPage.NOT_QRV
}
ContextSubmenu(strings.contextMarkWorked) {
page = StationContextPage.WORKED
}
ContextSeparator()
ContextSubmenu(strings.contextCalculateReachability) {
page = StationContextPage.REACHABILITY
}
}
StationContextPage.SNIPPETS -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.snippets.entries.forEach { snippet ->
ContextItem(snippet) {
mainWindowState.chatInput.appendSnippet(snippet)
menuState.close()
}
}
}
StationContextPage.NOT_QRV -> {
ContextBack { page = StationContextPage.ROOT }
NotQrvBand.entries
.filter { it.band in mainWindowState.activeBands }
.forEach { band ->
val tagged = mainWindowState.selectedStation
.isTaggedNotQrv(target, band)
ContextCheckItem(band.label, tagged) {
mainWindowState.selectedStation
.tagNotQrv(target, band, !tagged)
}
}
}
StationContextPage.WORKED -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
val worked = target.isWorkedOn(band)
ContextCheckItem(band.displayLabel, worked, enabled = !worked) {
if (mainWindowState.markStationWorked?.invoke(target, band) == true) {
menuState.close()
}
}
}
}
StationContextPage.REACHABILITY -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
ContextItem(band.displayLabel) {
if (mainWindowState.mapReachability.calculate(target, band)) {
onShowMap?.invoke()
menuState.close()
}
}
}
}
}
}
}
}
}
}
}
skedDraft?.let { draft ->
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(skedPosition),
onDismissRequest = { skedDraft = null },
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier
.widthIn(min = 420.dp, max = 560.dp)
.testTag(STATION_SKED_POPUP_TAG),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
) {
Column(
modifier = Modifier.padding(8.dp),
) {
Text(
strings.contextSkedTitle(draft.target.callSign.orEmpty()),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SkedEditorControls(
activeBands = mainWindowState.activeBands,
minutes = draft.minutes,
onMinutesChanged = { draft.minutes = it },
band = draft.band,
onBandChanged = { draft.band = it },
mode = draft.mode,
onModeChanged = { draft.mode = it },
remindPm = draft.remindPm,
onRemindPmChanged = { draft.remindPm = it },
reminderOffsets = draft.reminderOffsets,
onReminderOffsetsChanged = { draft.reminderOffsets = it },
canCreate = draft.band != null,
onCreate = {
if (draft.submit()) skedDraft = null
},
additionalActions = {
Form.button(strings.contextCancel) { skedDraft = null }
},
)
}
}
}
}
}
@Composable
private fun ContextHeading(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@Composable
private fun ContextBack(onClick: () -> Unit) {
val strings = LocalStrings.current
ContextItem("‹ ${strings.contextBack}", onClick = onClick)
ContextSeparator()
}
@Composable
private fun ContextSubmenu(text: String, onClick: () -> Unit) {
ContextItem("$text ›", onClick = onClick)
}
@Composable
private fun ContextCheckItem(
text: String,
checked: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick)
}
@Composable
private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
DropdownMenuItem(
text = { Text(text, style = compactControlTextStyle(), maxLines = 1) },
enabled = enabled,
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
@Composable
private fun ContextSeparator() {
HorizontalDivider(
thickness = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
internal fun stationContextTargetLabel(member: ChatMember): String {
val category = member.chatCategory
val categoryName = category?.getChatCategoryName(category.categoryNumber)
?.takeIf { it.isNotBlank() }
?: category?.categoryNumber?.toString().orEmpty()
return if (categoryName.isBlank()) member.callSign.orEmpty()
else "${member.callSign.orEmpty()} | $categoryName"
}
internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) {
Band.B_50 -> isWorked50
Band.B_70 -> isWorked70
Band.B_144 -> isWorked144
Band.B_432 -> isWorked432
Band.B_1296 -> isWorked1240
Band.B_2320 -> isWorked2300
Band.B_3400 -> isWorked3400
Band.B_5760 -> isWorked5600
Band.B_10G -> isWorked10G
Band.B_24G -> isWorked24G
}
/** Window-relative popup position clamped to the visible window on every DPI scale. */
internal data class WindowPointPopupPositionProvider(
val point: IntOffset,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = IntOffset(
x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)),
)
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset
import kst4contest.model.ChatMember
/** Current target and window-relative anchor of a station context menu. */
class StationContextMenuState(
private val selectStation: (ChatMember, StationSelectionSource) -> Unit,
) {
var position: IntOffset by mutableStateOf(IntOffset.Zero)
private set
var candidates: List<ChatMember> by mutableStateOf(emptyList())
private set
var target: ChatMember? by mutableStateOf(null)
private set
var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
val isOpen: Boolean
get() = candidates.isNotEmpty()
fun open(
members: List<ChatMember>,
source: StationSelectionSource,
position: IntOffset,
) {
val unique = members
.filter { StationIdentity.of(it) != null }
.distinctBy(StationIdentity::of)
if (unique.isEmpty()) {
close()
return
}
this.position = position
this.source = source
candidates = unique
target = unique.singleOrNull()
target?.let { selectStation(it, source) }
}
fun chooseTarget(member: ChatMember) {
val identity = StationIdentity.of(member) ?: return
val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return
target = resolved
selectStation(resolved, source)
}
fun close() {
candidates = emptyList()
target = null
}
}
@@ -4,13 +4,11 @@ import kst4contest.view.i18n.LocalStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
@@ -18,13 +16,20 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
internal const val FILTER_DISTANCE_GROUP_TAG = "filter-distance-group"
internal const val FILTER_DIRECTION_GROUP_TAG = "filter-direction-group"
internal const val FILTER_STATION_GROUP_TAG = "filter-station-group"
internal const val FILTER_WORKED_GROUP_TAG = "filter-worked-group"
internal const val FILTER_PROPAGATION_GROUP_TAG = "filter-propagation-group"
/**
* The station filter bar above the station table.
*
* Laid out to match the JavaFX original: three tight rows of controls separated
* by thin vertical rules between logical groups.
* Compact surfaces keep related filters together while the outer flow rows still wrap
* against the station panel's actual width.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -54,131 +59,6 @@ fun StationFilterBar(
* stays together on its line; only the QRB pair and the bearings may break up
* inside their group when even that group is wider than the pane.
*/
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
// Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
if (onReset != null) {
onReset()
} else {
filters.reset()
onChanged()
}
}
// QRB maximum + minimum (#21): together they make a range, so they stay one group
FilterGroup {
GroupSeparator()
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
FilterGroup {
CompactCheckbox(
checked = filters.minQrbEnabled,
onCheckedChange = {
filters.minQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.minQrbKm = parsed
if (filters.minQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.minQrbKm = 0.0
if (filters.minQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
}
}
}
// QTF checkbox + field
FilterGroup {
GroupSeparator()
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp)
)
Text("± BW/2", style = compactControlTextStyle())
}
// Bearings
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Bearing.entries.forEach { bearing ->
FilterButton(
bearing.name,
on = filters.bearing == bearing,
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
}
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -195,9 +75,137 @@ fun StationFilterBar(
modifier = Modifier.width(90.dp),
)
FilterGroup {
GroupSeparator()
Text(strings.filterHideWorked, style = compactControlTextStyle())
// Reset filters
FilterButton(strings.filterReset, on = false, isAccent = true) {
if (onReset != null) {
onReset()
} else {
filters.reset()
onChanged()
}
}
// Distance and bearing-width controls form one functional surface.
FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) {
FilterGroup {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
CompactCheckbox(
checked = filters.maxQrbEnabled,
onCheckedChange = {
filters.maxQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
CompactTextField(
value = if (filters.maxQrbKm.rem(1) == 0.0) {
filters.maxQrbKm.toInt().toString()
} else {
filters.maxQrbKm.toString()
},
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.maxQrbKm = parsed
if (filters.maxQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.maxQrbKm = 0.0
if (filters.maxQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp),
)
}
FilterGroup {
CompactCheckbox(
checked = filters.minQrbEnabled,
onCheckedChange = {
filters.minQrbEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
CompactTextField(
value = if (filters.minQrbKm.rem(1) == 0.0) {
filters.minQrbKm.toInt().toString()
} else {
filters.minQrbKm.toString()
},
onValueChange = {
val parsed = parseQrbKm(it)
if (parsed != null) {
filters.minQrbKm = parsed
if (filters.minQrbEnabled) onChanged()
} else if (it.isEmpty()) {
filters.minQrbKm = 0.0
if (filters.minQrbEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp),
)
}
}
}
}
FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) {
FilterGroup {
CompactCheckbox(
checked = filters.qtfEnabled,
onCheckedChange = {
filters.qtfEnabled = it
onChanged()
},
)
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
CompactTextField(
value = if (filters.qtfDegrees.rem(1) == 0.0) {
filters.qtfDegrees.toInt().toString()
} else {
filters.qtfDegrees.toString()
},
onValueChange = {
val parsed = it.toDoubleOrNull()
if (parsed != null) {
filters.qtfDegrees = parsed
filters.bearing = null
if (filters.qtfEnabled) onChanged()
} else if (it.isEmpty()) {
filters.qtfDegrees = 0.0
filters.bearing = null
if (filters.qtfEnabled) onChanged()
}
},
modifier = Modifier.width(50.dp),
)
Text("± BW/2", style = compactControlTextStyle())
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
Bearing.entries.forEach { bearing ->
FilterButton(
bearing.name,
on = filters.bearing == bearing,
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
) {
filters.toggleBearing(bearing)
onChanged()
}
}
}
}
FilterGroupSurface(FILTER_STATION_GROUP_TAG) {
Text(strings.filterHide, style = compactControlTextStyle())
FilterButton("wkdany", on = filters.hideWorkedAny) {
filters.hideWorkedAny = !filters.hideWorkedAny
onChanged()
@@ -210,29 +218,18 @@ fun StationFilterBar(
}
}
}
}
FilterGroup {
GroupSeparator()
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
filters.hideInactive = !filters.hideInactive
onChanged()
}
}
FilterGroupSurface(FILTER_WORKED_GROUP_TAG) {
Text(strings.filterShow, style = compactControlTextStyle())
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
filters.onlyNewGrids = !filters.onlyNewGrids
onChanged()
}
}
}
// ROW 3: Grid color | Tropo | New bands | AS next 5m | Reachability
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(1.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
FilterGroup {
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
filters.gridColouring = !filters.gridColouring
}
@@ -250,20 +247,22 @@ fun StationFilterBar(
}
}
FilterGroup {
GroupSeparator()
Form.inlineChoice(
label = "Reachability",
items = reachability.options,
selected = reachability.selectedOption,
describe = ReachabilityBandOption::label,
onSelect = { reachability.selectBand(it.band) },
)
Form.button(
text = "Calc selected",
enabled = reachability.canCalculate,
onClick = { reachability.calculateSelected() },
)
FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) {
FilterGroup {
Text(strings.filterReachability, style = compactControlTextStyle())
Form.inlineChoice(
label = "",
items = reachability.options,
selected = reachability.selectedOption,
describe = ReachabilityBandOption::label,
onSelect = { reachability.selectBand(it.band) },
)
Form.button(
text = strings.filterCalcSelected,
enabled = reachability.canCalculate,
onClick = { reachability.calculateSelected() },
)
}
}
}
}
@@ -304,16 +303,20 @@ private fun FilterGroup(content: @Composable () -> Unit) {
}
}
/**
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills
* the row's intrinsic height, which a line of a flow row does not offer.
*/
/** A compact surface that keeps related filters visually and spatially together. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun GroupSeparator() {
Box(
private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) {
FlowRow(
modifier = Modifier
.width(Density.HAIRLINE)
.height(18.dp)
.background(MaterialTheme.colorScheme.outline),
)
.testTag(tag)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
content()
}
}
@@ -54,11 +54,14 @@ object DirectedMessageStationTarget {
/** Where an explicit station selection came from. */
enum class StationSelectionSource {
STATION_TABLE,
STATION_CONTEXT_MENU,
DIRECTED_MESSAGE,
PUBLIC_MESSAGE,
QSO_OF_THE_OTHER,
MAP,
MAP_CONTEXT_MENU,
PRIORITY,
NEW_JOINED,
TIMELINE,
RESET_VIEW,
PROGRAMMATIC,
@@ -98,7 +101,7 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
current = row
lastSource = source
selectionRevision++
if (row != null) {
if (row != null && source.requestsStationTableScroll()) {
scrollRequestRevision++
}
}
@@ -114,3 +117,19 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
}
}
}
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
StationSelectionSource.STATION_TABLE,
StationSelectionSource.STATION_CONTEXT_MENU,
StationSelectionSource.RESET_VIEW,
StationSelectionSource.PROGRAMMATIC -> false
StationSelectionSource.DIRECTED_MESSAGE,
StationSelectionSource.PUBLIC_MESSAGE,
StationSelectionSource.QSO_OF_THE_OTHER,
StationSelectionSource.MAP,
StationSelectionSource.MAP_CONTEXT_MENU,
StationSelectionSource.PRIORITY,
StationSelectionSource.NEW_JOINED,
StationSelectionSource.TIMELINE -> true
}
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.geometry.Offset
@@ -24,7 +25,11 @@ import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
@@ -50,6 +55,7 @@ import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log2
import kotlin.math.sign
import kotlin.math.roundToInt
/**
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
@@ -94,6 +100,7 @@ class MapState(
* any zoom with a fraction, the antenna beam did not start where the map said home was.
* Neither failure is possible here, because nothing is scaled.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ComposeStationMap(
markers: List<MapCallsignRawSnapshot>,
@@ -106,6 +113,8 @@ fun ComposeStationMap(
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
modifier: Modifier = Modifier,
) {
@@ -122,6 +131,7 @@ fun ComposeStationMap(
* wrongly grouped markers whenever the window is resized.
*/
var canvasSize by remember { mutableStateOf(Size.Zero) }
var canvasCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
var tileRevision by remember { mutableStateOf(0) }
@@ -196,6 +206,39 @@ fun ComposeStationMap(
modifier = modifier
.clipToBounds()
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
.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 cluster = result.clusters().firstOrNull { candidate ->
within(
local,
viewport.screenX(candidate.centerLon).toFloat(),
viewport.screenY(candidate.centerLat).toFloat(),
clusterRadiusPx(candidate.size()),
)
}
val individual = result.individual().firstOrNull { marker ->
within(
local,
viewport.screenX(marker.longitudeDeg()).toFloat(),
viewport.screenY(marker.latitudeDeg()).toFloat(),
MARKER_HIT_RADIUS_PX,
)
}
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
val window = canvasCoordinates?.localToWindow(local)
if (!targets.isNullOrEmpty() && window != null) {
onMarkerContextRequested?.invoke(
targets,
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
)
event.changes.forEach { it.consume() }
}
}
}
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoomChange, _ ->
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
@@ -293,6 +336,7 @@ fun ComposeStationMap(
drawTiles(viewport, imageCache, darkMode)
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
drawWorkedGrossFields(viewport, workedGrossFields)
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
@@ -709,10 +753,7 @@ private fun DrawScope.drawHover(
val text = when {
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
if (locator.isBlank()) markerUnderPointer.displayCallSign()
else "${markerUnderPointer.displayCallSign()} ($locator)"
}
markerUnderPointer != null -> markerUnderPointer.tooltipText()
else -> return
}
@@ -758,6 +799,46 @@ private fun DrawScope.drawHover(
)
}
private fun DrawScope.drawWorkedGrossFields(
viewport: MapViewport,
workedGrossFields: Set<String>,
) {
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
val left = viewport.screenX(cell.westLon()).toFloat()
val right = viewport.screenX(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))
val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top))
drawRect(
color = WORKED_GRID_FILL,
topLeft = topLeft,
size = cellSize,
)
drawRect(
color = WORKED_GRID_BORDER,
topLeft = topLeft,
size = cellSize,
style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX),
)
}
}
/** Worked four-character cells visible in the current viewport, independent of markers. */
internal fun visibleWorkedGrossFieldCells(
viewport: MapViewport,
workedGrossFields: Set<String>,
): List<MaidenheadGridUtils.GridCell> {
if (workedGrossFields.isEmpty()) return emptyList()
return MaidenheadGridUtils.buildVisibleCells(
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
MaidenheadGridUtils.GridPrecision.SQUARE_4,
).filter { it.locatorLabel() in workedGrossFields }
}
/* ----------------------------------------------------------- helpers */
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
@@ -864,6 +945,9 @@ private const val GRID_LINE_WIDTH_PX = 1.4f
private val GRID_LABEL_COLOR = Color(0xFF63067A)
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
private val WORKED_GRID_FILL = Color.Black.copy(alpha = 0.30f)
private val WORKED_GRID_BORDER = Color.Black
private const val WORKED_GRID_BORDER_WIDTH_PX = 3f
private const val GRID_LABEL_PADDING_PX = 3f
private val DOT_FILL = Color(0xFF1D1D1D)
@@ -21,6 +21,8 @@ import kst4contest.view.compose.PaletteStore
import kst4contest.view.compose.ComposeWindowHost
import kst4contest.view.compose.MainWindowState
import kst4contest.view.compose.StationIdentity
import kst4contest.view.compose.StationContextMenuState
import kst4contest.view.compose.StationContextOverlays
import kst4contest.view.compose.StationSelectionSource
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
import kst4contest.view.map.PathAnalysisResult
@@ -141,6 +143,9 @@ object StationMapWindow {
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) }
val stationContextMenu = remember(mainWindowState) {
StationContextMenuState(mainWindowState::selectStation)
}
val selectedMember = mainWindowState.selectedStation.selected
@@ -180,6 +185,7 @@ object StationMapWindow {
}
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)
@@ -192,6 +198,8 @@ object StationMapWindow {
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
LaunchedEffect(selectedMember, selectedHasMarker) {
val automaticRequest = ++automaticAnalysisRevision
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
if (selectedMember == null) {
pathAnalysisResult = null
return@LaunchedEffect
@@ -232,11 +240,31 @@ object StationMapWindow {
)
analysis.request(selectedMember, selectedSnapshot) { result ->
pathAnalysisResult = result
if (automaticAnalysisRevision == automaticRequest &&
mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart
) {
pathAnalysisResult = result
}
}
}
}
val explicitReachabilityResult = mainWindowState.mapReachability.result
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
if (explicitReachabilityResult != null) {
automaticAnalysisRevision++
pathAnalysisResult = explicitReachabilityResult
pathAnalysisVisible = true
}
}
val workedGrossFields = if (mainWindowState.stationFilter.gridColouring) {
mainWindowState.stations.contentRevision
mainWindowState.workedGrossFieldsProvider()
} else {
emptySet()
}
androidx.compose.material3.Surface(
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
@@ -373,6 +401,7 @@ object StationMapWindow {
antennaBeamWidthDeg = antennaBeamWidthDeg,
maxQrbKm = maxQrbKm,
darkMode = isDarkTheme,
workedGrossFields = workedGrossFields,
onMarkerClicked = { snapshot ->
val variants = chatMembers()
.filter { it.callSignRaw == snapshot.callSignRaw() }
@@ -393,6 +422,15 @@ object StationMapWindow {
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
}
},
onMarkerContextRequested = { snapshots, position ->
val callsigns = snapshots.mapTo(linkedSetOf()) { it.callSignRaw() }
val candidates = chatMembers().filter { it.callSignRaw in callsigns }
stationContextMenu.open(
candidates,
StationSelectionSource.MAP_CONTEXT_MENU,
position,
)
},
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
)
@@ -456,6 +494,7 @@ object StationMapWindow {
} // main layout column
} // window column
} // Surface
StationContextOverlays(stationContextMenu, mainWindowState)
} // Content
/** Marks the map canvas so a test can click a marker on it. */
@@ -120,6 +120,15 @@ class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
val splitter = SplitterState(paneCount = 2, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
@Test
fun `the selected station content splitter takes the one stored profile position`() {
val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition()
assertEquals(1, stored.size)
val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {}
assertEquals(stored.map { it.toFloat() }, splitter.positions)
}
}
/**
@@ -0,0 +1,66 @@
package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.view.map.PathAnalysisResult
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class MapReachabilityStateTest {
@Test
fun `latest station and band request rejects stale results`() {
val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>()
val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback }
val first = member("DL1ABC", 2, "JN49AA")
val second = member("DL2XYZ", 3, "JO60AA")
assertTrue(state.calculate(first, Band.B_144))
assertEquals("Loading", state.result?.analysisMode())
assertTrue(state.calculate(second, Band.B_432))
callbacks[1](result("DL2XYZ", 432.0))
callbacks[0](result("DL1ABC", 144.0))
assertEquals("DL2XYZ", state.result?.toCallsignRaw())
assertEquals(432.0, state.result?.analysisFrequencyMHz())
assertFalse(state.running)
}
@Test
fun `selecting another station invalidates an outstanding explicit result`() {
lateinit var callback: (PathAnalysisResult) -> Unit
val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result }
val first = member("DL1ABC", 2, "JN49AA")
state.calculate(first, Band.B_144)
state.selectionChanged(StationIdentity("DL2XYZ", 2))
callback(result("DL1ABC", 144.0))
assertNull(state.result)
assertFalse(state.running)
}
@Test
fun `missing station or band does not start a request`() {
val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") }
assertFalse(state.calculate(null, Band.B_144))
assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null))
}
private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
qra = locator
}
private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile(
"Calculated", "JO51AA", "JN49AA", callSign,
100.0, 180.0, 10.0, 10.0, frequency, "done",
)
}
@@ -0,0 +1,126 @@
package kst4contest.view.compose
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@OptIn(ExperimentalTestApi::class)
class NewJoinedBarScreenTest {
@Test
fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest {
val selection = StationSelectionState()
val members = (1..7).map { member("DL${it}ABC") }
val newJoined = NewJoinedState(clock = { 10_000L }) { member ->
selection.select(member, StationSelectionSource.NEW_JOINED)
}.apply {
updateActiveMembers(members)
updateEvents(
members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, 2, 9_000L + index)
},
)
}
val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {})
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
}
}
onNodeWithText("New joined:").assertIsDisplayed()
listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach {
onNodeWithText(it).assertIsDisplayed()
}
onNodeWithText("DL2ABC").assertDoesNotExist()
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
onNodeWithText("DL2ABC").assertIsDisplayed().performClick()
assertSame(members[1], selection.selected)
assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource)
assertTrue(selection.scrollRequestRevision > 0L)
}
@Test
fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest {
val newJoined = NewJoinedState(onSelect = {})
val priorityMember = member("OK1FPR")
val priority = TopPriorityState(
topStations = { listOf(priorityMember to 767.0) },
onSelect = {},
).apply { refresh() }
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
}
}
onNodeWithText("New joined:").assertDoesNotExist()
onNodeWithText("Priority:").assertIsDisplayed()
onNodeWithText("1 OK1FPR 767").assertIsDisplayed()
}
@Test
fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest {
val members = (1..7).map { member("DL${it}ABC") }
val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply {
updateActiveMembers(members)
updateEvents(members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, 2, 9_000L + index)
})
}
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Box(Modifier.width(300.dp)) {
TopPriorityBar(
state = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
newJoined = newJoined,
onShowMore = {},
)
}
}
}
onNodeWithText("DL7ABC").assertIsDisplayed()
onNodeWithText("DL6ABC").assertDoesNotExist()
val limit = with(density) { 300.dp.toPx() }
listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag ->
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar")
}
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
onNodeWithText("DL6ABC").assertIsDisplayed()
}
private fun member(callSign: String) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(2)
}
}
@@ -0,0 +1,115 @@
package kst4contest.view.compose
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicLong
class NewJoinedStateTest {
private val now = AtomicLong(1_000_000L)
private var selected: ChatMember? = null
private val state = NewJoinedState(clock = now::get, onSelect = { selected = it })
@Test
fun `an initial active roster without live events produces no entries`() {
state.updateActiveMembers(listOf(member("HB9SJV", 2)))
assertTrue(state.entries.isEmpty())
}
@Test
fun `a live join is shown once and duplicate events do not renew it`() {
val station = member("HB9SJV", 2)
state.updateActiveMembers(listOf(station))
state.updateEvents(
listOf(
event("HB9SJV", 2, now.get() - 2_000L),
event("HB9SJV", 2, now.get() - 1_000L),
),
)
assertEquals(listOf("HB9SJV"), state.entries.map { it.label })
assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis)
}
@Test
fun `the same full callsign in two categories stays distinguishable`() {
val main = member("DL1KDA", 2)
val microwave = member("DL1KDA", 3)
state.updateActiveMembers(listOf(main, microwave))
state.updateEvents(
listOf(
event("DL1KDA", 3, now.get()),
event("DL1KDA", 2, now.get() - 1L),
),
)
assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber })
state.entries.first().select()
assertSame(microwave, selected)
}
@Test
fun `an entry expires exactly ten minutes after the join`() {
val station = member("OE5SOS", 2)
state.updateActiveMembers(listOf(station))
state.updateEvents(listOf(event("OE5SOS", 2, now.get())))
now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L)
state.refresh()
assertTrue(state.isRecent(station))
now.incrementAndGet()
state.refresh()
assertFalse(state.isRecent(station))
assertTrue(state.entries.isEmpty())
}
@Test
fun `only five newest entries are direct and the rest remain in the overflow`() {
val members = (1..7).map { member("DL${it}ABC", 2) }
state.updateActiveMembers(members)
state.updateEvents(
members.mapIndexed { index, member ->
event(member.callSign, 2, now.get() - (members.size - index))
},
)
assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"),
state.visibleEntries.map { it.label })
assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label })
}
@Test
fun `a station leaving disappears and a real rejoin gets the new event`() {
val first = member("PA2M", 2)
state.updateActiveMembers(listOf(first))
state.updateEvents(listOf(event("PA2M", 2, now.get())))
state.updateActiveMembers(emptyList())
assertTrue(state.entries.isEmpty())
val rejoined = member("PA2M", 2)
now.addAndGet(5_000L)
state.updateActiveMembers(listOf(rejoined))
state.updateEvents(listOf(event("PA2M", 2, now.get())))
state.entries.single().select()
assertSame(rejoined, selected)
assertEquals(now.get(), state.entries.single().joinedAtEpochMillis)
}
private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(categoryNumber)
}
private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) =
RecentChatJoin(callSign, categoryNumber, joinedAt)
}
@@ -106,6 +106,84 @@ class SelectedStationPanelScreenTest {
assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f)
}
@Test
fun allMessageChoicesShareOneVerticalAxis() = runComposeUiTest {
setPanel(selectedState("DM5M"), Modifier.width(570.dp).height(420.dp))
val centers = listOf("pm to me", "pm to other", "public msgs", "nothing")
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.center.y }
assertTrue(centers.max() - centers.min() <= 1f, "radio labels are misaligned: $centers")
}
@Test
fun selectedMessageSplitterChangesTheActualTableHeightInBothDirections() = runComposeUiTest {
val selected = selectedState("DM5M")
val splitter = SplitterState(2, doubleArrayOf(0.45)) {}
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SelectedStationPanel(
state = selected,
messages = messageTable(),
activeBands = EnumSet.of(Band.B_144),
contentSplitter = splitter,
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.width(570.dp).height(620.dp),
)
}
}
val initial = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
.fetchSemanticsNode().boundsInRoot.height
runOnIdle { splitter.drag(0, 0.70f, 500f) }
waitForIdle()
val taller = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
.fetchSemanticsNode().boundsInRoot.height
assertTrue(taller > initial, "moving the divider down did not enlarge the table")
runOnIdle { splitter.drag(0, 0.25f, 500f) }
waitForIdle()
val shorter = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
.fetchSemanticsNode().boundsInRoot.height
assertTrue(shorter < taller, "moving the divider up did not shrink the table")
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
}
@Test
fun selectedMessageSplitterSurvivesStationAndMessageUpdates() = runComposeUiTest {
val selected = selectedState("DM5M")
val messages = messageTable()
val splitter = SplitterState(2, doubleArrayOf(0.62)) {}
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
SelectedStationPanel(
state = selected,
messages = messages,
activeBands = EnumSet.of(Band.B_144),
contentSplitter = splitter,
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.width(570.dp).height(620.dp),
)
}
}
val expectedPosition = splitter.positions.single()
runOnIdle {
selected.select(member("DL1ABC"))
messages.replaceRows(listOf(ChatMessage().apply { messageText = "new message" }))
}
waitForIdle()
assertEquals(expectedPosition, splitter.positions.single())
}
@Test
fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest {
val selected = selectedState("DM5M")
@@ -300,9 +378,7 @@ class SelectedStationPanelScreenTest {
}
}
onNodeWithText("Lookup on qrz.com")
.performScrollTo()
.assertIsDisplayed()
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
}
private fun selectedState(callSign: String) = selectedState(member(callSign))
@@ -0,0 +1,82 @@
package kst4contest.view.compose
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SkedEditorStateTest {
@Test
fun `panel submission includes reminder settings in one request`() {
val requests = mutableListOf<SkedRequest>()
val member = member("DL1ABC", 2)
val state = SelectedStationState(
propagateNotQrv = { },
createSked = { _, _, _, _ -> false },
)
state.skedRequestSubmitter = { request -> requests += request; true }
state.select(member)
state.skedMinutes = 8
state.skedBand = Band.B_432
state.skedMode = "CW"
state.remindPm = true
state.reminderOffsets = "5+2+1"
assertTrue(state.createSked())
assertEquals(
SkedRequest(member, 8, Band.B_432, "CW", true, "5+2+1"),
requests.single(),
)
}
@Test
fun `popup draft keeps its target when the shared selection changes`() {
val requests = mutableListOf<SkedRequest>()
val original = member("DL1ABC", 2)
val later = member("DL2XYZ", 3)
val draft = SkedEditorState(
target = original,
minutes = 5,
band = Band.B_144,
mode = "SSB",
remindPm = false,
reminderOffsets = "2+1",
submitter = { request -> requests += request; true },
)
draft.minutes = 10
draft.band = Band.B_1296
draft.remindPm = true
@Suppress("UNUSED_VARIABLE") val unrelatedSelection = later
assertTrue(draft.submit())
assertSame(original, requests.single().target)
assertEquals(Band.B_1296, requests.single().band)
assertTrue(requests.single().remindPm)
}
@Test
fun `rejected popup submission reports failure so popup can remain open`() {
val draft = SkedEditorState(
target = member("DL1ABC", 2),
minutes = 5,
band = null,
mode = "SSB",
remindPm = false,
reminderOffsets = "2+1",
submitter = { false },
)
assertFalse(draft.submit())
}
private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
}
}
@@ -253,6 +253,23 @@ class SplitterStateTest {
assertEquals(100f, sizes.sum(), 0.01f)
}
@Test
fun `pane-specific minimum scaling tolerates float rounding`() {
val state = SplitterState(
paneCount = 2,
stored = doubleArrayOf(0.48),
minPanePx = 160f,
) {}
val sizes = state.paneSizes(
totalPx = 24.405657f,
minimumPaneSizesPx = floatArrayOf(52f, 160f),
)
assertTrue(sizes.all { it >= 0f })
assertEquals(24.405657f, sizes.sum(), 0.0001f)
}
private fun assertNull(value: Any?, message: String) {
assertFalse(value != null, message)
}
@@ -0,0 +1,90 @@
package kst4contest.view.compose
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class StationContextMenuStateTest {
@Test
fun `single row target is selected immediately with its context source`() {
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
val state = StationContextMenuState { member, source -> selections += member to source }
val member = member("DL1ABC", 2)
state.open(listOf(member), StationSelectionSource.STATION_CONTEXT_MENU, IntOffset(10, 20))
assertSame(member, state.target)
assertEquals(listOf(member to StationSelectionSource.STATION_CONTEXT_MENU), selections)
}
@Test
fun `grouped map target requires an explicit full callsign and category choice`() {
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
val state = StationContextMenuState { member, source -> selections += member to source }
val main = member("DN9APW-2", 2)
val second = member("DN9APW-2", 3)
state.open(
listOf(main, second, main),
StationSelectionSource.MAP_CONTEXT_MENU,
IntOffset(30, 40),
)
assertNull(state.target)
assertEquals(2, state.candidates.size)
assertEquals(emptyList<Pair<ChatMember, StationSelectionSource>>(), selections)
state.chooseTarget(second)
assertSame(second, state.target)
assertEquals(listOf(second to StationSelectionSource.MAP_CONTEXT_MENU), selections)
}
@Test
fun `target label retains the full suffix and category`() {
val target = member("DN9APW-70", 3)
val label = stationContextTargetLabel(target)
assertTrue(label.startsWith("DN9APW-70 | "))
}
@Test
fun `worked menu state follows the existing per-band flags`() {
val target = member("DL1ABC", 2)
target.isWorked144 = true
assertTrue(target.isWorkedOn(Band.B_144))
assertFalse(target.isWorkedOn(Band.B_432))
}
@Test
fun `popup anchor is clamped at the window edge`() {
val provider = WindowPointPopupPositionProvider(IntOffset(990, 790))
val position = provider.calculatePosition(
anchorBounds = IntRect.Zero,
windowSize = IntSize(1_000, 800),
layoutDirection = LayoutDirection.Ltr,
popupContentSize = IntSize(300, 250),
)
assertEquals(IntOffset(700, 550), position)
}
private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
}
}
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
@@ -227,4 +228,33 @@ class StationFilterBarScreenTest {
"bearing buttons are not $expectedWidth px wide: $bearingWidths",
)
}
@Test
fun functionalFilterGroupsRemainDistinctAndInsideANarrowBar() = runComposeUiTest {
val width = 300.dp
setContent {
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
Box(Modifier.width(width)) {
StationFilterBar(
filters = StationFilterState(),
activeBands = EnumSet.of(Band.B_144),
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
onChanged = {},
)
}
}
}
val limit = with(density) { width.toPx() }
listOf(
FILTER_DISTANCE_GROUP_TAG,
FILTER_DIRECTION_GROUP_TAG,
FILTER_STATION_GROUP_TAG,
FILTER_WORKED_GROUP_TAG,
FILTER_PROPAGATION_GROUP_TAG,
).forEach { tag ->
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
assertTrue(bounds.left >= 0f && bounds.right <= limit + 0.5f, "$tag crosses the filter bar")
}
}
}
@@ -2,17 +2,23 @@ package kst4contest.view.compose
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.click
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.IntOffset
import kst4contest.model.AirPlane
import kst4contest.model.AirPlaneReflectionInfo
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.util.EnumSet
@@ -85,10 +91,15 @@ class StationListScreenTest {
private fun ComposeUiTest.show(
state: DataTableState<ChatMember>,
onRowClick: (ChatMember) -> Unit = {},
onRowContextMenu: ((ChatMember, IntOffset) -> Unit)? = null,
) {
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(state = state, onRowClick = onRowClick)
DataTableView(
state = state,
onRowClick = onRowClick,
onRowContextMenu = onRowContextMenu,
)
}
}
waitForIdle()
@@ -107,7 +118,7 @@ class StationListScreenTest {
val inSecond = station("DL1ABC", category = 3)
val clicked = mutableListOf<ChatMember>()
show(state) { clicked += it }
show(state, onRowClick = { clicked += it })
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
waitForIdle()
@@ -206,6 +217,24 @@ class StationListScreenTest {
assertEquals(null, state.selectedRow)
}
@Test
fun aSecondaryClickReportsTheExactStationAndWindowPosition() = runComposeUiTest {
val state = stationList()
val member = station("DL1ABC", category = 3)
state.replaceRows(listOf(member))
val requests = mutableListOf<Pair<ChatMember, IntOffset>>()
show(state, onRowContextMenu = { target, position -> requests += target to position })
onNodeWithText("DL1ABC").performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
assertEquals(1, requests.size)
assertSame(member, requests.single().first)
assertTrue(requests.single().second.x >= 0 && requests.single().second.y >= 0)
}
@Test
fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest {
val selection = StationSelectionState()
@@ -259,4 +288,33 @@ class StationListScreenTest {
onNodeWithText("DL1MOVE").assertDoesNotExist()
onNodeWithText("DL2STAY").assertExists()
}
@Test
fun onlyTheCallsignCellOfARecentlyJoinedStationIsEmphasized() = runComposeUiTest {
val state = stationList()
val joined = station("HB9SJV", 2)
val existing = station("DL1OLD", 2)
val recent = NewJoinedState(clock = { 5_000L }, onSelect = {}).apply {
updateActiveMembers(listOf(joined, existing))
updateEvents(listOf(RecentChatJoin("HB9SJV", 2, 1_000L)))
}
val emphasized = mutableSetOf<Pair<String, String>>()
state.replaceRows(listOf(joined, existing))
setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
DataTableView(
state = state,
emphasizeCell = { member, column ->
val emphasize = column.id == "callsign" && recent.isRecent(member)
if (emphasize) emphasized += member.callSign to column.id
emphasize
},
)
}
}
waitForIdle()
assertEquals(setOf("HB9SJV" to "callsign"), emphasized)
}
}
@@ -35,6 +35,36 @@ class StationSelectionStateTest {
assertEquals(StationSelectionSource.MAP, state.lastSource)
}
@Test
fun `direct station table selections do not request scrolling`() {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
assertEquals(0L, state.scrollRequestRevision)
}
@Test
fun `station table context selection does not request scrolling`() {
val state = StationSelectionState()
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_CONTEXT_MENU)
assertEquals(0L, state.scrollRequestRevision)
}
@Test
fun `map context selection requests scrolling again for the same station`() {
val state = StationSelectionState()
val station = station("DL1ABC", 2)
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
val first = state.scrollRequestRevision
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
assertTrue(state.scrollRequestRevision > first)
}
@Test
fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() {
val state = StationSelectionState()
@@ -0,0 +1,157 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kst4contest.model.Band
import kst4contest.model.ChatCategory
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import kst4contest.model.RecentChatJoin
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.EnumSet
import java.util.function.Consumer
/** Produces review images for the station filters, join bar and selected-station splitter. */
class StationWorkspaceVisualRenderingTest {
@Test
fun rendersDayAndEveningAt100And125Percent() {
val reportDirectory = Path.of("build", "reports", "station-workspace-ui")
Files.createDirectories(reportDirectory)
listOf(
Scenario("wide", 700, 768),
Scenario("narrow", 360, 768),
Scenario("short", 570, 480),
).forEach { scenario ->
listOf(false, true).forEach { darkMode ->
listOf(1f, 1.25f).forEach { scale ->
val bytes = render(scenario, darkMode, scale)
val palette = if (darkMode) "evening" else "day"
val scaling = if (scale == 1f) "100" else "125"
val name = "${scenario.name}-$palette-$scaling.png"
Files.write(reportDirectory.resolve(name), bytes)
assertTrue(bytes.size > 10_000, "$name did not render")
}
}
}
}
private fun render(scenario: Scenario, darkMode: Boolean, scale: Float): ByteArray {
val members = (1..7).map { index -> member("DL${index}ABC", if (index == 7) 3 else 2) }
val selectedMember = members.last().apply {
qrb = 438.2
setQTFdirection(76.0)
isQrv432 = false
}
val newJoined = NewJoinedState(clock = { 20_000L }, onSelect = {}).apply {
updateActiveMembers(members)
updateEvents(members.mapIndexed { index, member ->
RecentChatJoin(member.callSign, member.chatCategory.categoryNumber, 10_000L + index)
})
}
val stations = DataTableState(
StationColumns.all(
tropoOf = { "12 dB" },
priorityScoreOf = { 500.0 },
formatBand = { _, _, _ -> "" },
activeBands = EnumSet.of(Band.B_144, Band.B_432),
),
RowKeys.byValue(StationColumns::rowKey),
).apply { replaceRows(members) }
val priority = TopPriorityState(
topStations = { listOf(members[4] to 767.0, members[3] to 740.0) },
onSelect = {},
).apply { refresh() }
val selected = SelectedStationState(
propagateNotQrv = {},
createSked = { _, _, _, _ -> true },
detectBands = { "Bands: 144/432 MHz" },
).apply { select(selectedMember) }
val selectedMessages = DataTableState(
SelectedStationMessageColumns.all(),
RowKeys.byReference<ChatMessage>(),
)
val scene = ImageComposeScene(
width = 1366,
height = scenario.height,
density = Density(scale),
) {
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier
.width(scenario.width.dp)
.fillMaxHeight()
.align(Alignment.TopEnd)
.padding(4.dp),
) {
StationFilterBar(
filters = StationFilterState(),
activeBands = EnumSet.of(Band.B_144, Band.B_432),
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
onChanged = {},
)
DataTableView(
state = stations,
modifier = Modifier.weight(0.45f),
emphasizeCell = { member, column ->
column.id == "callsign" && newJoined.isRecent(member)
},
)
TopPriorityBar(
state = priority,
newJoined = newJoined,
onShowMore = {},
)
SelectedStationPanel(
state = selected,
messages = selectedMessages,
activeBands = EnumSet.of(Band.B_144, Band.B_432),
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
openInBrowser = Consumer {},
onShowOnMap = {},
onShowPathInAirScout = {},
modifier = Modifier.weight(0.55f),
)
}
}
}
}
}
return try {
scene.render().encodeToData()!!.bytes
} finally {
scene.close()
}
}
private fun member(callSign: String, category: Int) = ChatMember().apply {
this.callSign = callSign
chatCategory = ChatCategory(category)
qra = "JO31AA"
name = "Contest station"
}
private data class Scenario(val name: String, val width: Int, val height: Int)
}
@@ -5,6 +5,7 @@ import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Density
import kst4contest.view.map.MapCallsignRawSnapshot
import kst4contest.view.map.MapViewport
import kst4contest.view.map.TileFetcher
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -15,6 +16,7 @@ import java.io.IOException
import java.nio.file.Files
import javax.imageio.ImageIO
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* What the map actually puts on the canvas.
@@ -44,6 +46,8 @@ class ComposeStationMapDrawingTest {
private val darkMapBackground = 0x23282D
private val workedGridBorder = 0x000000
/*
* 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
@@ -64,6 +68,8 @@ class ComposeStationMapDrawingTest {
zoom: Float,
markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
ownLocator6: String = "",
workedGrossFields: Set<String> = emptySet(),
darkMode: Boolean = true,
): BufferedImage {
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
ComposeStationMap(
@@ -77,7 +83,8 @@ class ComposeStationMapDrawingTest {
antennaBeamWidthDeg = 0.0,
maxQrbKm = 0.0,
onMarkerClicked = null,
darkMode = true,
workedGrossFields = workedGrossFields,
darkMode = darkMode,
modifier = Modifier.fillMaxSize(),
)
}
@@ -229,6 +236,49 @@ class ComposeStationMapDrawingTest {
}
}
@Test
fun `worked gross fields use a visible black border in both themes`() {
val dark = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"))
val light = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"), darkMode = false)
assertTrue(countColour(dark, workedGridBorder) > 0) {
"the Evening map has no black Worked-grid border"
}
assertTrue(countColour(light, workedGridBorder) > 0) {
"the Day map has no black Worked-grid border"
}
}
@Test
fun `worked gross field interior is covered by thirty percent black`() {
val viewport = MapViewport(10.0, 51.0, 6.0, canvasWidth.toDouble(), canvasHeight.toDouble())
val cell = visibleWorkedGrossFieldCells(viewport, setOf("JO51")).single()
val x = viewport.screenX(cell.westLon() + (cell.eastLon() - cell.westLon()) * 0.25).toInt()
val y = viewport.screenY(cell.northLat() + (cell.southLat() - cell.northLat()) * 0.25).toInt()
for (darkMode in listOf(false, true)) {
val withoutWorked = render(6f, markers = emptyList(), darkMode = darkMode)
val withWorked = render(
6f,
markers = emptyList(),
workedGrossFields = setOf("JO51"),
darkMode = darkMode,
)
val before = withoutWorked.getRGB(x, y)
val after = withWorked.getRGB(x, y)
for (shift in listOf(16, 8, 0)) {
val beforeChannel = (before shr shift) and 0xFF
val afterChannel = (after shr shift) and 0xFF
val expected = (beforeChannel * 0.70).roundToInt()
assertTrue(abs(afterChannel - expected) <= 2) {
"Worked fill in darkMode=$darkMode changed channel $shift from " +
"$beforeChannel to $afterChannel; expected $expected for 30% black"
}
}
}
}
/**
* Every grid line has to be drawn once.
*
@@ -5,11 +5,14 @@ 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
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.MouseButton
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.text.input.TextFieldValue
import kst4contest.locatorUtils.Location
@@ -34,6 +37,8 @@ import kst4contest.view.compose.StationColumns
import kst4contest.view.compose.StationFilterState
import kst4contest.view.compose.StationSelectionSource
import kst4contest.view.compose.StationSelectionState
import kst4contest.view.compose.STATION_CONTEXT_MENU_TAG
import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
import kst4contest.view.compose.ThreadStatusButtons
import kst4contest.view.compose.TimelineState
import kst4contest.view.compose.TopPriorityState
@@ -306,6 +311,64 @@ class StationMapWindowScreenTest {
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
}
@Test
fun aMarkerContextClickSelectsItsOnlyTargetAndOpensTheSharedMenu() = runComposeUiTest {
val state = windowState(prefs())
val member = station("DL1ABC", 2)
val scrollRevision = state.stations.scrollRequestRevision
show(state, listOf(member), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
onNodeWithTag(STATION_CONTEXT_MENU_TAG).assertExists()
assertSame(member, state.stations.selectedRow)
assertEquals(StationSelectionSource.MAP_CONTEXT_MENU, state.stationSelection.lastSource)
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
}
@Test
fun aMarkerContextClickRequiresCategoryChoiceForAmbiguousTargets() = runComposeUiTest {
val state = windowState(prefs())
val inMain = station("DL1ABC", 2)
val inSecond = station("DL1ABC", 3)
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput {
click(button = MouseButton.Secondary)
}
waitForIdle()
onNodeWithTag(STATION_CONTEXT_TARGET_CHOOSER_TAG).assertExists()
assertEquals(null, state.stations.selectedRow)
}
@Test
fun workedGrossFieldsAreReadOnlyWhileGridColouringIsEnabled() = runComposeUiTest {
val state = windowState(prefs())
var reads = 0
state.workedGrossFieldsProvider = {
reads++
setOf("JO50")
}
show(state, emptyList(), mapLookingAt("JO50JP", zoom = 6f))
assertEquals(0, reads)
runOnIdle { state.stationFilter.gridColouring = true }
waitForIdle()
assertTrue(reads > 0, "enabling Grid color did not request the persistent worked fields")
val readsWhileEnabled = reads
runOnIdle { state.stationFilter.gridColouring = false }
waitForIdle()
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.
@@ -0,0 +1,25 @@
package kst4contest.view.compose.map
import kst4contest.view.map.MapViewport
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WorkedGrossFieldOverlayTest {
@Test
fun `worked cells are planned from persistent gross fields without station markers`() {
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
val cells = visibleWorkedGrossFieldCells(viewport, setOf("JO51", "AA00"))
assertEquals(listOf("JO51"), cells.map { it.locatorLabel() })
}
@Test
fun `disabled grid colour produces no worked overlay cells`() {
val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0)
assertTrue(visibleWorkedGrossFieldCells(viewport, emptySet()).isEmpty())
}
}
@@ -1648,6 +1648,7 @@ private SimpleRoster<String>
* lst_chatMemberList is this very field.
*/
private final SimpleRoster<ChatMember> chatMemberList = new SimpleRoster<>(); // List of active stations
private final SimpleRoster<RecentChatJoin> recentChatJoins = new SimpleRoster<>();
private final SimpleRoster<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = new SimpleRoster<>();
private final SimpleRoster<ClusterMessage> lst_clusterMemberList = new SimpleRoster<>();
@@ -1803,6 +1804,20 @@ private SimpleRoster<String>
* user-enter events.
*/
public void addOrUpdateActiveChatMember(ChatMember member) {
addOrUpdateActiveChatMember(member, System.currentTimeMillis());
}
/**
* Adds or replaces an active member and records a join only for a previously absent
* full callsign/category identity.
*
* @param member member received in a live user-enter frame
* @param joinedAtEpochMillis wall-clock time of that frame
*/
public void addOrUpdateActiveChatMember(
ChatMember member,
long joinedAtEpochMillis
) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return;
@@ -1810,7 +1825,7 @@ private SimpleRoster<String>
initializeFrequencyFromStationNameIfUnambiguous(member);
activeChatMembersByCallAndCategory.put(key, member);
ChatMember previous = activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> chatMemberList.mutate(list -> {
int existingIndex = findChatMemberIndexInList(list, key);
@@ -1820,6 +1835,18 @@ private SimpleRoster<String>
list.add(member);
}
}));
if (previous == null) {
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> {
boolean alreadyRecorded = joins.stream().anyMatch(join ->
join.matches(member.getCallSign(), categoryNumber));
if (!alreadyRecorded) {
joins.add(0, new RecentChatJoin(
member.getCallSign(), categoryNumber, joinedAtEpochMillis));
}
});
}
}
/**
@@ -1834,6 +1861,9 @@ private SimpleRoster<String>
}
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
int categoryNumber = member.getChatCategory().getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.matches(member.getCallSign(), categoryNumber)));
runOnFxThread(() -> chatMemberList.mutate(list ->
list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember)))));
return removedMember != null;
@@ -1845,6 +1875,7 @@ private SimpleRoster<String>
*/
public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear();
recentChatJoins.clear();
runOnFxThread(chatMemberList::clear);
}
@@ -1867,6 +1898,8 @@ private SimpleRoster<String>
}
int categoryNumber = category.getCategoryNumber();
recentChatJoins.mutate(joins -> joins.removeIf(join ->
join.getCategoryNumber() == categoryNumber));
List<ChatMember> safeMembers = completeMembers == null
? List.of() : new ArrayList<>(completeMembers);
Map<String, ChatMember> workedDataFromDatabase =
@@ -1992,8 +2025,8 @@ private SimpleRoster<String>
/**
* Applies a QSO received from an external logger to all active callsign
* variants. Active member objects back JavaFX views, so their state is changed
* only on the JavaFX Application Thread. The band-upgrade hint is evaluated
* variants. Active member objects back UI views, so their state is changed
* only through the configured UI dispatcher. The band-upgrade hint is evaluated
* afterwards and therefore sees the new Worked state.
*
* @param loggedQso validated external QSO
@@ -2199,6 +2232,16 @@ private SimpleRoster<String>
target.setQrv10G(sourceMember.isQrv10G());
}
if (dbHandler != null) {
try {
dbHandler.updateNotQRVInfoOnChatMember(sourceMember);
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist NOT-QRV state for " + rawCall,
exception);
}
}
if (scoreService != null) {
scoreService.requestRecompute("NOT-QRV state changed");
}
@@ -2743,6 +2786,96 @@ private SimpleRoster<String>
return chatMemberList;
}
/**
* Applies and persists a validated QSO through the same path for external
* logger packets and explicit UI Worked actions.
*
* @param loggedQso validated QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso) {
return loggedQso != null
&& recordLoggedQso(loggedQso, loggedQso.toWorkedChatMember());
}
/**
* Applies and persists a QSO while retaining the supplied database/backup row.
* Logger listeners use this overload so later backup serialization observes the
* same normalization as database persistence.
*
* @param loggedQso validated QSO
* @param workedCall mutable row derived from the QSO
* @return true when the QSO was accepted and its station state was persisted
*/
public boolean recordLoggedQso(ExternalLoggedQso loggedQso, ChatMember workedCall) {
if (loggedQso == null || workedCall == null || dbHandler == null) {
return false;
}
LoggedQsoBand loggedBand = loggedQso.getBand();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& loggedQso.getLocator() != null) {
registerWorkedGrossField(
loggedBand.getProjectBand(),
loggedQso.getLocator(),
workedCall,
loggedQso.getSource());
}
applyExternalLoggedQso(loggedQso);
try {
boolean isInChat = dbHandler.updateWkdInfoOnChatMember(workedCall);
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
dbHandler.storeChatMember(workedCall);
}
return true;
} catch (SQLException exception) {
LOGGER.log(Level.WARNING,
"Could not persist Worked state for " + loggedQso.getCallSign(),
exception);
return false;
}
}
/**
* Marks one station and band as worked with contactinfo-equivalent semantics.
* Worked contacts are additive; invoking this method for an already worked band
* is intentionally idempotent.
*
* @param member station selected by the UI
* @param band band to mark as worked
* @return true when a valid contact was accepted and persisted
*/
public boolean markStationWorkedOnBand(ChatMember member, Band band) {
if (member == null || band == null) {
return false;
}
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
if (loggedBand == null) {
return false;
}
String callSign = member.getCallSign() != null
? member.getCallSign()
: member.getCallSignRaw();
return ExternalLoggedQso.create(callSign, loggedBand, member.getQra(), "MANUAL")
.map(this::recordLoggedQso)
.orElse(false);
}
/** Live joins only; initial and resynchronized user lists never enter this roster. */
public ObservableRoster<RecentChatJoin> getRecentChatJoins() {
return recentChatJoins;
}
public ObservableRoster<ChatMember> getLst_DBBasedWkdCallSignList() {
return lst_DBBasedWkdCallSignList;
}
@@ -92,6 +92,27 @@ public enum LoggedQsoBand {
};
}
/**
* Resolves a project band for UI actions that must follow the same Worked
* processing as an external logger contact.
*
* @param band project band
* @return normalized logged-QSO band, or {@code null} for a missing band
*/
public static LoggedQsoBand fromProjectBand(Band band) {
if (band == null) {
return null;
}
for (LoggedQsoBand loggedBand : values()) {
if (loggedBand.projectBand == band) {
return loggedBand;
}
}
return null;
}
/**
* Returns the common Band value used by worked-grid persistence.
*
@@ -1145,7 +1145,7 @@ public class MessageBusManagementThread extends Thread {
// System.out.println("MSGBUS: User detected");
if (!client.getChatPreferences().getStn_loginCallSign().equals(splittedMessageLine[2])) { //own call ignore
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(splittedMessageLine[2])) { //own call ignore
ChatMember newMember = new ChatMember();
@@ -1164,7 +1164,8 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.addOrUpdateActiveChatMember(newMember);
this.client.addOrUpdateActiveChatMember(
newMember, millisClock.getAsLong());
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
@@ -774,30 +774,7 @@ public class ReadUDPByWintestThread extends Thread {
}
ChatMember workedCall = loggedQso.toWorkedChatMember();
if (loggedBand != null
&& loggedBand.getProjectBand() != null
&& locatorFromLogger != null) {
this.client.registerWorkedGrossField(
loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource());
}
client.applyExternalLoggedQso(loggedQso);
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
File logUDPMessageToThisFile = new File(this.client.getChatPreferences()
.getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup());
@@ -3,7 +3,6 @@ package kst4contest.controller;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Arrays;
import javax.xml.XMLConstants;
@@ -366,118 +365,11 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points));
ChatMember workedCall = loggedQso.toWorkedChatMember();
Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand();
if (loggedBand == null && !rawBand.isEmpty()) {
System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\"");
}
{
/**
* That means, the station is worked already but maybe at another band. So we
* have to get the worked ChatMember out of the list and to modify the worked
* options.
*/
// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call);
// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize
client.applyExternalLoggedQso(loggedQso);
/**
* old mechanic to markup worked stations in the chatmember table
*/
// int indexOfChatMemberInTable = -1; //chatmember not in table
// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall);
//
// if (indexOfChatMemberInTable == -1) {
// // do nothing
// } else {
// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable);
//
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true);
//
// if (workedCall.isWorked144()) {
// modifyThat.setWorked144(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked144(true);
//
// } else if (workedCall.isWorked432()) {
// modifyThat.setWorked432(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked432(true);
//
// } else if (workedCall.isWorked1240()) {
// modifyThat.setWorked1240(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked1240(true);
//
// } else if (workedCall.isWorked2300()) {
// modifyThat.setWorked2300(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked2300(true);
//
// } else if (workedCall.isWorked3400()) {
// modifyThat.setWorked3400(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked3400(true);
//
// } else if (workedCall.isWorked5600()) {
// modifyThat.setWorked5600(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked5600(true);
//
// } else if (workedCall.isWorked10G()) {
// modifyThat.setWorked10G(true);
// client.getLst_chatMemberList()
// .get(client.checkListForChatMemberIndexByCallSign(modifyThat))
// .setWorked10G(true);
// }
/**
* //TODO: following line is a quick fix to making disappear worked chatmembers of the list
* Thats uncomfortable due to this also causes selection changes,
* Better way is to change all worked and qrv values to observables and then trigger the underlying
* list to fire an invalidationevent. Really Todo!
*/
// try{
//
// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all
// } catch (Exception IllegalStateException) {
// //do nothing, as it works...
// }
// }
/**
* end -> old mechanic to markup worked stations in the chatmember table
*/
}
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(
workedBand, gridSquare, workedCall, loggedQso.getSource());
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
workedCall.setName("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
client.recordLoggedQso(loggedQso, workedCall);
appendToUdpBackupFile(this.client.getChatPreferences()
@@ -594,18 +486,6 @@ public class ReadUDPbyUCXMessageThread extends Thread {
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
} catch (SQLException e) {
// TODO Auto-generated catch block
e.printStackTrace();
threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true);
threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace()));
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
// threadStatusMessage = new String[2];
// threadStatusMessage[0] = "STOPPED";
// threadStatusMessage[1] = Arrays.toString(e.getStackTrace());
// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage);
}
// System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size());
@@ -146,6 +146,22 @@ public final class WorkedGrossFieldCache {
return false;
}
/**
* Returns an immutable snapshot of all gross fields worked on at least one
* band. Map rendering uses the snapshot without holding this cache's monitor.
*
* @return immutable any-band gross-field snapshot
*/
public synchronized Set<String> snapshotWorkedGrossFieldsAny() {
Set<String> snapshot = new HashSet<>();
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null) {
snapshot.addAll(workedGrossFields);
}
}
return Set.copyOf(snapshot);
}
/**
* Tries to normalize a locator. Accepts a plain six-character locator or extracts
* one from exchange strings such as "001JO41HK".
@@ -0,0 +1,48 @@
package kst4contest.model;
import java.util.Locale;
import java.util.Objects;
/**
* One live ON4KST join event.
*
* <p>The full callsign and category form the identity. The timestamp belongs to the
* first event while that identity remains active; ordinary user updates must not renew it.</p>
*/
public final class RecentChatJoin {
private final String callSign;
private final int categoryNumber;
private final long joinedAtEpochMillis;
public RecentChatJoin(
String callSign,
int categoryNumber,
long joinedAtEpochMillis
) {
this.callSign = Objects.requireNonNull(callSign, "callSign")
.trim()
.toUpperCase(Locale.ROOT);
this.categoryNumber = categoryNumber;
this.joinedAtEpochMillis = joinedAtEpochMillis;
}
public String getCallSign() {
return callSign;
}
public int getCategoryNumber() {
return categoryNumber;
}
public long getJoinedAtEpochMillis() {
return joinedAtEpochMillis;
}
/** Returns whether the event belongs to the same complete chat identity. */
public boolean matches(String otherCallSign, int otherCategoryNumber) {
return otherCallSign != null
&& categoryNumber == otherCategoryNumber
&& callSign.equalsIgnoreCase(otherCallSign.trim());
}
}
@@ -60,10 +60,30 @@ public record MapCallsignRawSnapshot(
}
public String markerLabel() {
String baseLabel = bandSummary.isBlank()
? displayCallSign
: displayCallSign + " (" + bandSummary + ")";
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
return displayCallSign;
}
/**
* Returns the details removed from the visible marker label for its hover text.
*
* @return multiline callsign, locator, band and frequency summary
*/
public String tooltipText() {
StringJoiner joiner = new StringJoiner("\n");
joiner.add(displayCallSign);
if (!locator6.isBlank()) {
joiner.add("Locator: " + locator6);
}
if (!bandSummary.isBlank()) {
joiner.add("Bands: " + bandSummary);
}
if (offersSelectedBand) {
joiner.add("B+");
}
if (!lastKnownFrequenciesByBand.isEmpty()) {
joiner.add(detailFrequencyText());
}
return joiner.toString();
}
public String detailFrequencyText() {
@@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip {
public static String build(MapMarkerCluster cluster) {
List<String> callsigns = new ArrayList<>();
for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
String label = marker.markerLabel();
String label = marker.tooltipText().replace('\n', ' ');
if (label != null && !label.isBlank()) {
callsigns.add(label.trim());
}
@@ -0,0 +1,40 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class ChatControllerNotQrvPersistenceTest {
@Test
void propagatedNotQrvStateIsPersistedForTheSharedRawCall() throws SQLException {
DBController database = mock(DBController.class);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2);
ChatMember second = member("9A0BB-70", 3);
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(second);
selected.setQrv432(false);
controller.propagateNotQrvStateToActiveMembers(selected);
assertFalse(second.isQrv432());
verify(database).updateNotQRVInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw()) && !member.isQrv432()));
}
private static ChatMember member(String callSign, int category) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
return member;
}
}
@@ -0,0 +1,72 @@
package kst4contest.controller;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import kst4contest.model.RecentChatJoin;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatControllerRecentJoinTest {
@Test
void liveJoinIsRecordedOnceWithoutRenewingTheTimestampOnDuplicateUpdates() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 9_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(1, joins.size());
assertEquals("HB9SJV", joins.get(0).getCallSign());
assertEquals(2, joins.get(0).getCategoryNumber());
assertEquals(1_000L, joins.get(0).getJoinedAtEpochMillis());
}
@Test
void theSameCallsignInTwoCategoriesRemainsTwoJoinIdentities() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("DL1KDA", 2), 1_000L);
controller.addOrUpdateActiveChatMember(member("DL1KDA", 3), 2_000L);
List<RecentChatJoin> joins = controller.getRecentChatJoins().snapshot();
assertEquals(2, joins.size());
assertEquals(List.of(3, 2), joins.stream().map(RecentChatJoin::getCategoryNumber).toList());
}
@Test
void leavingRemovesTheJoinAndARealRejoinStartsAFreshTimestamp() {
ChatController controller = new ChatController();
ChatMember member = member("OE5SOS", 2);
controller.addOrUpdateActiveChatMember(member, 1_000L);
controller.removeActiveChatMember(member);
assertTrue(controller.getRecentChatJoins().isEmpty());
controller.addOrUpdateActiveChatMember(member("OE5SOS", 2), 7_000L);
assertEquals(7_000L,
controller.getRecentChatJoins().snapshot().get(0).getJoinedAtEpochMillis());
}
@Test
void clearingTheActiveRosterAlsoClearsTransientJoinEvents() {
ChatController controller = new ChatController();
controller.addOrUpdateActiveChatMember(member("PA2M", 2), 1_000L);
controller.clearActiveChatMembers();
assertTrue(controller.getRecentChatJoins().isEmpty());
}
private static ChatMember member(String callSign, int categoryNumber) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(categoryNumber));
return member;
}
}
@@ -0,0 +1,66 @@
package kst4contest.controller;
import kst4contest.model.Band;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ChatControllerWorkedActionTest {
@Test
void manualWorkedActionUsesContactInfoPersistenceAndUpdatesEveryActiveVariant()
throws SQLException {
DBController database = mock(DBController.class);
when(database.updateWkdInfoOnChatMember(argThat(member -> member != null)))
.thenReturn(true);
ChatController controller = new ChatController();
controller.setDbHandler(database);
ChatMember selected = member("9A0BB-2", 2, "JN75AA");
ChatMember secondCategory = member("9A0BB-70", 3, "JN75AA");
controller.addOrUpdateActiveChatMember(selected);
controller.addOrUpdateActiveChatMember(secondCategory);
assertTrue(controller.markStationWorkedOnBand(selected, Band.B_432));
assertTrue(selected.isWorked());
assertTrue(selected.isWorked432());
assertTrue(secondCategory.isWorked());
assertTrue(secondCategory.isWorked432());
assertTrue(controller.getWorkedGrossFieldCache()
.isGrossFieldWorked(Band.B_432, "JN75"));
verify(database).updateWkdInfoOnChatMember(argThat(member ->
"9A0BB".equals(member.getCallSignRaw())
&& member.isWorked()
&& member.isWorked432()
&& "JN75AA".equals(member.getQra())));
verify(database).upsertWorkedGrossField(
Band.B_432, "JN75AA", "9A0BB", "MANUAL");
}
@Test
void manualWorkedActionRejectsMissingStationOrBandWithoutChangingState() {
ChatController controller = new ChatController();
ChatMember member = member("DL1ABC", 2, "JO50AA");
assertFalse(controller.markStationWorkedOnBand(null, Band.B_144));
assertFalse(controller.markStationWorkedOnBand(member, null));
assertFalse(member.isWorked());
}
private static ChatMember member(String callSign, int category, String locator) {
ChatMember member = new ChatMember();
member.setCallSign(callSign);
member.setChatCategory(new ChatCategory(category));
member.setQra(locator);
return member;
}
}
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.stream.Stream;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -37,6 +38,15 @@ class ExternalLoggedQsoTest {
assertNull(LoggedQsoBand.fromWinTestBandId("99"));
}
@Test
void resolvesEveryProjectBandForManualWorkedActions() {
for (Band band : Band.values()) {
LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band);
assertEquals(band, loggedBand.getProjectBand());
}
}
@Test
void missingCallsignIsRejectedBeforeAnyStateIsBuilt() {
assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty());
@@ -41,7 +41,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processUCXUDPMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
@@ -55,7 +55,7 @@ class ExternalLoggerListenerValidationTest {
assertDoesNotThrow(() -> listener.processWinTestMessage(packet));
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(controller, never()).getDbHandler();
}
}
@@ -63,6 +63,7 @@ class MessageBusManagementThreadChatFrameTest {
private static final String RECEIVER_LOCATOR = "JO70LL";
private static final double MAX_QRB_KM = 900.0;
private static final double BEAM_WIDTH_DEG = 50.0;
private static final long FIXED_NOW_MILLIS = 1_700_000_123_456L;
@TempDir
Path tempDir;
@@ -102,6 +103,10 @@ class MessageBusManagementThreadChatFrameTest {
.thenReturn(mock(StationMetricsService.class));
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
when(controller.getDxClusterServer()).thenReturn(dxClusterServer);
DBController database = mock(DBController.class);
when(controller.getDbHandler()).thenReturn(database);
when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(any(ChatMember.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(controller.findActiveChatMember(any(ChatMember.class)))
.thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0)));
}
@@ -181,6 +186,60 @@ class MessageBusManagementThreadChatFrameTest {
assertFalse(published.isMessageDirectedToMe());
}
@Test
@Timeout(10)
void liveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA5|2|HB9SJV|Contest team|JN47AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("HB9SJV", member.getValue().getCallSign());
assertEquals(ChatCategory.VUHF,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void alternateLiveUserEntryCarriesTheInjectedJoinTimestampToTheController()
throws InterruptedException {
runThrough(message("UA2|3|OE5SOS|Microwave team|JN78AA|3|"));
ArgumentCaptor<ChatMember> member = ArgumentCaptor.forClass(ChatMember.class);
verify(controller).addOrUpdateActiveChatMember(member.capture(),
org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS));
assertEquals("OE5SOS", member.getValue().getCallSign());
assertEquals(ChatCategory.MICROWAVE,
member.getValue().getChatCategory().getCategoryNumber());
}
@Test
@Timeout(10)
void initialUserListAndItsTerminatorDoNotCreateLiveJoinEvents()
throws InterruptedException {
runThrough(
message("UA0|2|HB9SJV|Contest team|JN47AA|3|"),
message("UE|2|1|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
verify(controller).stageInitialOn4KstChatMember(
org.mockito.ArgumentMatchers.eq(7L), any(ChatMember.class));
verify(controller).onOn4KstInitialUserListCompleted(
org.mockito.ArgumentMatchers.eq(7L), any(ChatCategory.class));
}
@Test
@Timeout(10)
void ownCallIsExcludedFromLiveJoinEventsIgnoringCase()
throws InterruptedException {
runThrough(message("UA5|2|dn9apw|Own station|JO50LL|3|"));
verify(controller, never()).addOrUpdateActiveChatMember(
any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong());
}
// --- sent by me ---------------------------------------------------------
@Test
@@ -417,7 +476,8 @@ class MessageBusManagementThreadChatFrameTest {
mock(ThreadStatusCallback.class),
7L,
rxQueue,
ignored -> true);
ignored -> true,
() -> FIXED_NOW_MILLIS);
for (ChatMessage frame : frames) {
rxQueue.add(frame);
}
@@ -25,7 +25,6 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
@@ -145,12 +144,12 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
}
/**
* A UCXLog QSO packet marks the station worked, registers the gross field
* and stores an unknown station in the database and the UDP backup file.
* A UCXLog QSO packet delegates the complete contact to the shared controller
* action and writes the derived row to the UDP backup file.
* Bytes before the XML declaration are transport framing and are dropped.
*/
@Test
void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception {
void contactInfoPacketUsesSharedWorkedActionAndWritesBackup(@TempDir Path tempDir) throws Exception {
Path backupFile = tempDir.resolve("udp-backup.txt");
DBController database = givenQsoProcessingEnabled(backupFile);
when(database.updateWkdInfoOnChatMember(any())).thenReturn(false);
@@ -164,24 +163,13 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "</contactinfo>");
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).applyExternalLoggedQso(qso.capture());
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals("OK1ABC", qso.getValue().getCallSign());
assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand());
assertEquals("JO70FD", qso.getValue().getLocator());
assertEquals("UCXLOG", qso.getValue().getSource());
verify(controller).registerWorkedGrossField(
eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG"));
ArgumentCaptor<ChatMember> stored = ArgumentCaptor.forClass(ChatMember.class);
verify(database).storeChatMember(stored.capture());
assertEquals("OK1ABC", stored.getValue().getCallSign());
assertEquals("unknown", stored.getValue().getName());
assertEquals("JO70FD", stored.getValue().getQra());
assertTrue(stored.getValue().isWorked());
assertTrue(stored.getValue().isWorked144());
String backup = Files.readString(backupFile, StandardCharsets.UTF_8);
assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup);
assertTrue(backup.startsWith("\nOK1ABC;null;JO70FD;"), backup);
}
/**
@@ -198,10 +186,10 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
+ "<contactreplace><app>DXLog</app><band>432</band><call>SP2XYZ</call>"
+ "<rcvnr>042JO93AB</rcvnr></contactreplace>");
verify(controller).registerWorkedGrossField(
eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG"));
verify(database).updateWkdInfoOnChatMember(any());
verify(database, never()).storeChatMember(any());
ArgumentCaptor<ExternalLoggedQso> qso = ArgumentCaptor.forClass(ExternalLoggedQso.class);
verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class));
assertEquals(LoggedQsoBand.BAND_432, qso.getValue().getBand());
assertEquals("JO93AB", qso.getValue().getLocator());
}
@Test
@@ -211,7 +199,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
verify(controller, never()).applyExternalLoggedQso(any());
verify(controller, never()).recordLoggedQso(any(), any());
verify(database, never()).updateWkdInfoOnChatMember(any());
}
@@ -229,8 +217,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call>"
+ "<gridsquare>JO70FD</gridsquare></contactinfo>");
verify(controller).applyExternalLoggedQso(any());
verify(database).storeChatMember(any());
verify(controller).recordLoggedQso(any(), any());
}
/** A RadioInfo without Freq used to end the listener with a NullPointerException. */
@@ -250,7 +237,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -297,7 +284,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest {
@Test
void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception {
givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt"));
doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any());
doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any());
statusCallbackThrows = true;
sendThenAwaitMarker("<contactinfo><band>144</band><call>OK1ABC</call></contactinfo>");
@@ -0,0 +1,25 @@
package kst4contest.controller;
import kst4contest.model.Band;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WorkedGrossFieldCacheTest {
@Test
void anyBandSnapshotContainsUniqueNormalizedGrossFieldsAndIsImmutable() {
WorkedGrossFieldCache cache = new WorkedGrossFieldCache();
cache.addWorked(Band.B_144, "jo51aa");
cache.addWorked(Band.B_432, "JO51BB");
cache.addWorked(Band.B_1296, "JN49AA");
Set<String> snapshot = cache.snapshotWorkedGrossFieldsAny();
assertEquals(Set.of("JO51", "JN49"), snapshot);
assertThrows(UnsupportedOperationException.class, () -> snapshot.add("JO50"));
}
}
@@ -106,6 +106,8 @@ class ChatPreferencesLayoutPersistenceTest {
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812;
preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340;
preferences.setGUIstationMapClusteringEnabled(false);
preferences.setGUIselectedCallSignSplitPane_dividerposition(
new double[] {0.68});
preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
@@ -124,6 +126,8 @@ class ChatPreferencesLayoutPersistenceTest {
assertTrue(restored.readPreferencesFromXmlFile());
assertEquals("SAVED-CALL", restored.getStn_loginCallSign());
assertFalse(restored.isGUIstationMapClusteringEnabled());
assertArrayEquals(new double[] {0.68},
restored.getGUIselectedCallSignSplitPane_dividerposition(), 0.0001);
assertEquals(123.75,
restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow());
}
@@ -0,0 +1,37 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotLabelTest {
@Test
void visibleMarkerLabelContainsOnlyTheCallsign() {
MapCallsignRawSnapshot snapshot = snapshot();
assertEquals("DM5M", snapshot.markerLabel());
}
@Test
void tooltipRetainsLocatorBandsUpgradeAndFrequencyDetails() {
String tooltip = snapshot().tooltipText();
assertTrue(tooltip.contains("DM5M"));
assertTrue(tooltip.contains("JO51AA"));
assertTrue(tooltip.contains("144, 432"));
assertTrue(tooltip.contains("B+"));
assertTrue(tooltip.contains("144: 144.310"));
}
private MapCallsignRawSnapshot snapshot() {
return new MapCallsignRawSnapshot(
"DM5M", "DM5M", "JO51AA", 51.0, 10.0,
"144, 432", Map.of("144", "144.310"), true,
false, false, false, 100.0, 180.0, 0, 0L
);
}
}
@@ -51,9 +51,10 @@ class MapMarkerClusterTooltipTest {
List<String> lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n"));
assertEquals("3 stations", lines.get(0));
assertEquals("DL000AA", lines.get(1));
assertEquals("DL001AA", lines.get(2));
assertEquals("DL002AA", lines.get(3));
assertTrue(lines.get(1).startsWith("DL000AA"));
assertTrue(lines.get(2).startsWith("DL001AA"));
assertTrue(lines.get(3).startsWith("DL002AA"));
assertTrue(lines.get(1).contains("JO50JP"));
}
/** A hundred callsigns is a box taller than the window. */
+9 -5
View File
@@ -131,6 +131,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state.
- A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener.
- External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the user-interface thread, through `UiDispatcher`, before evaluating a band-upgrade notice.
- Explicit **Mark as worked** context actions construct the same validated `ExternalLoggedQso` state and use the same controller/database path as logger `contactinfo` handling. The operation is additive and idempotent; it does not provide an Unwork action or create a parallel UI-only store.
- The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state.
### Win-Test log recovery
@@ -194,16 +195,19 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts.
- Station selection preserves the established `/cq callsign` prefill behaviour.
- `StationSelectionState` is the canonical selection shared by the station table and Further Info panel. Its identity is the complete callsign plus chat category. Explicit actions carry a `StationSelectionSource` and increment a separate scroll-request revision even when the same station is selected again; roster reconciliation replaces the selected instance without moving the table.
- Recent-join state is transient domain state keyed by complete callsign plus chat category. Only live `UA5`/`UA2` frames can create an entry; the `UA0`/`UE` initial or reconnect snapshot and `UM3` profile updates never do. Duplicate live updates do not renew the timestamp, a leave removes the entry, and a genuine rejoin begins a new ten-minute interval. The local login callsign is excluded. The core `ObservableRoster` is mirrored through `UiDispatcher`; New-joined actions reuse the canonical station selection and scroll request.
- The station table starts with a case-insensitive ascending callsign sort. A user-selected sort remains authoritative across roster and filter updates. An externally selected filtered station is pinned into the table only; `filteredRows` remains the exact filter result used by the map.
- PM, Public, QSO-of-the-other, priority, timeline and individual map-marker actions route through the application-level station selector. Directed messages target the sender, except that an outgoing local PM targets its receiver. Public and QSO-of-the-other rows target their sender in the message category.
- PM, Public, QSO-of-the-other, priority, timeline and individual map-marker actions route through the application-level station selector. Directed messages target the sender, except that an outgoing local PM targets its receiver. Public and QSO-of-the-other rows target their sender in the message category. Direct clicks and context clicks inside the station table select without issuing an automatic scroll request; selections originating in the map, messages, priority, New joined or other station controls issue one even when the identity was already selected.
- The selected-station message radio mode is independent of station identity and always retains the complete callsign plus chat category. `PM_TO_ME` is the private conversation with the local station plus public traffic from the selected station carrying the structured leading `(LOCALCALL)` address, `PM_TO_OTHER` is private traffic sent by the selected station to other participants, `PUBLIC` is traffic sent by it to `ALL`, and `NOTHING` applies no additional type/receiver restriction. An arbitrary callsign mention in prose is not a structured public address.
- ON4KST chat parsing records `messageDirectedToCommunity` from the receiver field and `messageDirectedToMe` from either a genuine private receiver or the exact leading public address. The selected-station Compose message mirror is initialized with the main window state, before the first selection callback, and roster changes recompute and push the filtered rows automatically.
- The map's **Reset filters** action uses the same `StationFilterState.reset()` path as the main filter bar and does not change sort, selection, zoom or viewport. Individual raw-callsign markers retain an already selected category variant; otherwise they use the configured Main category. Spatial clusters zoom and never choose an arbitrary member.
- The map's **Reset filters** action uses the same `StationFilterState.reset()` path as the main filter bar and does not change sort, selection, zoom, viewport or the display-only Grid-color toggle. Individual raw-callsign markers retain an already selected category variant; otherwise they use the configured Main category. Spatial clusters zoom and never choose an arbitrary member.
- The user list and map share one station context-menu implementation. A secondary click selects a unique target through `StationSelectionState`; base-call markers or screen clusters containing several full-callsign/category identities first require an explicit target choice. Message snippets reuse the existing send-field action, and the context sked editor owns an isolated draft with the target fixed at opening time.
- Map markers draw only the display callsign; bands, `B+`, locator and QRG details remain in tooltips. When Grid color is enabled, the canvas draws a black outline and a 30 percent black fill overlay for four-character squares from `WorkedGrossFieldCache.snapshotWorkedGrossFieldsAny()`, independently of visible station markers. The cache is rebuilt from SQLite at startup and is the same any-band state used by **Only new grids**.
- The AP timeline renders the producer's four candidate lanes in order (two in-beam, two global priority) plus a separate sked lane. Its relative 0–30 minute axis normally uses five-minute ticks and increases the interval when labels would overlap. The vertical message splitter applies pane-specific minimums for the timeline, shortcut row and send row, so older stored divider positions cannot clip these fixed-height controls; stored positions remain the unconstrained baseline.
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
- The lower selected-station panel is vertically scrollable. Its message table has a finite, independently scrollable height so Compose never measures a `LazyColumn` with unbounded vertical constraints and every action remains reachable in a short splitter pane. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state.
- The lower selected-station panel contains a vertical two-pane `SplitterPane`: the upper pane owns the independently scrollable message table, while the lower pane scrolls NOT-QRV and Sked above a pinned lookup group. The lower pane reserves 160 dp before stored divider positions are applied, so normal small-window layouts retain direct access to lookup actions; impossible smaller layouts keep the action groups scrollable. `GUIselectedCallSignSplitPane_dividerposition` persists this divider through the existing profile-scoped layout autosave. Station or message updates never reset it. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state.
- The selected-station sked picker takes its options exclusively from `MainWindowState.activeBands`, which originates in `BandOpportunityResolver.getEnabledStationBands(...)`. Station QRG and name evidence chooses the initial band but must not empty or narrow the operator's locally enabled choices. The detected-band header value is already localized and labelled by its producer, so the Compose panel renders it unchanged.
- Compact desktop buttons, text fields, choices and checkbox rows share `Density.CONTROL_HEIGHT` at 26 dp and `compactControlTextStyle()`. Material touch-target and component defaults must not reintroduce different visible heights. Compass filter buttons keep a common 28 dp width with reduced horizontal content padding; the send-row QRG fields reserve 96 dp and QTF fields 56 dp so normal contest values remain visible.
- Compact desktop buttons, text fields, choices and checkbox rows share `Density.CONTROL_HEIGHT` at 26 dp and `compactControlTextStyle()`. Material touch-target and component defaults must not reintroduce different visible heights. Compass filter buttons keep a common 28 dp width with reduced horizontal content padding; the send-row QRG fields reserve 96 dp and QTF fields 56 dp so normal contest values remain visible. The station filter bar groups related controls on shared theme surfaces while its outer flow rows continue to wrap from the station pane's actual width.
## Autoanswer / Beacon
@@ -408,7 +412,7 @@ Before implementing planned items, re-check current decisions and obtain a fresh
- Historical project context is useful but may be stale; current code/tests win.
- External service/API behaviour must be verified against current upstream documentation when uncertain.
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
- `station_map_path_analysis.png` and `station_map_reset.png` predate the **Grouping**, **Reset filters** and **Always on top** controls in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
- `station_map_path_analysis.png` and `station_map_reset.png` predate the current header controls, callsign-only markers, shared station context menu and Worked-square overlay. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
## Recent Significant Changes
+27 -3
View File
@@ -106,8 +106,10 @@ Beim ersten Aufbau ist die Benutzerliste alphabetisch nach dem vollständigen Ru
Die Stationsauswahl gilt gemeinsam für Benutzerliste, PM-, Public- und QSO-of-the-other-Tabelle, Prioritätsanzeige, AP-Timeline und Karte. Eine Auswahl aus einer anderen Ansicht scrollt die Benutzerliste zur aktuellen Position des Chatmembers und hebt die Zeile hervor. Ist die Station durch einen Filter ausgeblendet, erscheint sie vorübergehend zusätzlich in der Benutzerliste; die gespeicherten Filter und die exakt gefilterte Kartenansicht ändern sich dadurch nicht. Rufzeichen und Chat-Kategorie bilden gemeinsam die Identität.
Ein fett dargestelltes Rufzeichen kennzeichnet eine Station, die während der laufenden Sitzung tatsächlich neu in den Chat gekommen ist. Die Hervorhebung bleibt zehn Minuten sichtbar. Die anfängliche Benutzerliste und eine vollständige Neusynchronisierung nach einem Reconnect erzeugen keine solchen Markierungen. Im Day-Design wird eine kontrastreiche dunkle, im Evening-Design eine entsprechend helle Schrift verwendet.
Ein grün und fett dargestelltes Rufzeichen kennzeichnet eine aus einer gerichteten Nachricht hergeleitete Richtungsgelegenheit. Die Markierung bezieht sich auf den Absender der Nachricht und bleibt höchstens fünf Minuten sichtbar. Herleitung und Grenzen: [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten).
### Sendfeld
Das Sendfeld enthält den vorbereiteten Text für die nächste ausgehende Nachricht.
@@ -180,6 +182,8 @@ Damit muss der Divider nicht allein deshalb verschoben werden, um eine einzelne
Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Filter können miteinander kombiniert werden; eine Station bleibt nur sichtbar, wenn sie alle aktiven Bedingungen erfüllt.
Dezent umrahmte Flächen fassen **QRB**, **QTF** einschließlich Richtungstasten, **Hide**, **Show** und **Reachability** jeweils funktional zusammen. **Find** und **Reset filters** bleiben eigenständige Controls. Die Gruppen ändern nur die Darstellung; Filterzustände, Reset-Verhalten und Kartenkopplung bleiben gemeinsam verwaltet.
![Umgebrochene Filterleiste bei schmaler Chatmember-Ansicht](filter_bar_wrapped.png)
> **Hinweis:** Dieser Screenshot zeigt noch die frühere Anordnung der Filterleiste. Er wird nach der visuellen Prüfung der neuen kompakten, umbrechenden Leiste ersetzt.
@@ -267,7 +271,7 @@ Eine bloße Erwähnung des eigenen Rufzeichens irgendwo im Nachrichtentext macht
Die Einstellung verändert nur die Darstellung im Stationsinfo-Panel. Nachrichten werden dadurch weder verworfen noch aus den übrigen Nachrichtentabellen entfernt. Der Filter kann im Panel jederzeit für die aktuell betrachtete Station gewechselt werden.
Ist der Further-Info-Bereich durch den unteren Divider sehr niedrig, lässt sich sein Inhalt vertikal scrollen. Die Nachrichtentabelle behält dabei einen kompakten, eigenen Scrollbereich; die Sked-, NOT-QRV-, Karten-, AirScout-, Antennen- und Lookup-Aktionen bleiben erreichbar. Leere Tabellen zeigen weiterhin ihre Spaltenstruktur und den Hinweis **Keine Einträge**.
Ein horizontaler Divider im Further-Info-Bereich teilt die Höhe zwischen Nachrichtentabelle und Stationsfunktionen. Die Nachrichtentabelle wird beim Verschieben unmittelbar höher oder niedriger. NOT-QRV und Sked besitzen im unteren Bereich einen eigenen vertikalen Scrollbereich; **Look up and show** bleibt darunter fest sichtbar. Die Dividerposition wird mit dem Layout des aktiven Root- oder Operatorprofils gespeichert und bei einem kleineren Fenster auf einen bedienbaren Wert begrenzt. Leere Tabellen zeigen weiterhin ihre Spaltenstruktur und den Hinweis **Keine Einträge**.
Der Stationskopf fasst Rufzeichen, Chat-Kategorie, QTF, QRB, letzte Aktivität und erkannte Bänder platzsparend zusammen. Der Locator wird im Kopf nicht noch einmal wiederholt; in den übrigen Stationsansichten bleibt er unverändert verfügbar. Bei einem schmalen Panel fließen die Angaben kontrolliert in weitere Zeilen. **Show messages** steht direkt vor den vier Nachrichtenfiltern und fließt gemeinsam mit ihnen um.
@@ -310,7 +314,13 @@ Die vollständige Herleitung und die Grenzen der Funktion sind unter [Skeds und
## Prioritätsliste
Die kompakte Prioritätsleiste befindet sich rechts zwischen Benutzerliste und Further-Info-Bereich. Sie zeigt die beiden derzeit höchstbewerteten Kandidaten unmittelbar im Hauptfenster:
Die kompakte Leiste befindet sich rechts zwischen Benutzerliste und Further-Info-Bereich. Links erscheint bei Bedarf die Gruppe **Neu hinzugekommen**, rechts bleibt die unabhängige Gruppe **Priority**.
**Neu hinzugekommen** zeigt die neuesten tatsächlichen Chat-Beitritte für zehn Minuten an, neuere Einträge zuerst. Bei ausreichender Breite sind bis zu fünf Stationen direkt sichtbar; weitere oder wegen geringer Breite ausgeblendete Einträge stehen im kompakten **more**-Dropdown. Die anfängliche Benutzerliste, eine vollständige Neusynchronisierung, reine Profilaktualisierungen und das eigene Rufzeichen werden nicht aufgenommen. Verlässt eine Station den Chat, verschwindet ihr Eintrag sofort. Ein späterer echter Wiedereintritt startet die Frist neu. Vollständiges Rufzeichen und Chat-Kategorie bleiben getrennte Identitäten.
Ein Klick auf einen Eintrag verwendet die gemeinsame Stationsauswahl, aktualisiert das Further-Info-Panel und scrollt bei Bedarf zur Station in der Benutzerliste.
Die Gruppe **Priority** zeigt die beiden derzeit höchstbewerteten Kandidaten unmittelbar im Hauptfenster:
```text
Priority: 1 RUFZEICHEN SCORE 2 RUFZEICHEN SCORE more
@@ -353,8 +363,12 @@ Die Karte verwendet die Stationen, die nach Anwendung der aktuellen Benutzerlist
![Stationskarte mit ausgewählter Station und eingeblendeter Streckenanalyse](station_map_path_analysis.png)
> **Screenshot-Hinweis:** Das Bild zeigt noch die frühere Markerbeschriftung und weder das neue Kontextmenü noch die Worked-Großfeldfläche. Es wird nach der manuellen Kartenabnahme ersetzt.
### Station auswählen
Ein Marker zeigt als sichtbare Beschriftung ausschließlich das Rufzeichen. Locator, erkannte Bänder, `B+`-Status und zuletzt bekannte QRGs stehen weiterhin im Tooltip.
Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
1. übernimmt den konkreten Chatmember als aktuelle Auswahl,
@@ -368,6 +382,12 @@ Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder
Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele.
Ein Rechtsklick auf einen Marker oder eine Zeile der Benutzerliste öffnet dasselbe Stations-Kontextmenü. Es enthält **Message snippets**, **Create sked…**, bandbezogene **Tag NOT QRV**- und **Mark as worked**-Aktionen sowie **Calculate reachability**. Repräsentiert ein Marker mehrere vollständige Rufzeichen oder Chat-Kategorien, muss zuerst das konkrete Ziel gewählt werden. Dadurch werden keine Aktionen willkürlich auf den ersten Eintrag angewendet.
Die Snippet-Aktion ergänzt lediglich das Sendefeld und sendet nicht selbst. Der Sked-Dialog arbeitet mit dem beim Öffnen festgehaltenen Ziel; ein späterer Auswahlwechsel ändert diesen Entwurf nicht. **Mark as worked** verwendet dieselbe additive Worked- und Datenbanklogik wie ein empfangenes Logger-`contactinfo` und entfernt keine bereits vorhandene Markierung.
**Calculate reachability** startet die vorhandene Streckenberechnung ausdrücklich für das gewählte Band. Wird die Aktion aus der Benutzerliste aufgerufen, öffnet KST4Contest die Karte und zeigt dort Ladezustand, Ergebnis oder Fehler für genau dieses Ziel an.
Die Kopfzeile ergänzt bei ausgewählter Station:
- vollständiges Rufzeichen,
@@ -379,7 +399,9 @@ Die Kopfzeile ergänzt bei ausgewählter Station:
Lange Inhalte werden in der Kopfzeile gekürzt. Der vollständige Text steht im Tooltip.
**Filter zurücksetzen** führt dieselbe Aktion wie die Schaltfläche in der Benutzerliste aus. Dabei werden alle aktivierten Stationsfilter, die Rufzeichensuche, Richtungsfilter und Grid-Färbung zurückgesetzt. Sortierung, Stationsauswahl, Zoom und Kartenausschnitt bleiben erhalten. **Immer im Vordergrund** wirkt ausschließlich auf das Kartenfenster, wird sofort angewendet und für dieses Operatorprofil automatisch gespeichert; ohne gespeicherten Wert ist die Option ausgeschaltet.
**Filter zurücksetzen** führt dieselbe Aktion wie die Schaltfläche in der Benutzerliste aus. Dabei werden alle aktivierten Stationsfilter, die Rufzeichensuche und Richtungsfilter zurückgesetzt. Die reine Darstellungsoption **Grid color** bleibt ebenso unverändert wie Sortierung, Stationsauswahl, Zoom und Kartenausschnitt. **Immer im Vordergrund** wirkt ausschließlich auf das Kartenfenster, wird sofort angewendet und für dieses Operatorprofil automatisch gespeichert; ohne gespeicherten Wert ist die Option ausgeschaltet.
Bei aktivem **Grid color** kennzeichnet die Karte dauerhaft bekannte, bereits gearbeitete vierstellige Maidenhead-Großfelder mit einem schwarzen Rahmen und einer schwarzen Flächenüberblendung von 30 Prozent. Die Darstellung stammt aus derselben persistenten Worked-Grid-Verwaltung wie **Only new grids** und bleibt deshalb auch sichtbar, wenn sich momentan keine Chatstation in dem Großfeld befindet. Bei ausgeschaltetem **Grid color** werden weder Rahmen noch Flächenüberblendung gezeichnet.
### Auswahl mit Reset view löschen
@@ -524,6 +546,8 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb
KST4Contest speichert Größen und Positionen der Programmfenster, die relevanten Divider sowie manuell geänderte Tabellenbreiten automatisch nach einer kurzen Verzögerung in der Konfigurationsdatei. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. **Save Settings** ist dafür nicht erforderlich, speichert aber weiterhin den vollständigen aktuellen Stand einschließlich Layout.
Dazu gehört der horizontale Divider zwischen der Nachrichtentabelle und den Stationsfunktionen im Further-Info-Bereich. Sein Wert gilt für das aktive Root- oder Operatorprofil; beim Wiederherstellen begrenzt KST4Contest ihn auf die im aktuellen Fenster nutzbare Höhe.
Das Hauptfenster wird beim Start zusätzlich gegen den sichtbaren Bereich des primären Bildschirms geprüft. Ist die gespeicherte Größe zu groß, verkleinert und verschiebt KST4Contest das Fenster so, dass es wieder erreichbar bleibt. Die genaue Herleitung ist unter [Bildschirmgerechte Größe des Hauptfensters](de-Funktionen#bildschirmgerechte-größe-des-hauptfensters-ab-v141) beschrieben.
Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, genügt eine manuelle Korrektur; die neue Größe wird automatisch gespeichert.
+27 -3
View File
@@ -107,6 +107,8 @@ On its first build, the user list is sorted alphabetically by complete callsign,
Station selection is shared by the user list, PM, Public and QSO-of-the-other tables, priority display, AP timeline and map. A selection made in another view scrolls the user list to the chat member's current position and highlights the row. If a filter hides that station, it is temporarily added to the user list; the stored filters and exactly filtered map view remain unchanged. Callsign and chat category jointly identify the station.
A callsign shown in bold marks a station which actually joined the chat during the current session. The emphasis remains visible for ten minutes. The initial user list and a complete resynchronisation after a reconnect do not create these markers. The Day design uses a contrasting dark text colour, and the Evening design uses an appropriately light colour.
A callsign displayed in green and bold indicates a directional opportunity derived from a directed message. The marker applies to the sender of that message and remains visible for no more than five minutes. Derivation and limitations: [Directional Opportunities from Directed Messages](en-Features#directional-opportunities-from-directed-messages).
@@ -182,6 +184,8 @@ This avoids having to move the divider merely to read an individual long message
The filter bar is located above the chat-member table. Filters can be combined; a station remains visible only if it satisfies every active condition.
Subtly bordered surfaces group **QRB**, **QTF** including direction buttons, **Hide**, **Show** and **Reachability** by function. **Find** and **Reset filters** remain separate controls. The grouping changes presentation only; filter state, reset behaviour and map coupling remain shared.
![Wrapped filter bar in a narrow chat-member view](filter_bar_wrapped.png)
> **Note:** This screenshot still shows the previous filter-bar layout. It will be replaced after the new compact, wrapping layout has completed its visual check.
@@ -269,7 +273,7 @@ A local callsign mentioned only somewhere in ordinary message text does not make
This setting changes the Further Info display only. Messages are neither discarded nor removed from the other message tables, and the filter can be changed at any time for the currently selected station.
When the lower divider leaves the Further Info area very short, its contents can be scrolled vertically. The message table keeps a compact scroll area of its own, while the sked, NOT-QRV, map, AirScout, antenna and lookup actions remain reachable. Empty tables retain their column structure and show **No entries**.
A horizontal divider inside Further Info divides the available height between the message table and station actions. Moving it immediately makes the table taller or shorter. NOT-QRV and Sked use their own vertical scroll region in the lower area, while **Look up and show** remains pinned below it. The divider position is stored with the active root or operator profile's layout and is clamped to a usable value in a smaller window. Empty tables retain their column structure and show **No entries**.
The station header combines the callsign, chat category, QTF, QRB, last activity and detected bands in a compact flow. It does not repeat the locator; the locator remains available in the other station views. In a narrow panel, the metadata wraps onto additional lines without clipping. **Show messages** sits directly in front of the four message filters and flows with them.
@@ -312,7 +316,13 @@ The complete derivation and limitations are described under [Skeds and Sked Remi
## Priority List
The compact priority bar is located on the right-hand side between the user list and the Further Info section. It displays the two currently highest-ranked candidates directly in the main window:
The compact bar is located on the right-hand side between the user list and the Further Info section. The **New joined** group appears on the left when needed, while the independent **Priority** group remains on the right.
**New joined** shows actual chat joins for ten minutes, newest first. Up to five stations are displayed directly when space permits; additional entries, or entries moved out at a narrow width, remain available in the compact **more** dropdown. The initial user list, a complete resynchronisation, profile-only updates and the local callsign do not create entries. A station is removed immediately when it leaves. A later real rejoin starts a new ten-minute period. Complete callsign and chat category remain separate identities.
Clicking an entry uses the shared station selection, updates Further Info and scrolls the user list to the station when necessary.
The **Priority** group displays the two currently highest-ranked candidates directly in the main window:
```text
Priority: 1 CALLSIGN SCORE 2 CALLSIGN SCORE more
@@ -355,8 +365,12 @@ The map uses the stations which remain visible after applying the current user-l
![Station map with a selected station and visible path analysis](station_map_path_analysis.png)
> **Screenshot note:** This image still shows the previous marker label and neither the new context menu nor the Worked-square overlay. It will be replaced after manual map acceptance.
### Selecting a Station
A marker displays only its callsign as visible text. Locator, detected bands, `B+` state and the most recently known QRGs remain available in its tooltip.
A single station marker can be selected directly. KST4Contest then:
1. selects the corresponding chat member,
@@ -372,6 +386,12 @@ The change takes effect immediately without changing the current zoom, viewport
This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting.
Right-clicking a marker or a row in the user list opens the same station context menu. It contains **Message snippets**, **Create sked…**, band-specific **Tag NOT QRV** and **Mark as worked** actions, and **Calculate reachability**. When a marker represents several complete callsigns or chat categories, the concrete target must be chosen first. No action is applied arbitrarily to the first entry.
The snippet action only extends the send field and does not send by itself. The sked editor keeps the target captured when it opened; a later selection change does not alter that draft. **Mark as worked** uses the same additive Worked and database path as a received logger `contactinfo` and never removes an existing mark.
**Calculate reachability** explicitly starts the existing path calculation for the chosen band. When invoked from the user list, KST4Contest opens the map and displays the loading state, result or error for that exact target.
For a selected station, the header additionally shows:
- the complete callsign,
@@ -383,7 +403,9 @@ For a selected station, the header additionally shows:
Long header content is shortened. The complete text remains available in its tooltip.
**Reset filters** invokes the same action as the button in the user list. It clears all enabled station filters, callsign search, direction filters and grid colouring. Sorting, station selection, zoom and viewport remain unchanged. **Always on top** affects only the map window, is applied immediately and is saved automatically for the operator profile. It is disabled when no value has been stored.
**Reset filters** invokes the same action as the button in the user list. It clears all enabled station filters, callsign search and direction filters. The display-only **Grid color** option remains unchanged, as do sorting, station selection, zoom and viewport. **Always on top** affects only the map window, is applied immediately and is saved automatically for the operator profile. It is disabled when no value has been stored.
While **Grid color** is enabled, the map marks persistently known, already worked four-character Maidenhead squares with a black outline and a 30 percent black fill overlay. The overlay uses the same persistent Worked-grid state as **Only new grids**, so a square remains visible even when it currently contains no chat station. Neither outline nor fill overlay is drawn while **Grid color** is disabled.
### Clearing the Selection with Reset View
@@ -528,6 +550,8 @@ Functions which communicate with the server are available only after the ON4KST
KST4Contest automatically stores programme-window sizes and positions, relevant dividers and manually changed table-column widths in the configuration file after a short delay. Any pending layout update is written when the programme exits. **Save Settings** is not required for these changes, but still stores the complete current state, including the layout.
This includes the horizontal divider between the message table and the station actions in Further Info. Its value belongs to the active root or operator profile; when restoring it, KST4Contest clamps the value to the height available in the current window.
The main window is additionally checked against the visible area of the primary screen during startup. If the stored size is too large, KST4Contest reduces and moves the window so that it remains accessible. The complete process is described under [Screen-Aware Main Window Sizing](en-Features#screen-aware-main-window-sizing-from-v141).
The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, correcting it manually is sufficient; the new size is saved automatically.