diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index 43ae07e8..e8e03315 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -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 diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 6b852532..0795425b 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -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 diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 96caa031..2d7f21b9 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -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> composeMemberListener; + private java.util.function.Consumer> composeRecentJoinListener; private java.util.function.Consumer> composeChatListener; private java.util.function.Consumer> 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 variants = chatcontroller.getLst_chatMemberList().snapshot() + .stream() + .filter(candidate -> candidate != null + && java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw())) + .toList(); + java.util.EnumSet 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 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 offsets = parseMinuteOffsets(ssState.getReminderOffsets()); + if (request.getRemindPm()) { + List 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; diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt index c6f63dea..a335ca60 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt @@ -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 DataTableView( state: DataTableState, modifier: Modifier = Modifier, rowStyle: ((T) -> CellAccent?)? = null, + emphasizeCell: ((T, DataColumn) -> Boolean)? = null, onRowClick: ((T) -> Unit)? = null, + onRowContextMenu: ((T, IntOffset) -> Unit)? = null, selectOnRowClick: Boolean = true, ) { val density = LocalDensity.current @@ -321,6 +331,9 @@ fun DataTableView( val accent = rowStyle?.invoke(row) val isEven = index % 2 == 0 val _forceRedraw = state.contentRevision + var rowCoordinates by remember(state.keyOf(row)) { + mutableStateOf(null) + } Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -341,6 +354,20 @@ fun 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 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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt index b34509d5..26015138 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt @@ -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 diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt index 4c43d07e..d34b8492 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -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) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt index 995d13c7..af5a9fdb 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -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) = { 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 } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt new file mode 100644 index 00000000..3bf2b2cb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt @@ -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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt new file mode 100644 index 00000000..9bda87a3 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt @@ -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 = emptyList() + private var activeMembers: List = emptyList() + private var liveEntries by mutableStateOf>(emptyList()) + + val entries: List + get() = liveEntries + + val visibleEntries: List + get() = liveEntries.take(SHOWN) + + val overflowEntries: List + get() = liveEntries.drop(SHOWN) + + fun updateEvents(newEvents: List) { + events = newEvents.toList() + refresh() + } + + fun updateActiveMembers(newMembers: List) { + 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() + 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt index 7869cd70..cffb8905 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -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): List { val options = ArrayList(activeBands.size) @@ -67,6 +81,7 @@ fun SelectedStationPanel( messages: DataTableState, /** My own station's enabled bands; used for NOT-QRV and sked controls. */ activeBands: Set, + contentSplitter: SplitterState? = null, openInBrowser: Consumer, 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, + openInBrowser: Consumer, + 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) { 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, + 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 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) { + 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, ) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt index 1780488a..8e90427a 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt @@ -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. * diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt new file mode 100644 index 00000000..c3f3b2a2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt @@ -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()) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt index b6e1bed0..a233bf3e 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt @@ -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 } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt new file mode 100644 index 00000000..f179fb9e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt @@ -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(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)), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt new file mode 100644 index 00000000..40ddbd93 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt @@ -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 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, + 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 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt index e5400065..c21c995f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -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() + } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt index 3daf3c33..3b59b5f0 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt @@ -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 { current = row lastSource = source selectionRevision++ - if (row != null) { + if (row != null && source.requestsStationTableScroll()) { scrollRequestRevision++ } } @@ -114,3 +117,19 @@ class StationSelectionState : TableSelectionModel { } } } + +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 +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt index 9e81f338..448f8be3 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt @@ -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, @@ -106,6 +113,8 @@ fun ComposeStationMap( antennaBeamWidthDeg: Double, maxQrbKm: Double, onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?, + onMarkerContextRequested: ((List, IntOffset) -> Unit)? = null, + workedGrossFields: Set = 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(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, +) { + 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, +): List { + 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) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt index f4da10fe..8199b4de 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -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(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. */ diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt index 5666414e..4d745a27 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt @@ -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) + } } /** diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt new file mode 100644 index 00000000..2fec3251 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt @@ -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", + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt new file mode 100644 index 00000000..73ef2776 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt @@ -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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt new file mode 100644 index 00000000..d0e97c8f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt @@ -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) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt index ee779978..68e277ae 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt @@ -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)) diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt new file mode 100644 index 00000000..1fe14f52 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt @@ -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() + 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() + 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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt index 02ad644f..998b7da8 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt @@ -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) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt new file mode 100644 index 00000000..cd131d44 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt @@ -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>() + 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>() + 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>(), 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) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt index 0e8daa7e..e49337bb 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt @@ -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") + } + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt index a32496b1..08c2142a 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt @@ -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, 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() - 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>() + + 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>() + 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) + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt index d8590852..138d3a2b 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt @@ -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() diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt new file mode 100644 index 00000000..b4a3547e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt @@ -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(), + ) + + 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) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt index 6f5932b4..544d8fc7 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt @@ -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 = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)), ownLocator6: String = "", + workedGrossFields: Set = 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. * diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt index 95a3bdcf..a20a37de 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt @@ -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. diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt new file mode 100644 index 00000000..51c4900f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt @@ -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()) + } +} diff --git a/core/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java index 76dc7bcb..a428777b 100644 --- a/core/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -1648,6 +1648,7 @@ private SimpleRoster * lst_chatMemberList is this very field. */ private final SimpleRoster chatMemberList = new SimpleRoster<>(); // List of active stations + private final SimpleRoster recentChatJoins = new SimpleRoster<>(); private final SimpleRoster> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); private final SimpleRoster lst_clusterMemberList = new SimpleRoster<>(); @@ -1803,6 +1804,20 @@ private SimpleRoster * 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 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 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 } 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 */ public void clearActiveChatMembers() { activeChatMembersByCallAndCategory.clear(); + recentChatJoins.clear(); runOnFxThread(chatMemberList::clear); } @@ -1867,6 +1898,8 @@ private SimpleRoster } int categoryNumber = category.getCategoryNumber(); + recentChatJoins.mutate(joins -> joins.removeIf(join -> + join.getCategoryNumber() == categoryNumber)); List safeMembers = completeMembers == null ? List.of() : new ArrayList<>(completeMembers); Map workedDataFromDatabase = @@ -1992,8 +2025,8 @@ private SimpleRoster /** * 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 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 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 getRecentChatJoins() { + return recentChatJoins; + } + public ObservableRoster getLst_DBBasedWkdCallSignList() { return lst_DBBasedWkdCallSignList; } diff --git a/core/src/main/java/kst4contest/controller/LoggedQsoBand.java b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java index 29cb39d2..d225cc64 100644 --- a/core/src/main/java/kst4contest/controller/LoggedQsoBand.java +++ b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java @@ -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. * diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index c17f82d8..5949ca8a 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -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); diff --git a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java index 719fe0b4..38f904e8 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -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()); diff --git a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index 0b15c825..c167ec46 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -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()); diff --git a/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java index a44dea82..fe058165 100644 --- a/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java +++ b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java @@ -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 snapshotWorkedGrossFieldsAny() { + Set snapshot = new HashSet<>(); + for (Set 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". @@ -199,4 +215,4 @@ public final class WorkedGrossFieldCache { String normalized = grossField.trim().toUpperCase(Locale.ROOT); return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null; } -} \ No newline at end of file +} diff --git a/core/src/main/java/kst4contest/model/RecentChatJoin.java b/core/src/main/java/kst4contest/model/RecentChatJoin.java new file mode 100644 index 00000000..b2a05665 --- /dev/null +++ b/core/src/main/java/kst4contest/model/RecentChatJoin.java @@ -0,0 +1,48 @@ +package kst4contest.model; + +import java.util.Locale; +import java.util.Objects; + +/** + * One live ON4KST join event. + * + *

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.

+ */ +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()); + } +} diff --git a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java index a2f84da1..1931354b 100644 --- a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java +++ b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java @@ -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() { @@ -77,4 +97,4 @@ public record MapCallsignRawSnapshot( } return joiner.toString(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java index 401f32f5..17275a3c 100644 --- a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java +++ b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java @@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip { public static String build(MapMarkerCluster cluster) { List 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()); } diff --git a/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java b/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java new file mode 100644 index 00000000..07faf3b6 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java @@ -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; + } +} diff --git a/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java b/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java new file mode 100644 index 00000000..f6075644 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java @@ -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 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 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; + } +} diff --git a/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java b/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java new file mode 100644 index 00000000..7b1650cc --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java @@ -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; + } +} diff --git a/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java index bfe85811..ea90a27e 100644 --- a/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java +++ b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java @@ -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()); diff --git a/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java index e1fc723d..8b8c6d00 100644 --- a/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java +++ b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java @@ -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(); } } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java index 1699465d..b25c8214 100644 --- a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java @@ -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 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 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); } diff --git a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java index 76cc0f06..63c900e1 100644 --- a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java +++ b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java @@ -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 { + ""); ArgumentCaptor 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 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 { + "DXLog432SP2XYZ" + "042JO93AB"); - verify(controller).registerWorkedGrossField( - eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG")); - verify(database).updateWkdInfoOnChatMember(any()); - verify(database, never()).storeChatMember(any()); + ArgumentCaptor 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("144OK1ABC"); - verify(controller, never()).applyExternalLoggedQso(any()); + verify(controller, never()).recordLoggedQso(any(), any()); verify(database, never()).updateWkdInfoOnChatMember(any()); } @@ -229,8 +217,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { sendThenAwaitMarker("144OK1ABC" + "JO70FD"); - 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("144OK1ABC"); @@ -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("144OK1ABC"); diff --git a/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java b/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java new file mode 100644 index 00000000..11ca2a87 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java @@ -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 snapshot = cache.snapshotWorkedGrossFieldsAny(); + + assertEquals(Set.of("JO51", "JN49"), snapshot); + assertThrows(UnsupportedOperationException.class, () -> snapshot.add("JO50")); + } +} diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java index f46d9f46..6fa3de55 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java @@ -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()); } diff --git a/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java b/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java new file mode 100644 index 00000000..d4126995 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java @@ -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 + ); + } +} diff --git a/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java index 859996e9..5274c10c 100644 --- a/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java +++ b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java @@ -51,9 +51,10 @@ class MapMarkerClusterTooltipTest { List 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. */ diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 0ca1b489..c51a357e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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 diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index b5b8caaa..4ff6ca13 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -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. diff --git a/github_docs/en-User-Interface.md b/github_docs/en-User-Interface.md index 0a0eee66..6b93c01f 100644 --- a/github_docs/en-User-Interface.md +++ b/github_docs/en-User-Interface.md @@ -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.