mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-25 15:45:58 +01:00
fix: complete Compose UI workflow corrections
This commit is contained in:
1 parent
971fb35109
commit
4be6c178f8
57 files changed
+3084
-465
No files matched your search
@@ -256,9 +256,14 @@ filter.onlyNewGrids=Nur neue Felder
|
||||
filter.gridColour=Feldfarbe
|
||||
filter.newBands=Neue Bänder
|
||||
filter.tropo=Tropo >=0dB
|
||||
filter.hide=Ausblenden
|
||||
filter.show=Anzeigen
|
||||
filter.reachability=Erreichbarkeit
|
||||
filter.calcSelected=Auswahl berechnen
|
||||
table.empty=Keine Einträge
|
||||
selected.none=Keine Station gewählt.
|
||||
selected.priority=Priorität:
|
||||
selected.newJoined=Neu hinzugekommen:
|
||||
selected.more=mehr
|
||||
selected.showMessages=Nachrichten zeigen
|
||||
selected.createSked=Sked anlegen
|
||||
@@ -430,6 +435,16 @@ dialog.apply=Übernehmen
|
||||
chat.clear=leeren
|
||||
selected.showOnMap=🗺 Auf der Karte zeigen
|
||||
selected.notQrvAll=alle
|
||||
context.messageSnippets=Textbausteine
|
||||
context.createSked=Sked erstellen…
|
||||
context.tagNotQrv=Als NOT QRV markieren
|
||||
context.markWorked=Als gearbeitet markieren
|
||||
context.calculateReachability=Erreichbarkeit berechnen
|
||||
context.chooseStation=Station auswählen
|
||||
context.back=Zurück
|
||||
context.category=Kategorie {0}
|
||||
context.skedTitle=Sked für {0} erstellen
|
||||
context.cancel=Abbrechen
|
||||
column.category=Kategorie
|
||||
column.sender=Absender
|
||||
column.receiver=Empfänger
|
||||
|
||||
@@ -265,9 +265,14 @@ filter.onlyNewGrids=Only new grids
|
||||
filter.gridColour=Grid color
|
||||
filter.newBands=New bands
|
||||
filter.tropo=Tropo >=0dB
|
||||
filter.hide=Hide
|
||||
filter.show=Show
|
||||
filter.reachability=Reachability
|
||||
filter.calcSelected=Calc selected
|
||||
table.empty=No entries
|
||||
selected.none=No station selected.
|
||||
selected.priority=Priority:
|
||||
selected.newJoined=New joined:
|
||||
selected.more=more
|
||||
selected.showMessages=Show messages
|
||||
selected.createSked=Create sked
|
||||
@@ -439,6 +444,16 @@ dialog.apply=Apply
|
||||
chat.clear=clear
|
||||
selected.showOnMap=🗺 Show on map
|
||||
selected.notQrvAll=all
|
||||
context.messageSnippets=Message snippets
|
||||
context.createSked=Create sked…
|
||||
context.tagNotQrv=Tag NOT QRV
|
||||
context.markWorked=Mark as worked
|
||||
context.calculateReachability=Calculate reachability
|
||||
context.chooseStation=Choose station
|
||||
context.back=Back
|
||||
context.category=Category {0}
|
||||
context.skedTitle=Create sked for {0}
|
||||
context.cancel=Cancel
|
||||
column.category=Category
|
||||
column.sender=Sender
|
||||
column.receiver=Receiver
|
||||
|
||||
@@ -27,6 +27,8 @@ import kst4contest.view.compose.StationSelectionSource;
|
||||
import kst4contest.view.compose.StationSelectionState;
|
||||
import kst4contest.view.compose.TopPriorityCandidatesWindow;
|
||||
import kst4contest.view.compose.TopPriorityState;
|
||||
import kst4contest.view.compose.NewJoinedState;
|
||||
import kst4contest.view.compose.SkedRequest;
|
||||
import kst4contest.view.compose.TimelineState;
|
||||
import kst4contest.view.compose.ChatInputState;
|
||||
import kst4contest.view.compose.MainMenuState;
|
||||
@@ -83,6 +85,9 @@ import kst4contest.controller.OperatorProfilePaths;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
import kst4contest.view.map.OfflineDemImportService;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot;
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
|
||||
import kst4contest.view.map.PathAnalysisResult;
|
||||
import kst4contest.controller.WorkedGrossFieldCache;
|
||||
|
||||
|
||||
@@ -3023,6 +3028,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
*/
|
||||
private kst4contest.view.i18n.LanguageStore languageStore;
|
||||
private java.util.function.Consumer<java.util.List<ChatMember>> composeMemberListener;
|
||||
private java.util.function.Consumer<java.util.List<RecentChatJoin>> composeRecentJoinListener;
|
||||
private java.util.function.Consumer<java.util.List<ChatMessage>> composeChatListener;
|
||||
private java.util.function.Consumer<java.util.List<ClusterMessage>> composeClusterListener;
|
||||
|
||||
@@ -3182,6 +3188,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
},
|
||||
surroundings::getSecondAntennaOffered,
|
||||
stationSelection);
|
||||
selectedStation.setSkedRequestSubmitter(this::createSkedFromCompose);
|
||||
|
||||
selectedStation.setMessageFilter(
|
||||
SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences()));
|
||||
@@ -3189,6 +3196,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
TopPriorityState topPriority = new TopPriorityState(
|
||||
this::composeTopStations,
|
||||
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY));
|
||||
NewJoinedState newJoined = new NewJoinedState(
|
||||
System::currentTimeMillis,
|
||||
member -> selectStationInCompose(
|
||||
selectedStation, member, StationSelectionSource.NEW_JOINED));
|
||||
|
||||
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
|
||||
|
||||
@@ -3241,6 +3252,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}),
|
||||
new SplitterState(
|
||||
MainWindowState.SELECTED_STATION_PANE_COUNT,
|
||||
chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition(),
|
||||
SplitterState.DEFAULT_MIN_PANE_PX,
|
||||
positions -> {
|
||||
chatcontroller.getChatPreferences()
|
||||
.setGUIselectedCallSignSplitPane_dividerposition(positions);
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}),
|
||||
newJoined,
|
||||
reachability);
|
||||
|
||||
/*
|
||||
@@ -3269,8 +3291,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
* Subscribed first and seeded afterwards, so a message arriving in between is not
|
||||
* missed — the order RosterListBinding uses for the same reason.
|
||||
*/
|
||||
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
|
||||
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> {
|
||||
stations.replaceRows(rows);
|
||||
newJoined.updateActiveMembers(rows);
|
||||
});
|
||||
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
|
||||
composeRecentJoinListener = rows -> uiDispatcher.runOnUi(() -> newJoined.updateEvents(rows));
|
||||
chatcontroller.getRecentChatJoins().addListener(composeRecentJoinListener);
|
||||
|
||||
composeMainWindowState.setStationSelector((member, source) -> {
|
||||
selectStationInCompose(selectedStation, member, source);
|
||||
@@ -3284,6 +3311,36 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
composeMainWindowState.setMarkStationWorked(
|
||||
(member, band) -> chatcontroller.markStationWorkedOnBand(member, band));
|
||||
composeMainWindowState.setWorkedGrossFieldsProvider(
|
||||
() -> chatcontroller.getWorkedGrossFieldCache().snapshotWorkedGrossFieldsAny());
|
||||
composeMainWindowState.getMapReachability().setRequester((member, band, callback) -> {
|
||||
java.util.List<ChatMember> variants = chatcontroller.getLst_chatMemberList().snapshot()
|
||||
.stream()
|
||||
.filter(candidate -> candidate != null
|
||||
&& java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw()))
|
||||
.toList();
|
||||
java.util.EnumSet<Band> bands = java.util.EnumSet.noneOf(Band.class);
|
||||
bands.addAll(enabledStationBands);
|
||||
MapCallsignRawSnapshot snapshot = new MapCallsignRawSnapshotBuilder()
|
||||
.buildSnapshots(variants, member, bands)
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (snapshot == null) {
|
||||
callback.invoke(PathAnalysisResult.waitingForValidTarget(
|
||||
chatcontroller.getChatPreferences().getStn_loginLocatorMainCat(),
|
||||
member.getQra()));
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
chatcontroller.getReachabilityService().requestPathAnalysisForMap(
|
||||
member,
|
||||
snapshot,
|
||||
band,
|
||||
result -> callback.invoke(result));
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
|
||||
composeChatListener = rows -> uiDispatcher.runOnUi(() -> {
|
||||
directed.replaceRows(chatcontroller.toMeMessages());
|
||||
@@ -3300,7 +3357,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
}
|
||||
uiDispatcher.runOnUi(() -> topPriority.refresh());
|
||||
|
||||
stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot());
|
||||
java.util.List<ChatMember> initialMembers = chatcontroller.getLst_chatMemberList().snapshot();
|
||||
stations.replaceRows(initialMembers);
|
||||
newJoined.updateActiveMembers(initialMembers);
|
||||
newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot());
|
||||
directed.replaceRows(chatcontroller.toMeMessages());
|
||||
publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
|
||||
cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
|
||||
@@ -3410,6 +3470,29 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
final int minutes,
|
||||
final Band band,
|
||||
final String mode) {
|
||||
boolean remindPm = false;
|
||||
String reminderOffsets = SelectedStationState.Companion.getREMINDER_OFFSETS().get(0);
|
||||
if (composeMainWindowState != null) {
|
||||
SelectedStationState state = composeMainWindowState.getSelectedStation();
|
||||
remindPm = state.getRemindPm();
|
||||
reminderOffsets = state.getReminderOffsets();
|
||||
}
|
||||
return createSkedFromCompose(new SkedRequest(
|
||||
selectedMember,
|
||||
minutes,
|
||||
band,
|
||||
mode,
|
||||
remindPm,
|
||||
reminderOffsets));
|
||||
}
|
||||
|
||||
private boolean createSkedFromCompose(final SkedRequest request) {
|
||||
if (request == null) {
|
||||
return false;
|
||||
}
|
||||
final ChatMember selectedMember = request.getTarget();
|
||||
final int minutes = request.getMinutes();
|
||||
final Band band = request.getBand();
|
||||
|
||||
if (selectedMember == null || band == null) {
|
||||
return false;
|
||||
@@ -3431,17 +3514,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
chatcontroller.getScoreService().requestRecompute("sked-created");
|
||||
|
||||
// Arm PM reminders, exactly as the JavaFX Create sked handler does
|
||||
if (composeMainWindowState != null) {
|
||||
SelectedStationState ssState = composeMainWindowState.getSelectedStation();
|
||||
if (ssState.getRemindPm()) {
|
||||
List<Integer> offsets = parseMinuteOffsets(ssState.getReminderOffsets());
|
||||
if (request.getRemindPm()) {
|
||||
List<Integer> offsets = parseMinuteOffsets(request.getReminderOffsets());
|
||||
chatcontroller.getSkedReminderService().armReminders(
|
||||
sked.getTargetChatCallsign(),
|
||||
sked.getTargetChatCategory(),
|
||||
skedTime,
|
||||
offsets
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -3624,6 +3704,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
|
||||
composeMemberListener = null;
|
||||
}
|
||||
if (composeRecentJoinListener != null) {
|
||||
chatcontroller.getRecentChatJoins().removeListener(composeRecentJoinListener);
|
||||
composeRecentJoinListener = null;
|
||||
}
|
||||
if (composeChatListener != null) {
|
||||
chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
|
||||
composeChatListener = null;
|
||||
|
||||
@@ -29,6 +29,9 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.awt.Cursor
|
||||
@@ -43,14 +46,19 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A multi-column table.
|
||||
@@ -72,13 +80,15 @@ import kst4contest.view.i18n.LocalStrings
|
||||
* the supported way to reach the hover text the JavaFX cells had, and losing that
|
||||
* loses the only way to read a value that does not fit its column.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun <T> DataTableView(
|
||||
state: DataTableState<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
rowStyle: ((T) -> CellAccent?)? = null,
|
||||
emphasizeCell: ((T, DataColumn<T>) -> Boolean)? = null,
|
||||
onRowClick: ((T) -> Unit)? = null,
|
||||
onRowContextMenu: ((T, IntOffset) -> Unit)? = null,
|
||||
selectOnRowClick: Boolean = true,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -321,6 +331,9 @@ fun <T> DataTableView(
|
||||
val accent = rowStyle?.invoke(row)
|
||||
val isEven = index % 2 == 0
|
||||
val _forceRedraw = state.contentRevision
|
||||
var rowCoordinates by remember(state.keyOf(row)) {
|
||||
mutableStateOf<LayoutCoordinates?>(null)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
@@ -341,6 +354,20 @@ fun <T> DataTableView(
|
||||
}
|
||||
onRowClick?.invoke(row)
|
||||
}
|
||||
.onGloballyPositioned { rowCoordinates = it }
|
||||
.onPointerEvent(PointerEventType.Press) { event ->
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
val local = event.changes.firstOrNull()?.position
|
||||
val window = local?.let { rowCoordinates?.localToWindow(it) }
|
||||
if (window != null) {
|
||||
onRowContextMenu?.invoke(
|
||||
row,
|
||||
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
|
||||
)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -377,6 +404,11 @@ fun <T> DataTableView(
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (emphasizeCell?.invoke(row, column) == true) {
|
||||
FontWeight.Bold
|
||||
} else {
|
||||
null
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = cell?.foreground
|
||||
|
||||
@@ -38,11 +38,11 @@ internal object Density {
|
||||
/** Two-letter compass labels need only a small inset inside their fixed width. */
|
||||
val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp
|
||||
|
||||
/** A compact, independently scrollable message table in the selected-station pane. */
|
||||
val SELECTED_MESSAGE_TABLE_HEIGHT = 104.dp
|
||||
|
||||
/** Enough room for the NOT-QRV and sked groups to remain usable side by side. */
|
||||
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 520.dp
|
||||
val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 450.dp
|
||||
|
||||
/** Keeps the station actions usable when a stored splitter position is too low. */
|
||||
val SELECTED_ACTION_AREA_MIN_HEIGHT = 160.dp
|
||||
|
||||
val TAB_MIN_HEIGHT = 30.dp
|
||||
|
||||
|
||||
@@ -246,6 +246,10 @@ private fun StationColumn(
|
||||
onFiltersChanged: () -> Unit,
|
||||
onShowMorePriorities: () -> Unit,
|
||||
) {
|
||||
val stationContextMenu = remember(state) {
|
||||
StationContextMenuState(state::selectStation)
|
||||
}
|
||||
|
||||
SplitterPane(
|
||||
state = state.rightSplitter,
|
||||
orientation = SplitterOrientation.VERTICAL,
|
||||
@@ -264,19 +268,28 @@ private fun StationColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
DataTableView(
|
||||
state = state.stations,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = onStationSelected,
|
||||
selectOnRowClick = false,
|
||||
)
|
||||
}
|
||||
DataTableView(
|
||||
state = state.stations,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
emphasizeCell = { member, column ->
|
||||
column.id == "callsign" && state.newJoined.isRecent(member)
|
||||
},
|
||||
onRowClick = onStationSelected,
|
||||
onRowContextMenu = { member, position ->
|
||||
stationContextMenu.open(
|
||||
listOf(member),
|
||||
StationSelectionSource.STATION_CONTEXT_MENU,
|
||||
position,
|
||||
)
|
||||
},
|
||||
selectOnRowClick = false,
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
TopPriorityBar(
|
||||
state = state.topPriority,
|
||||
newJoined = state.newJoined,
|
||||
onShowMore = onShowMorePriorities,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -286,6 +299,7 @@ private fun StationColumn(
|
||||
state = state.selectedStation,
|
||||
messages = state.selectedStationMessages,
|
||||
activeBands = state.activeBands,
|
||||
contentSplitter = state.selectedStationSplitter,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
onShowPathInAirScout = onShowPathInAirScout,
|
||||
@@ -294,4 +308,5 @@ private fun StationColumn(
|
||||
},
|
||||
),
|
||||
)
|
||||
StationContextOverlays(stationContextMenu, state, onShowOnMap)
|
||||
}
|
||||
@@ -55,13 +55,21 @@ class MainWindowState(
|
||||
val bandUpgradeNotice: BlinkingNotice,
|
||||
val threadButtons: ThreadStatusButtons,
|
||||
|
||||
/** The three splitters, whose positions the operator has arranged and stored. */
|
||||
/** The splitters whose positions the operator has arranged and stored. */
|
||||
val outerSplitter: SplitterState,
|
||||
val messageSplitter: SplitterState,
|
||||
val rightSplitter: SplitterState,
|
||||
val selectedStationSplitter: SplitterState = SplitterState(
|
||||
SELECTED_STATION_PANE_COUNT,
|
||||
doubleArrayOf(0.55),
|
||||
) {},
|
||||
val newJoined: NewJoinedState = NewJoinedState(onSelect = {}),
|
||||
val reachability: ReachabilityState = ReachabilityState(activeBands),
|
||||
) {
|
||||
|
||||
/** Explicit map analysis requested from either station context menu. */
|
||||
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
|
||||
|
||||
/** The shortcut buttons above the send line. */
|
||||
/**
|
||||
* The one path that selects a station for the whole application (panel, table, score
|
||||
@@ -76,11 +84,18 @@ class MainWindowState(
|
||||
/** Debounced persistence hook shared by the main and map windows. */
|
||||
var layoutSaveRequested: (() -> Unit)? = null
|
||||
|
||||
/** Contactinfo-equivalent manual Worked action supplied by the controller host. */
|
||||
var markStationWorked: ((ChatMember, kst4contest.model.Band) -> Boolean)? = null
|
||||
|
||||
/** Persistent any-band Worked gross fields used by the optional map overlay. */
|
||||
var workedGrossFieldsProvider: (() -> Set<String>) = { emptySet() }
|
||||
|
||||
val stationSelection: StationSelectionState
|
||||
get() = selectedStation.stationSelection
|
||||
|
||||
/** Routes every explicit station choice through the application-level action. */
|
||||
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
|
||||
mapReachability.selectionChanged(StationIdentity.of(member))
|
||||
val selector = stationSelector
|
||||
if (selector != null) {
|
||||
selector(member, source)
|
||||
@@ -132,6 +147,9 @@ class MainWindowState(
|
||||
|
||||
/** Station list, top priorities, selected station. */
|
||||
const val RIGHT_PANE_COUNT = 3
|
||||
|
||||
/** Selected-station message table and its scrollable action area. */
|
||||
const val SELECTED_STATION_PANE_COUNT = 2
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
|
||||
/**
|
||||
* Explicit map reachability request shared by station-list and map context menus.
|
||||
* A monotonically increasing request ID prevents older callbacks from replacing a
|
||||
* result for a newer station or band.
|
||||
*/
|
||||
class MapReachabilityState(
|
||||
private val ownLocator: String,
|
||||
requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> },
|
||||
) {
|
||||
var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester
|
||||
|
||||
var result: PathAnalysisResult? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var running: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var requestRevision: Long by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
var targetIdentity: StationIdentity? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var requestedBand: Band? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun calculate(member: ChatMember?, band: Band?): Boolean {
|
||||
if (member == null || band == null) return false
|
||||
val identity = StationIdentity.of(member) ?: return false
|
||||
val requestId = ++requestRevision
|
||||
targetIdentity = identity
|
||||
requestedBand = band
|
||||
running = true
|
||||
result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw)
|
||||
|
||||
try {
|
||||
requester(member, band) { completed ->
|
||||
if (requestRevision == requestId
|
||||
&& targetIdentity == identity
|
||||
&& requestedBand == band
|
||||
) {
|
||||
result = completed
|
||||
running = false
|
||||
}
|
||||
}
|
||||
} catch (exception: RuntimeException) {
|
||||
if (requestRevision == requestId) {
|
||||
result = PathAnalysisResult.noProfile(
|
||||
"Failed",
|
||||
ownLocator,
|
||||
member.qra,
|
||||
member.callSignRaw,
|
||||
Double.NaN,
|
||||
member.getQTFdirection() ?: Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
band.defaultAnalysisFrequencyMHz,
|
||||
exception.message ?: "Reachability calculation failed.",
|
||||
)
|
||||
running = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun selectionChanged(identity: StationIdentity?) {
|
||||
if (targetIdentity == null || targetIdentity == identity) return
|
||||
requestRevision++
|
||||
targetIdentity = null
|
||||
requestedBand = null
|
||||
result = null
|
||||
running = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
|
||||
/** One selectable station in the transient New joined group. */
|
||||
class NewJoinedEntry internal constructor(
|
||||
val identity: StationIdentity,
|
||||
val label: String,
|
||||
val joinedAtEpochMillis: Long,
|
||||
private val member: ChatMember,
|
||||
private val onSelect: (ChatMember) -> Unit,
|
||||
) {
|
||||
fun select() = onSelect(member)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose-facing view of live ON4KST join events.
|
||||
*
|
||||
* Initial and resynchronized user lists never reach this state. It receives the core's
|
||||
* live-event roster and intersects it with the currently active station roster, so a
|
||||
* departed station disappears immediately. The clock is injected to make expiry exact
|
||||
* and testable without waiting ten minutes.
|
||||
*/
|
||||
class NewJoinedState(
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
private val onSelect: (ChatMember) -> Unit,
|
||||
) {
|
||||
|
||||
private var events: List<RecentChatJoin> = emptyList()
|
||||
private var activeMembers: List<ChatMember> = emptyList()
|
||||
private var liveEntries by mutableStateOf<List<NewJoinedEntry>>(emptyList())
|
||||
|
||||
val entries: List<NewJoinedEntry>
|
||||
get() = liveEntries
|
||||
|
||||
val visibleEntries: List<NewJoinedEntry>
|
||||
get() = liveEntries.take(SHOWN)
|
||||
|
||||
val overflowEntries: List<NewJoinedEntry>
|
||||
get() = liveEntries.drop(SHOWN)
|
||||
|
||||
fun updateEvents(newEvents: List<RecentChatJoin>) {
|
||||
events = newEvents.toList()
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun updateActiveMembers(newMembers: List<ChatMember>) {
|
||||
activeMembers = newMembers.toList()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Removes expired or departed entries and resolves each event to the latest row instance. */
|
||||
fun refresh() {
|
||||
val now = clock()
|
||||
val activeByIdentity = activeMembers.mapNotNull { member ->
|
||||
StationIdentity.of(member)?.let { identity -> identity to member }
|
||||
}.toMap()
|
||||
|
||||
val firstJoinByIdentity = linkedMapOf<StationIdentity, RecentChatJoin>()
|
||||
events.forEach { event ->
|
||||
val identity = StationIdentity.of(event.callSign, event.categoryNumber)
|
||||
?: return@forEach
|
||||
val existing = firstJoinByIdentity[identity]
|
||||
if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) {
|
||||
firstJoinByIdentity[identity] = event
|
||||
}
|
||||
}
|
||||
|
||||
liveEntries = firstJoinByIdentity
|
||||
.mapNotNull { (identity, event) ->
|
||||
val active = activeByIdentity[identity] ?: return@mapNotNull null
|
||||
val ageMillis = now - event.joinedAtEpochMillis
|
||||
if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
NewJoinedEntry(
|
||||
identity = identity,
|
||||
label = identity.callSign,
|
||||
joinedAtEpochMillis = event.joinedAtEpochMillis,
|
||||
member = active,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
.sortedByDescending(NewJoinedEntry::joinedAtEpochMillis)
|
||||
}
|
||||
|
||||
fun isRecent(member: ChatMember): Boolean {
|
||||
val identity = StationIdentity.of(member) ?: return false
|
||||
return liveEntries.any { it.identity == identity }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VALIDITY_MILLIS = 10L * 60L * 1000L
|
||||
const val SHOWN = 5
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -22,7 +24,14 @@ import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -37,10 +46,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import kst4contest.model.Band
|
||||
import java.util.function.Consumer
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
|
||||
internal const val SELECTED_MESSAGES_TABLE_TAG = "selected-messages-table"
|
||||
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
|
||||
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group"
|
||||
internal const val NEW_JOINED_MORE_TAG = "new-joined-more"
|
||||
internal const val NEW_JOINED_GROUP_TAG = "new-joined-group"
|
||||
internal const val PRIORITY_GROUP_TAG = "priority-group"
|
||||
|
||||
internal fun skedBandOptions(activeBands: Set<Band>): List<Band> {
|
||||
val options = ArrayList<Band>(activeBands.size)
|
||||
@@ -67,6 +81,7 @@ fun SelectedStationPanel(
|
||||
messages: DataTableState<kst4contest.model.ChatMessage>,
|
||||
/** My own station's enabled bands; used for NOT-QRV and sked controls. */
|
||||
activeBands: Set<Band>,
|
||||
contentSplitter: SplitterState? = null,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
@@ -74,8 +89,9 @@ fun SelectedStationPanel(
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
val member = state.selected
|
||||
|
||||
val scroll = rememberScrollState()
|
||||
val splitter = contentSplitter ?: remember {
|
||||
SplitterState(2, doubleArrayOf(DEFAULT_SELECTED_MESSAGE_FRACTION)) {}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -84,22 +100,60 @@ fun SelectedStationPanel(
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, top = 3.dp, end = Density.SCROLLBAR_GUTTER, bottom = 4.dp)
|
||||
.verticalScroll(scroll),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (member == null) {
|
||||
Text(
|
||||
strings.selectedNone,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(4.dp),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
SelectedStationHeaderAndFilter(state, member)
|
||||
|
||||
SplitterPane(
|
||||
state = splitter,
|
||||
orientation = SplitterOrientation.VERTICAL,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
minimumPaneSizes = listOf(
|
||||
Density.ROW_MIN_HEIGHT * 2,
|
||||
Density.SELECTED_ACTION_AREA_MIN_HEIGHT,
|
||||
),
|
||||
panes = listOf(
|
||||
{
|
||||
DataTableView(
|
||||
state = messages,
|
||||
modifier = Modifier.fillMaxSize().testTag(SELECTED_MESSAGES_TABLE_TAG),
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectedStationFunctions(
|
||||
state = state,
|
||||
activeBands = activeBands,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
onShowPathInAirScout = onShowPathInAirScout,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SelectedStationHeaderAndFilter(
|
||||
state: SelectedStationState,
|
||||
member: kst4contest.model.ChatMember,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
val detectedBands = state.detectedBands()
|
||||
val metadata = buildList {
|
||||
@@ -142,10 +196,11 @@ fun SelectedStationPanel(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
modifier = Modifier.height(Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
"${strings.selectedShowMessages}:",
|
||||
@@ -166,20 +221,51 @@ fun SelectedStationPanel(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DataTableView(
|
||||
state = messages,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(Density.SELECTED_MESSAGE_TABLE_HEIGHT),
|
||||
)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SelectedStationFunctions(
|
||||
state: SelectedStationState,
|
||||
activeBands: Set<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
val scroll = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = Density.SCROLLBAR_GUTTER)
|
||||
.verticalScroll(scroll),
|
||||
) {
|
||||
SelectedStationActionGroups(
|
||||
state = state,
|
||||
activeBands = activeBands,
|
||||
)
|
||||
}
|
||||
|
||||
SelectedStationActionGroups(
|
||||
state = state,
|
||||
activeBands = activeBands,
|
||||
)
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Form.section(strings.selectedLookUpAndShow) {
|
||||
CompactFunctionGroup(
|
||||
title = strings.selectedLookUpAndShow,
|
||||
modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER),
|
||||
) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout)
|
||||
Form.button(strings.selectedShowOnMap, onClick = onShowOnMap)
|
||||
@@ -202,18 +288,10 @@ fun SelectedStationPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_SELECTED_MESSAGE_FRACTION = 0.55
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SelectedStationActionGroups(
|
||||
@@ -294,7 +372,7 @@ private fun CompactFunctionGroup(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||
.padding(4.dp),
|
||||
.padding(3.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Text(
|
||||
@@ -310,6 +388,47 @@ private fun CompactFunctionGroup(
|
||||
@Composable
|
||||
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
|
||||
val strings = LocalStrings.current
|
||||
SkedEditorControls(
|
||||
activeBands = activeBands,
|
||||
minutes = state.skedMinutes,
|
||||
onMinutesChanged = { state.skedMinutes = it },
|
||||
band = state.skedBand,
|
||||
onBandChanged = { state.skedBand = it },
|
||||
mode = state.skedMode,
|
||||
onModeChanged = { state.skedMode = it },
|
||||
remindPm = state.remindPm,
|
||||
onRemindPmChanged = { state.remindPm = it },
|
||||
reminderOffsets = state.reminderOffsets,
|
||||
onReminderOffsetsChanged = { state.reminderOffsets = it },
|
||||
canCreate = state.canCreateSked,
|
||||
onCreate = { state.createSked() },
|
||||
additionalActions = {
|
||||
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
|
||||
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared compact sked editor used by the station panel and the context popup. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SkedEditorControls(
|
||||
activeBands: Set<Band>,
|
||||
minutes: Int,
|
||||
onMinutesChanged: (Int) -> Unit,
|
||||
band: Band?,
|
||||
onBandChanged: (Band) -> Unit,
|
||||
mode: String?,
|
||||
onModeChanged: (String) -> Unit,
|
||||
remindPm: Boolean,
|
||||
onRemindPmChanged: (Boolean) -> Unit,
|
||||
reminderOffsets: String,
|
||||
onReminderOffsetsChanged: (String) -> Unit,
|
||||
canCreate: Boolean,
|
||||
onCreate: () -> Unit,
|
||||
additionalActions: @Composable () -> Unit = {},
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -319,32 +438,32 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedSkedIn,
|
||||
items = SelectedStationState.SKED_MINUTES,
|
||||
selected = state.skedMinutes,
|
||||
selected = minutes,
|
||||
describe = { it.toString() },
|
||||
trailingText = strings.selectedSkedMinutesAt,
|
||||
onSelect = { state.skedMinutes = it },
|
||||
onSelect = onMinutesChanged,
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = skedBandOptions(activeBands),
|
||||
selected = state.skedBand,
|
||||
selected = band,
|
||||
describe = { it.prefix + " MHz" },
|
||||
trailingText = strings.selectedSkedModeLink,
|
||||
onSelect = { state.skedBand = it },
|
||||
onSelect = onBandChanged,
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = SelectedStationState.SKED_MODES,
|
||||
selected = state.skedMode,
|
||||
selected = mode,
|
||||
describe = { it },
|
||||
trailingText = ".",
|
||||
onSelect = { state.skedMode = it },
|
||||
onSelect = onModeChanged,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.height(Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
CompactCheckbox(state.remindPm) { state.remindPm = it }
|
||||
CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged)
|
||||
Text(
|
||||
strings.selectedRemindPmIn,
|
||||
style = compactControlTextStyle(),
|
||||
@@ -353,11 +472,11 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = SelectedStationState.REMINDER_OFFSETS,
|
||||
selected = state.reminderOffsets,
|
||||
selected = reminderOffsets,
|
||||
describe = { it },
|
||||
enabled = state.remindPm,
|
||||
enabled = remindPm,
|
||||
trailingText = strings.selectedMinutes,
|
||||
onSelect = { state.reminderOffsets = it },
|
||||
onSelect = onReminderOffsetsChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -367,9 +486,8 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() }
|
||||
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
|
||||
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
|
||||
Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate)
|
||||
additionalActions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,29 +498,111 @@ private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band
|
||||
@Composable
|
||||
fun TopPriorityBar(
|
||||
state: TopPriorityState,
|
||||
newJoined: NewJoinedState? = null,
|
||||
onShowMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
state.entries.forEach { entry ->
|
||||
Form.button(entry.label, onClick = entry.select)
|
||||
LaunchedEffect(newJoined) {
|
||||
while (newJoined != null) {
|
||||
delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS)
|
||||
newJoined.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Form.button(strings.selectedMore, onClick = onShowMore)
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val allJoinedEntries = newJoined?.entries.orEmpty()
|
||||
val directCount = when {
|
||||
maxWidth >= 520.dp -> NewJoinedState.SHOWN
|
||||
maxWidth >= 450.dp -> 4
|
||||
maxWidth >= 380.dp -> 3
|
||||
maxWidth >= 310.dp -> 2
|
||||
else -> 1
|
||||
}
|
||||
val joinedEntries = allJoinedEntries.take(directCount)
|
||||
val overflow = allJoinedEntries.drop(directCount)
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (joinedEntries.isNotEmpty()) {
|
||||
CandidateGroup(Modifier.testTag(NEW_JOINED_GROUP_TAG)) {
|
||||
Text(strings.selectedNewJoined, style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
joinedEntries.forEach { entry ->
|
||||
Form.button(entry.label, horizontalPadding = 4.dp, onClick = entry::select)
|
||||
}
|
||||
|
||||
if (overflow.isNotEmpty()) {
|
||||
NewJoinedOverflow(overflow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CandidateGroup(Modifier.testTag(PRIORITY_GROUP_TAG)) {
|
||||
Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
state.entries.forEach { entry ->
|
||||
Form.button(entry.label, onClick = entry.select)
|
||||
}
|
||||
|
||||
Form.button(strings.selectedMore, onClick = onShowMore)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CandidateGroup(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NewJoinedOverflow(entries: List<NewJoinedEntry>) {
|
||||
val strings = LocalStrings.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Form.button(
|
||||
strings.selectedMore,
|
||||
modifier = Modifier.testTag(NEW_JOINED_MORE_TAG),
|
||||
horizontalPadding = 4.dp,
|
||||
) { expanded = true }
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
entries.forEach { entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry.label, style = compactControlTextStyle()) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
entry.select()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L
|
||||
|
||||
|
||||
/**
|
||||
* One of a set of mutually exclusive choices.
|
||||
@@ -418,6 +618,7 @@ private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.selectable(selected = selected, onClick = onSelect)
|
||||
.height(Density.CONTROL_HEIGHT)
|
||||
.padding(end = Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
@@ -86,6 +86,9 @@ class SelectedStationState @JvmOverloads constructor(
|
||||
val stationSelection: StationSelectionState = StationSelectionState(),
|
||||
) {
|
||||
|
||||
/** Shared sked action used by both the panel and isolated context-menu drafts. */
|
||||
var skedRequestSubmitter: ((SkedRequest) -> Boolean)? = null
|
||||
|
||||
val selected: ChatMember?
|
||||
get() = stationSelection.selected
|
||||
|
||||
@@ -162,15 +165,22 @@ class SelectedStationState @JvmOverloads constructor(
|
||||
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
||||
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||
|
||||
val member = selected ?: return false
|
||||
return selected?.let { isTaggedNotQrv(it, band) } ?: false
|
||||
}
|
||||
|
||||
fun isTaggedNotQrv(member: ChatMember, band: NotQrvBand): Boolean {
|
||||
@Suppress("UNUSED_EXPRESSION") tagRevision
|
||||
return !band.read(member)
|
||||
}
|
||||
|
||||
/** Tagged means not QRV, so the station's QRV flag goes the other way. */
|
||||
fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
|
||||
val member = selected ?: return
|
||||
tagNotQrv(member, band, tagged)
|
||||
}
|
||||
|
||||
/** Applies a context action to its fixed target instead of the current selection. */
|
||||
fun tagNotQrv(member: ChatMember, band: NotQrvBand, tagged: Boolean) {
|
||||
band.write(member, !tagged)
|
||||
tagRevision++
|
||||
propagateNotQrv(member)
|
||||
@@ -178,7 +188,10 @@ class SelectedStationState @JvmOverloads constructor(
|
||||
|
||||
fun tagNotQrvAll(tagged: Boolean) {
|
||||
val member = selected ?: return
|
||||
tagNotQrvAll(member, tagged)
|
||||
}
|
||||
|
||||
fun tagNotQrvAll(member: ChatMember, tagged: Boolean) {
|
||||
NotQrvBand.entries.forEach { it.write(member, !tagged) }
|
||||
tagRevision++
|
||||
propagateNotQrv(member)
|
||||
@@ -188,9 +201,33 @@ class SelectedStationState @JvmOverloads constructor(
|
||||
fun createSked(): Boolean {
|
||||
val member = selected ?: return false
|
||||
|
||||
return createSked(member, skedMinutes, skedBand, skedMode)
|
||||
val request = SkedRequest(
|
||||
target = member,
|
||||
minutes = skedMinutes,
|
||||
band = skedBand,
|
||||
mode = skedMode,
|
||||
remindPm = remindPm,
|
||||
reminderOffsets = reminderOffsets,
|
||||
)
|
||||
return submitSked(request)
|
||||
}
|
||||
|
||||
/** Submits a fixed-target request through the shared application action. */
|
||||
fun submitSked(request: SkedRequest): Boolean =
|
||||
skedRequestSubmitter?.invoke(request)
|
||||
?: createSked(request.target, request.minutes, request.band, request.mode)
|
||||
|
||||
/** Copies the panel values into an isolated draft for a fixed target. */
|
||||
fun createSkedDraft(target: ChatMember): SkedEditorState = SkedEditorState(
|
||||
target = target,
|
||||
minutes = skedMinutes,
|
||||
band = skedBand ?: resolveDefaultSkedBand(target),
|
||||
mode = skedMode,
|
||||
remindPm = remindPm,
|
||||
reminderOffsets = reminderOffsets,
|
||||
submitter = ::submitSked,
|
||||
)
|
||||
|
||||
/**
|
||||
* Marks the path to this station as failed.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
|
||||
/** Complete immutable request handed to the shared sked creation action. */
|
||||
data class SkedRequest(
|
||||
val target: ChatMember,
|
||||
val minutes: Int,
|
||||
val band: Band?,
|
||||
val mode: String?,
|
||||
val remindPm: Boolean,
|
||||
val reminderOffsets: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Isolated editor draft used by the station-context popup.
|
||||
*
|
||||
* The target is captured when the popup opens. Later station selections therefore do
|
||||
* not redirect a partially edited sked.
|
||||
*/
|
||||
class SkedEditorState(
|
||||
val target: ChatMember,
|
||||
minutes: Int,
|
||||
band: Band?,
|
||||
mode: String?,
|
||||
remindPm: Boolean,
|
||||
reminderOffsets: String,
|
||||
private val submitter: (SkedRequest) -> Boolean,
|
||||
) {
|
||||
var minutes by mutableStateOf(minutes)
|
||||
var band by mutableStateOf(band)
|
||||
var mode by mutableStateOf(mode)
|
||||
var remindPm by mutableStateOf(remindPm)
|
||||
var reminderOffsets by mutableStateOf(reminderOffsets)
|
||||
|
||||
fun request(): SkedRequest = SkedRequest(
|
||||
target = target,
|
||||
minutes = minutes,
|
||||
band = band,
|
||||
mode = mode,
|
||||
remindPm = remindPm,
|
||||
reminderOffsets = reminderOffsets,
|
||||
)
|
||||
|
||||
fun submit(): Boolean = submitter(request())
|
||||
}
|
||||
@@ -119,7 +119,14 @@ class SplitterState(
|
||||
}
|
||||
val upperBound = totalPx - trailingMinimum
|
||||
val wanted = livePositions[index] * totalPx
|
||||
val position = wanted.coerceIn(lowerBound, upperBound)
|
||||
// Scaling impossible minimums can leave the two bounds a few float ULPs
|
||||
// apart. Treat that rounding-only empty range like drag() does instead of
|
||||
// letting coerceIn throw during layout.
|
||||
val position = if (lowerBound > upperBound) {
|
||||
(lowerBound + upperBound) / 2f
|
||||
} else {
|
||||
wanted.coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
effective[index] = position
|
||||
previous = position
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
|
||||
internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu"
|
||||
internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser"
|
||||
internal const val STATION_SKED_POPUP_TAG = "station-sked-popup"
|
||||
|
||||
private enum class StationContextPage {
|
||||
ROOT,
|
||||
SNIPPETS,
|
||||
NOT_QRV,
|
||||
WORKED,
|
||||
REACHABILITY,
|
||||
}
|
||||
|
||||
/** Shared station action popup used by the user list and the map. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StationContextOverlays(
|
||||
menuState: StationContextMenuState,
|
||||
mainWindowState: MainWindowState,
|
||||
onShowMap: (() -> Unit)? = null,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
var page by remember { mutableStateOf(StationContextPage.ROOT) }
|
||||
var skedDraft by remember { mutableStateOf<SkedEditorState?>(null) }
|
||||
var skedPosition by remember { mutableStateOf(IntOffset.Zero) }
|
||||
|
||||
LaunchedEffect(menuState.candidates, menuState.target) {
|
||||
page = StationContextPage.ROOT
|
||||
}
|
||||
|
||||
if (menuState.isOpen) {
|
||||
Popup(
|
||||
popupPositionProvider = WindowPointPopupPositionProvider(menuState.position),
|
||||
onDismissRequest = menuState::close,
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG),
|
||||
shape = MenuDefaults.shape,
|
||||
color = MenuDefaults.containerColor,
|
||||
tonalElevation = MenuDefaults.TonalElevation,
|
||||
shadowElevation = MenuDefaults.ShadowElevation,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 190.dp, max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
val target = menuState.target
|
||||
if (target == null) {
|
||||
ContextHeading(
|
||||
strings.contextChooseStation,
|
||||
Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG),
|
||||
)
|
||||
menuState.candidates.forEach { candidate ->
|
||||
ContextItem(stationContextTargetLabel(candidate)) {
|
||||
menuState.chooseTarget(candidate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (page) {
|
||||
StationContextPage.ROOT -> {
|
||||
ContextSubmenu(strings.contextMessageSnippets) {
|
||||
page = StationContextPage.SNIPPETS
|
||||
}
|
||||
ContextItem(strings.contextCreateSked) {
|
||||
skedPosition = menuState.position
|
||||
skedDraft = mainWindowState.selectedStation.createSkedDraft(target)
|
||||
menuState.close()
|
||||
}
|
||||
ContextSeparator()
|
||||
ContextSubmenu(strings.contextTagNotQrv) {
|
||||
page = StationContextPage.NOT_QRV
|
||||
}
|
||||
ContextSubmenu(strings.contextMarkWorked) {
|
||||
page = StationContextPage.WORKED
|
||||
}
|
||||
ContextSeparator()
|
||||
ContextSubmenu(strings.contextCalculateReachability) {
|
||||
page = StationContextPage.REACHABILITY
|
||||
}
|
||||
}
|
||||
|
||||
StationContextPage.SNIPPETS -> {
|
||||
ContextBack { page = StationContextPage.ROOT }
|
||||
mainWindowState.snippets.entries.forEach { snippet ->
|
||||
ContextItem(snippet) {
|
||||
mainWindowState.chatInput.appendSnippet(snippet)
|
||||
menuState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StationContextPage.NOT_QRV -> {
|
||||
ContextBack { page = StationContextPage.ROOT }
|
||||
NotQrvBand.entries
|
||||
.filter { it.band in mainWindowState.activeBands }
|
||||
.forEach { band ->
|
||||
val tagged = mainWindowState.selectedStation
|
||||
.isTaggedNotQrv(target, band)
|
||||
ContextCheckItem(band.label, tagged) {
|
||||
mainWindowState.selectedStation
|
||||
.tagNotQrv(target, band, !tagged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StationContextPage.WORKED -> {
|
||||
ContextBack { page = StationContextPage.ROOT }
|
||||
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
|
||||
val worked = target.isWorkedOn(band)
|
||||
ContextCheckItem(band.displayLabel, worked, enabled = !worked) {
|
||||
if (mainWindowState.markStationWorked?.invoke(target, band) == true) {
|
||||
menuState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StationContextPage.REACHABILITY -> {
|
||||
ContextBack { page = StationContextPage.ROOT }
|
||||
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
|
||||
ContextItem(band.displayLabel) {
|
||||
if (mainWindowState.mapReachability.calculate(target, band)) {
|
||||
onShowMap?.invoke()
|
||||
menuState.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
skedDraft?.let { draft ->
|
||||
Popup(
|
||||
popupPositionProvider = WindowPointPopupPositionProvider(skedPosition),
|
||||
onDismissRequest = { skedDraft = null },
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.widthIn(min = 420.dp, max = 560.dp)
|
||||
.testTag(STATION_SKED_POPUP_TAG),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
strings.contextSkedTitle(draft.target.callSign.orEmpty()),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
SkedEditorControls(
|
||||
activeBands = mainWindowState.activeBands,
|
||||
minutes = draft.minutes,
|
||||
onMinutesChanged = { draft.minutes = it },
|
||||
band = draft.band,
|
||||
onBandChanged = { draft.band = it },
|
||||
mode = draft.mode,
|
||||
onModeChanged = { draft.mode = it },
|
||||
remindPm = draft.remindPm,
|
||||
onRemindPmChanged = { draft.remindPm = it },
|
||||
reminderOffsets = draft.reminderOffsets,
|
||||
onReminderOffsetsChanged = { draft.reminderOffsets = it },
|
||||
canCreate = draft.band != null,
|
||||
onCreate = {
|
||||
if (draft.submit()) skedDraft = null
|
||||
},
|
||||
additionalActions = {
|
||||
Form.button(strings.contextCancel) { skedDraft = null }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextHeading(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextBack(onClick: () -> Unit) {
|
||||
val strings = LocalStrings.current
|
||||
ContextItem("‹ ${strings.contextBack}", onClick = onClick)
|
||||
ContextSeparator()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextSubmenu(text: String, onClick: () -> Unit) {
|
||||
ContextItem("$text ›", onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextCheckItem(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(text, style = compactControlTextStyle(), maxLines = 1) },
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextSeparator() {
|
||||
HorizontalDivider(
|
||||
thickness = Density.HAIRLINE,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun stationContextTargetLabel(member: ChatMember): String {
|
||||
val category = member.chatCategory
|
||||
val categoryName = category?.getChatCategoryName(category.categoryNumber)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: category?.categoryNumber?.toString().orEmpty()
|
||||
return if (categoryName.isBlank()) member.callSign.orEmpty()
|
||||
else "${member.callSign.orEmpty()} | $categoryName"
|
||||
}
|
||||
|
||||
internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) {
|
||||
Band.B_50 -> isWorked50
|
||||
Band.B_70 -> isWorked70
|
||||
Band.B_144 -> isWorked144
|
||||
Band.B_432 -> isWorked432
|
||||
Band.B_1296 -> isWorked1240
|
||||
Band.B_2320 -> isWorked2300
|
||||
Band.B_3400 -> isWorked3400
|
||||
Band.B_5760 -> isWorked5600
|
||||
Band.B_10G -> isWorked10G
|
||||
Band.B_24G -> isWorked24G
|
||||
}
|
||||
|
||||
/** Window-relative popup position clamped to the visible window on every DPI scale. */
|
||||
internal data class WindowPointPopupPositionProvider(
|
||||
val point: IntOffset,
|
||||
) : PopupPositionProvider {
|
||||
override fun calculatePosition(
|
||||
anchorBounds: IntRect,
|
||||
windowSize: IntSize,
|
||||
layoutDirection: LayoutDirection,
|
||||
popupContentSize: IntSize,
|
||||
): IntOffset = IntOffset(
|
||||
x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
|
||||
y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import kst4contest.model.ChatMember
|
||||
|
||||
/** Current target and window-relative anchor of a station context menu. */
|
||||
class StationContextMenuState(
|
||||
private val selectStation: (ChatMember, StationSelectionSource) -> Unit,
|
||||
) {
|
||||
var position: IntOffset by mutableStateOf(IntOffset.Zero)
|
||||
private set
|
||||
|
||||
var candidates: List<ChatMember> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
var target: ChatMember? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
|
||||
private set
|
||||
|
||||
val isOpen: Boolean
|
||||
get() = candidates.isNotEmpty()
|
||||
|
||||
fun open(
|
||||
members: List<ChatMember>,
|
||||
source: StationSelectionSource,
|
||||
position: IntOffset,
|
||||
) {
|
||||
val unique = members
|
||||
.filter { StationIdentity.of(it) != null }
|
||||
.distinctBy(StationIdentity::of)
|
||||
if (unique.isEmpty()) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
this.position = position
|
||||
this.source = source
|
||||
candidates = unique
|
||||
target = unique.singleOrNull()
|
||||
target?.let { selectStation(it, source) }
|
||||
}
|
||||
|
||||
fun chooseTarget(member: ChatMember) {
|
||||
val identity = StationIdentity.of(member) ?: return
|
||||
val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return
|
||||
target = resolved
|
||||
selectStation(resolved, source)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
candidates = emptyList()
|
||||
target = null
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,11 @@ import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -18,13 +16,20 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal const val FILTER_DISTANCE_GROUP_TAG = "filter-distance-group"
|
||||
internal const val FILTER_DIRECTION_GROUP_TAG = "filter-direction-group"
|
||||
internal const val FILTER_STATION_GROUP_TAG = "filter-station-group"
|
||||
internal const val FILTER_WORKED_GROUP_TAG = "filter-worked-group"
|
||||
internal const val FILTER_PROPAGATION_GROUP_TAG = "filter-propagation-group"
|
||||
|
||||
/**
|
||||
* The station filter bar above the station table.
|
||||
*
|
||||
* Laid out to match the JavaFX original: three tight rows of controls separated
|
||||
* by thin vertical rules between logical groups.
|
||||
* Compact surfaces keep related filters together while the outer flow rows still wrap
|
||||
* against the station panel's actual width.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -54,131 +59,6 @@ fun StationFilterBar(
|
||||
* stays together on its line; only the QRB pair and the bearings may break up
|
||||
* inside their group when even that group is wider than the pane.
|
||||
*/
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Reset filters
|
||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||
if (onReset != null) {
|
||||
onReset()
|
||||
} else {
|
||||
filters.reset()
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// QRB maximum + minimum (#21): together they make a range, so they stay one group
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.maxQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.maxQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.maxQrbKm = parsed
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.maxQrbKm = 0.0
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
}
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.minQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.minQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.minQrbKm = parsed
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.minQrbKm = 0.0
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QTF checkbox + field
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
CompactCheckbox(
|
||||
checked = filters.qtfEnabled,
|
||||
onCheckedChange = {
|
||||
filters.qtfEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
Text("± BW/2", style = compactControlTextStyle())
|
||||
}
|
||||
|
||||
// Bearings
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(
|
||||
bearing.name,
|
||||
on = filters.bearing == bearing,
|
||||
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
|
||||
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
||||
) {
|
||||
filters.toggleBearing(bearing)
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
@@ -195,9 +75,137 @@ fun StationFilterBar(
|
||||
modifier = Modifier.width(90.dp),
|
||||
)
|
||||
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
Text(strings.filterHideWorked, style = compactControlTextStyle())
|
||||
// Reset filters
|
||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||
if (onReset != null) {
|
||||
onReset()
|
||||
} else {
|
||||
filters.reset()
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// Distance and bearing-width controls form one functional surface.
|
||||
FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) {
|
||||
FilterGroup {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.maxQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.maxQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) {
|
||||
filters.maxQrbKm.toInt().toString()
|
||||
} else {
|
||||
filters.maxQrbKm.toString()
|
||||
},
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.maxQrbKm = parsed
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.maxQrbKm = 0.0
|
||||
if (filters.maxQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp),
|
||||
)
|
||||
}
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.minQrbEnabled,
|
||||
onCheckedChange = {
|
||||
filters.minQrbEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.minQrbKm.rem(1) == 0.0) {
|
||||
filters.minQrbKm.toInt().toString()
|
||||
} else {
|
||||
filters.minQrbKm.toString()
|
||||
},
|
||||
onValueChange = {
|
||||
val parsed = parseQrbKm(it)
|
||||
if (parsed != null) {
|
||||
filters.minQrbKm = parsed
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.minQrbKm = 0.0
|
||||
if (filters.minQrbEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) {
|
||||
FilterGroup {
|
||||
CompactCheckbox(
|
||||
checked = filters.qtfEnabled,
|
||||
onCheckedChange = {
|
||||
filters.qtfEnabled = it
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) {
|
||||
filters.qtfDegrees.toInt().toString()
|
||||
} else {
|
||||
filters.qtfDegrees.toString()
|
||||
},
|
||||
onValueChange = {
|
||||
val parsed = it.toDoubleOrNull()
|
||||
if (parsed != null) {
|
||||
filters.qtfDegrees = parsed
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
} else if (it.isEmpty()) {
|
||||
filters.qtfDegrees = 0.0
|
||||
filters.bearing = null
|
||||
if (filters.qtfEnabled) onChanged()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.width(50.dp),
|
||||
)
|
||||
Text("± BW/2", style = compactControlTextStyle())
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(
|
||||
bearing.name,
|
||||
on = filters.bearing == bearing,
|
||||
modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH),
|
||||
horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING,
|
||||
) {
|
||||
filters.toggleBearing(bearing)
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterGroupSurface(FILTER_STATION_GROUP_TAG) {
|
||||
Text(strings.filterHide, style = compactControlTextStyle())
|
||||
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
||||
filters.hideWorkedAny = !filters.hideWorkedAny
|
||||
onChanged()
|
||||
@@ -210,29 +218,18 @@ fun StationFilterBar(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
|
||||
filters.hideInactive = !filters.hideInactive
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
FilterGroupSurface(FILTER_WORKED_GROUP_TAG) {
|
||||
Text(strings.filterShow, style = compactControlTextStyle())
|
||||
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
|
||||
filters.onlyNewGrids = !filters.onlyNewGrids
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROW 3: Grid color | Tropo | New bands | AS next 5m | Reachability
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterGroup {
|
||||
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
|
||||
filters.gridColouring = !filters.gridColouring
|
||||
}
|
||||
@@ -250,20 +247,22 @@ fun StationFilterBar(
|
||||
}
|
||||
}
|
||||
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = reachability.options,
|
||||
selected = reachability.selectedOption,
|
||||
describe = ReachabilityBandOption::label,
|
||||
onSelect = { reachability.selectBand(it.band) },
|
||||
)
|
||||
Form.button(
|
||||
text = "Calc selected",
|
||||
enabled = reachability.canCalculate,
|
||||
onClick = { reachability.calculateSelected() },
|
||||
)
|
||||
FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) {
|
||||
FilterGroup {
|
||||
Text(strings.filterReachability, style = compactControlTextStyle())
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = reachability.options,
|
||||
selected = reachability.selectedOption,
|
||||
describe = ReachabilityBandOption::label,
|
||||
onSelect = { reachability.selectBand(it.band) },
|
||||
)
|
||||
Form.button(
|
||||
text = strings.filterCalcSelected,
|
||||
enabled = reachability.canCalculate,
|
||||
onClick = { reachability.calculateSelected() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,16 +303,20 @@ private fun FilterGroup(content: @Composable () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills
|
||||
* the row's intrinsic height, which a line of a flow row does not offer.
|
||||
*/
|
||||
/** A compact surface that keeps related filters visually and spatially together. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun GroupSeparator() {
|
||||
Box(
|
||||
private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.height(18.dp)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
.testTag(tag)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||
.padding(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -54,11 +54,14 @@ object DirectedMessageStationTarget {
|
||||
/** Where an explicit station selection came from. */
|
||||
enum class StationSelectionSource {
|
||||
STATION_TABLE,
|
||||
STATION_CONTEXT_MENU,
|
||||
DIRECTED_MESSAGE,
|
||||
PUBLIC_MESSAGE,
|
||||
QSO_OF_THE_OTHER,
|
||||
MAP,
|
||||
MAP_CONTEXT_MENU,
|
||||
PRIORITY,
|
||||
NEW_JOINED,
|
||||
TIMELINE,
|
||||
RESET_VIEW,
|
||||
PROGRAMMATIC,
|
||||
@@ -98,7 +101,7 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
current = row
|
||||
lastSource = source
|
||||
selectionRevision++
|
||||
if (row != null) {
|
||||
if (row != null && source.requestsStationTableScroll()) {
|
||||
scrollRequestRevision++
|
||||
}
|
||||
}
|
||||
@@ -114,3 +117,19 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
|
||||
StationSelectionSource.STATION_TABLE,
|
||||
StationSelectionSource.STATION_CONTEXT_MENU,
|
||||
StationSelectionSource.RESET_VIEW,
|
||||
StationSelectionSource.PROGRAMMATIC -> false
|
||||
|
||||
StationSelectionSource.DIRECTED_MESSAGE,
|
||||
StationSelectionSource.PUBLIC_MESSAGE,
|
||||
StationSelectionSource.QSO_OF_THE_OTHER,
|
||||
StationSelectionSource.MAP,
|
||||
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||
StationSelectionSource.PRIORITY,
|
||||
StationSelectionSource.NEW_JOINED,
|
||||
StationSelectionSource.TIMELINE -> true
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -24,7 +25,11 @@ import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -50,6 +55,7 @@ import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.sign
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
|
||||
@@ -94,6 +100,7 @@ class MapState(
|
||||
* any zoom with a fraction, the antenna beam did not start where the map said home was.
|
||||
* Neither failure is possible here, because nothing is scaled.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ComposeStationMap(
|
||||
markers: List<MapCallsignRawSnapshot>,
|
||||
@@ -106,6 +113,8 @@ fun ComposeStationMap(
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
|
||||
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -122,6 +131,7 @@ fun ComposeStationMap(
|
||||
* wrongly grouped markers whenever the window is resized.
|
||||
*/
|
||||
var canvasSize by remember { mutableStateOf(Size.Zero) }
|
||||
var canvasCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
/* Bumped when a tile arrives, which is what asks for the frame that shows it. */
|
||||
var tileRevision by remember { mutableStateOf(0) }
|
||||
@@ -196,6 +206,39 @@ fun ComposeStationMap(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
|
||||
.onGloballyPositioned { canvasCoordinates = it }
|
||||
.onPointerEvent(PointerEventType.Press) { event ->
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent
|
||||
val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height)
|
||||
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
|
||||
val cluster = result.clusters().firstOrNull { candidate ->
|
||||
within(
|
||||
local,
|
||||
viewport.screenX(candidate.centerLon).toFloat(),
|
||||
viewport.screenY(candidate.centerLat).toFloat(),
|
||||
clusterRadiusPx(candidate.size()),
|
||||
)
|
||||
}
|
||||
val individual = result.individual().firstOrNull { marker ->
|
||||
within(
|
||||
local,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
|
||||
val window = canvasCoordinates?.localToWindow(local)
|
||||
if (!targets.isNullOrEmpty() && window != null) {
|
||||
onMarkerContextRequested?.invoke(
|
||||
targets,
|
||||
IntOffset(window.x.roundToInt(), window.y.roundToInt()),
|
||||
)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { centroid, pan, zoomChange, _ ->
|
||||
var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||
@@ -293,6 +336,7 @@ fun ComposeStationMap(
|
||||
|
||||
drawTiles(viewport, imageCache, darkMode)
|
||||
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
|
||||
drawWorkedGrossFields(viewport, workedGrossFields)
|
||||
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
|
||||
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
|
||||
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
|
||||
@@ -709,10 +753,7 @@ private fun DrawScope.drawHover(
|
||||
val text = when {
|
||||
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
|
||||
|
||||
markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
|
||||
if (locator.isBlank()) markerUnderPointer.displayCallSign()
|
||||
else "${markerUnderPointer.displayCallSign()} ($locator)"
|
||||
}
|
||||
markerUnderPointer != null -> markerUnderPointer.tooltipText()
|
||||
|
||||
else -> return
|
||||
}
|
||||
@@ -758,6 +799,46 @@ private fun DrawScope.drawHover(
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawWorkedGrossFields(
|
||||
viewport: MapViewport,
|
||||
workedGrossFields: Set<String>,
|
||||
) {
|
||||
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
|
||||
val left = viewport.screenX(cell.westLon()).toFloat()
|
||||
val right = viewport.screenX(cell.eastLon()).toFloat()
|
||||
val top = viewport.screenY(cell.northLat()).toFloat()
|
||||
val bottom = viewport.screenY(cell.southLat()).toFloat()
|
||||
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
|
||||
val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top))
|
||||
drawRect(
|
||||
color = WORKED_GRID_FILL,
|
||||
topLeft = topLeft,
|
||||
size = cellSize,
|
||||
)
|
||||
drawRect(
|
||||
color = WORKED_GRID_BORDER,
|
||||
topLeft = topLeft,
|
||||
size = cellSize,
|
||||
style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Worked four-character cells visible in the current viewport, independent of markers. */
|
||||
internal fun visibleWorkedGrossFieldCells(
|
||||
viewport: MapViewport,
|
||||
workedGrossFields: Set<String>,
|
||||
): List<MaidenheadGridUtils.GridCell> {
|
||||
if (workedGrossFields.isEmpty()) return emptyList()
|
||||
return MaidenheadGridUtils.buildVisibleCells(
|
||||
viewport.southLat(),
|
||||
viewport.westLon(),
|
||||
viewport.northLat(),
|
||||
viewport.eastLon(),
|
||||
MaidenheadGridUtils.GridPrecision.SQUARE_4,
|
||||
).filter { it.locatorLabel() in workedGrossFields }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- helpers */
|
||||
|
||||
private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
|
||||
@@ -864,6 +945,9 @@ private const val GRID_LINE_WIDTH_PX = 1.4f
|
||||
private val GRID_LABEL_COLOR = Color(0xFF63067A)
|
||||
private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
|
||||
private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
|
||||
private val WORKED_GRID_FILL = Color.Black.copy(alpha = 0.30f)
|
||||
private val WORKED_GRID_BORDER = Color.Black
|
||||
private const val WORKED_GRID_BORDER_WIDTH_PX = 3f
|
||||
private const val GRID_LABEL_PADDING_PX = 3f
|
||||
|
||||
private val DOT_FILL = Color(0xFF1D1D1D)
|
||||
|
||||
@@ -21,6 +21,8 @@ import kst4contest.view.compose.PaletteStore
|
||||
import kst4contest.view.compose.ComposeWindowHost
|
||||
import kst4contest.view.compose.MainWindowState
|
||||
import kst4contest.view.compose.StationIdentity
|
||||
import kst4contest.view.compose.StationContextMenuState
|
||||
import kst4contest.view.compose.StationContextOverlays
|
||||
import kst4contest.view.compose.StationSelectionSource
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
@@ -141,6 +143,9 @@ object StationMapWindow {
|
||||
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
|
||||
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
|
||||
var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) }
|
||||
val stationContextMenu = remember(mainWindowState) {
|
||||
StationContextMenuState(mainWindowState::selectStation)
|
||||
}
|
||||
|
||||
val selectedMember = mainWindowState.selectedStation.selected
|
||||
|
||||
@@ -180,6 +185,7 @@ object StationMapWindow {
|
||||
}
|
||||
|
||||
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
||||
var automaticAnalysisRevision by remember { mutableStateOf(0L) }
|
||||
|
||||
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
||||
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
||||
@@ -192,6 +198,8 @@ object StationMapWindow {
|
||||
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
LaunchedEffect(selectedMember, selectedHasMarker) {
|
||||
val automaticRequest = ++automaticAnalysisRevision
|
||||
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
|
||||
if (selectedMember == null) {
|
||||
pathAnalysisResult = null
|
||||
return@LaunchedEffect
|
||||
@@ -232,11 +240,31 @@ object StationMapWindow {
|
||||
)
|
||||
|
||||
analysis.request(selectedMember, selectedSnapshot) { result ->
|
||||
pathAnalysisResult = result
|
||||
if (automaticAnalysisRevision == automaticRequest &&
|
||||
mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart
|
||||
) {
|
||||
pathAnalysisResult = result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val explicitReachabilityResult = mainWindowState.mapReachability.result
|
||||
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
|
||||
if (explicitReachabilityResult != null) {
|
||||
automaticAnalysisRevision++
|
||||
pathAnalysisResult = explicitReachabilityResult
|
||||
pathAnalysisVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
val workedGrossFields = if (mainWindowState.stationFilter.gridColouring) {
|
||||
mainWindowState.stations.contentRevision
|
||||
mainWindowState.workedGrossFieldsProvider()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
androidx.compose.material3.Surface(
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
|
||||
contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
|
||||
@@ -373,6 +401,7 @@ object StationMapWindow {
|
||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||
maxQrbKm = maxQrbKm,
|
||||
darkMode = isDarkTheme,
|
||||
workedGrossFields = workedGrossFields,
|
||||
onMarkerClicked = { snapshot ->
|
||||
val variants = chatMembers()
|
||||
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||
@@ -393,6 +422,15 @@ object StationMapWindow {
|
||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||
}
|
||||
},
|
||||
onMarkerContextRequested = { snapshots, position ->
|
||||
val callsigns = snapshots.mapTo(linkedSetOf()) { it.callSignRaw() }
|
||||
val candidates = chatMembers().filter { it.callSignRaw in callsigns }
|
||||
stationContextMenu.open(
|
||||
candidates,
|
||||
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||
position,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG)
|
||||
)
|
||||
|
||||
@@ -456,6 +494,7 @@ object StationMapWindow {
|
||||
} // main layout column
|
||||
} // window column
|
||||
} // Surface
|
||||
StationContextOverlays(stationContextMenu, mainWindowState)
|
||||
} // Content
|
||||
|
||||
/** Marks the map canvas so a test can click a marker on it. */
|
||||
|
||||
@@ -120,6 +120,15 @@ class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) {
|
||||
val splitter = SplitterState(paneCount = 2, stored = stored) {}
|
||||
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the selected station content splitter takes the one stored profile position`() {
|
||||
val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition()
|
||||
assertEquals(1, stored.size)
|
||||
|
||||
val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {}
|
||||
assertEquals(stored.map { it.toFloat() }, splitter.positions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MapReachabilityStateTest {
|
||||
|
||||
@Test
|
||||
fun `latest station and band request rejects stale results`() {
|
||||
val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>()
|
||||
val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback }
|
||||
val first = member("DL1ABC", 2, "JN49AA")
|
||||
val second = member("DL2XYZ", 3, "JO60AA")
|
||||
|
||||
assertTrue(state.calculate(first, Band.B_144))
|
||||
assertEquals("Loading", state.result?.analysisMode())
|
||||
assertTrue(state.calculate(second, Band.B_432))
|
||||
|
||||
callbacks[1](result("DL2XYZ", 432.0))
|
||||
callbacks[0](result("DL1ABC", 144.0))
|
||||
|
||||
assertEquals("DL2XYZ", state.result?.toCallsignRaw())
|
||||
assertEquals(432.0, state.result?.analysisFrequencyMHz())
|
||||
assertFalse(state.running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting another station invalidates an outstanding explicit result`() {
|
||||
lateinit var callback: (PathAnalysisResult) -> Unit
|
||||
val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result }
|
||||
val first = member("DL1ABC", 2, "JN49AA")
|
||||
|
||||
state.calculate(first, Band.B_144)
|
||||
state.selectionChanged(StationIdentity("DL2XYZ", 2))
|
||||
callback(result("DL1ABC", 144.0))
|
||||
|
||||
assertNull(state.result)
|
||||
assertFalse(state.running)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing station or band does not start a request`() {
|
||||
val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") }
|
||||
|
||||
assertFalse(state.calculate(null, Band.B_144))
|
||||
assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null))
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = locator
|
||||
}
|
||||
|
||||
private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile(
|
||||
"Calculated", "JO51AA", "JN49AA", callSign,
|
||||
100.0, 180.0, 10.0, 10.0, frequency, "done",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class NewJoinedBarScreenTest {
|
||||
|
||||
@Test
|
||||
fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest {
|
||||
val selection = StationSelectionState()
|
||||
val members = (1..7).map { member("DL${it}ABC") }
|
||||
val newJoined = NewJoinedState(clock = { 10_000L }) { member ->
|
||||
selection.select(member, StationSelectionSource.NEW_JOINED)
|
||||
}.apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(
|
||||
members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||
},
|
||||
)
|
||||
}
|
||||
val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {})
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("New joined:").assertIsDisplayed()
|
||||
listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach {
|
||||
onNodeWithText(it).assertIsDisplayed()
|
||||
}
|
||||
onNodeWithText("DL2ABC").assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||
onNodeWithText("DL2ABC").assertIsDisplayed().performClick()
|
||||
|
||||
assertSame(members[1], selection.selected)
|
||||
assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource)
|
||||
assertTrue(selection.scrollRequestRevision > 0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest {
|
||||
val newJoined = NewJoinedState(onSelect = {})
|
||||
val priorityMember = member("OK1FPR")
|
||||
val priority = TopPriorityState(
|
||||
topStations = { listOf(priorityMember to 767.0) },
|
||||
onSelect = {},
|
||||
).apply { refresh() }
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("New joined:").assertDoesNotExist()
|
||||
onNodeWithText("Priority:").assertIsDisplayed()
|
||||
onNodeWithText("1 OK1FPR 767").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest {
|
||||
val members = (1..7).map { member("DL${it}ABC") }
|
||||
val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, 2, 9_000L + index)
|
||||
})
|
||||
}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(300.dp)) {
|
||||
TopPriorityBar(
|
||||
state = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("DL7ABC").assertIsDisplayed()
|
||||
onNodeWithText("DL6ABC").assertDoesNotExist()
|
||||
val limit = with(density) { 300.dp.toPx() }
|
||||
listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag ->
|
||||
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar")
|
||||
}
|
||||
|
||||
onNodeWithTag(NEW_JOINED_MORE_TAG).performClick()
|
||||
onNodeWithText("DL6ABC").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun member(callSign: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NewJoinedStateTest {
|
||||
|
||||
private val now = AtomicLong(1_000_000L)
|
||||
private var selected: ChatMember? = null
|
||||
private val state = NewJoinedState(clock = now::get, onSelect = { selected = it })
|
||||
|
||||
@Test
|
||||
fun `an initial active roster without live events produces no entries`() {
|
||||
state.updateActiveMembers(listOf(member("HB9SJV", 2)))
|
||||
|
||||
assertTrue(state.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a live join is shown once and duplicate events do not renew it`() {
|
||||
val station = member("HB9SJV", 2)
|
||||
state.updateActiveMembers(listOf(station))
|
||||
state.updateEvents(
|
||||
listOf(
|
||||
event("HB9SJV", 2, now.get() - 2_000L),
|
||||
event("HB9SJV", 2, now.get() - 1_000L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("HB9SJV"), state.entries.map { it.label })
|
||||
assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same full callsign in two categories stays distinguishable`() {
|
||||
val main = member("DL1KDA", 2)
|
||||
val microwave = member("DL1KDA", 3)
|
||||
state.updateActiveMembers(listOf(main, microwave))
|
||||
state.updateEvents(
|
||||
listOf(
|
||||
event("DL1KDA", 3, now.get()),
|
||||
event("DL1KDA", 2, now.get() - 1L),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber })
|
||||
state.entries.first().select()
|
||||
assertSame(microwave, selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an entry expires exactly ten minutes after the join`() {
|
||||
val station = member("OE5SOS", 2)
|
||||
state.updateActiveMembers(listOf(station))
|
||||
state.updateEvents(listOf(event("OE5SOS", 2, now.get())))
|
||||
|
||||
now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L)
|
||||
state.refresh()
|
||||
assertTrue(state.isRecent(station))
|
||||
|
||||
now.incrementAndGet()
|
||||
state.refresh()
|
||||
assertFalse(state.isRecent(station))
|
||||
assertTrue(state.entries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only five newest entries are direct and the rest remain in the overflow`() {
|
||||
val members = (1..7).map { member("DL${it}ABC", 2) }
|
||||
state.updateActiveMembers(members)
|
||||
state.updateEvents(
|
||||
members.mapIndexed { index, member ->
|
||||
event(member.callSign, 2, now.get() - (members.size - index))
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"),
|
||||
state.visibleEntries.map { it.label })
|
||||
assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a station leaving disappears and a real rejoin gets the new event`() {
|
||||
val first = member("PA2M", 2)
|
||||
state.updateActiveMembers(listOf(first))
|
||||
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||
|
||||
state.updateActiveMembers(emptyList())
|
||||
assertTrue(state.entries.isEmpty())
|
||||
|
||||
val rejoined = member("PA2M", 2)
|
||||
now.addAndGet(5_000L)
|
||||
state.updateActiveMembers(listOf(rejoined))
|
||||
state.updateEvents(listOf(event("PA2M", 2, now.get())))
|
||||
|
||||
state.entries.single().select()
|
||||
assertSame(rejoined, selected)
|
||||
assertEquals(now.get(), state.entries.single().joinedAtEpochMillis)
|
||||
}
|
||||
|
||||
private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(categoryNumber)
|
||||
}
|
||||
|
||||
private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) =
|
||||
RecentChatJoin(callSign, categoryNumber, joinedAt)
|
||||
}
|
||||
+79
-3
@@ -106,6 +106,84 @@ class SelectedStationPanelScreenTest {
|
||||
assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allMessageChoicesShareOneVerticalAxis() = runComposeUiTest {
|
||||
setPanel(selectedState("DM5M"), Modifier.width(570.dp).height(420.dp))
|
||||
|
||||
val centers = listOf("pm to me", "pm to other", "public msgs", "nothing")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.center.y }
|
||||
|
||||
assertTrue(centers.max() - centers.min() <= 1f, "radio labels are misaligned: $centers")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedMessageSplitterChangesTheActualTableHeightInBothDirections() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
val splitter = SplitterState(2, doubleArrayOf(0.45)) {}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
contentSplitter = splitter,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(620.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val initial = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
runOnIdle { splitter.drag(0, 0.70f, 500f) }
|
||||
waitForIdle()
|
||||
val taller = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
assertTrue(taller > initial, "moving the divider down did not enlarge the table")
|
||||
|
||||
runOnIdle { splitter.drag(0, 0.25f, 500f) }
|
||||
waitForIdle()
|
||||
val shorter = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG)
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
assertTrue(shorter < taller, "moving the divider up did not shrink the table")
|
||||
|
||||
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectedMessageSplitterSurvivesStationAndMessageUpdates() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
val messages = messageTable()
|
||||
val splitter = SplitterState(2, doubleArrayOf(0.62)) {}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messages,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
contentSplitter = splitter,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(620.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val expectedPosition = splitter.positions.single()
|
||||
runOnIdle {
|
||||
selected.select(member("DL1ABC"))
|
||||
messages.replaceRows(listOf(ChatMessage().apply { messageText = "new message" }))
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(expectedPosition, splitter.positions.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
@@ -300,9 +378,7 @@ class SelectedStationPanelScreenTest {
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Lookup on qrz.com")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
onNodeWithText("Lookup on qrz.com").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun selectedState(callSign: String) = selectedState(member(callSign))
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SkedEditorStateTest {
|
||||
|
||||
@Test
|
||||
fun `panel submission includes reminder settings in one request`() {
|
||||
val requests = mutableListOf<SkedRequest>()
|
||||
val member = member("DL1ABC", 2)
|
||||
val state = SelectedStationState(
|
||||
propagateNotQrv = { },
|
||||
createSked = { _, _, _, _ -> false },
|
||||
)
|
||||
state.skedRequestSubmitter = { request -> requests += request; true }
|
||||
state.select(member)
|
||||
state.skedMinutes = 8
|
||||
state.skedBand = Band.B_432
|
||||
state.skedMode = "CW"
|
||||
state.remindPm = true
|
||||
state.reminderOffsets = "5+2+1"
|
||||
|
||||
assertTrue(state.createSked())
|
||||
assertEquals(
|
||||
SkedRequest(member, 8, Band.B_432, "CW", true, "5+2+1"),
|
||||
requests.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `popup draft keeps its target when the shared selection changes`() {
|
||||
val requests = mutableListOf<SkedRequest>()
|
||||
val original = member("DL1ABC", 2)
|
||||
val later = member("DL2XYZ", 3)
|
||||
val draft = SkedEditorState(
|
||||
target = original,
|
||||
minutes = 5,
|
||||
band = Band.B_144,
|
||||
mode = "SSB",
|
||||
remindPm = false,
|
||||
reminderOffsets = "2+1",
|
||||
submitter = { request -> requests += request; true },
|
||||
)
|
||||
|
||||
draft.minutes = 10
|
||||
draft.band = Band.B_1296
|
||||
draft.remindPm = true
|
||||
@Suppress("UNUSED_VARIABLE") val unrelatedSelection = later
|
||||
|
||||
assertTrue(draft.submit())
|
||||
assertSame(original, requests.single().target)
|
||||
assertEquals(Band.B_1296, requests.single().band)
|
||||
assertTrue(requests.single().remindPm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected popup submission reports failure so popup can remain open`() {
|
||||
val draft = SkedEditorState(
|
||||
target = member("DL1ABC", 2),
|
||||
minutes = 5,
|
||||
band = null,
|
||||
mode = "SSB",
|
||||
remindPm = false,
|
||||
reminderOffsets = "2+1",
|
||||
submitter = { false },
|
||||
)
|
||||
|
||||
assertFalse(draft.submit())
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,23 @@ class SplitterStateTest {
|
||||
assertEquals(100f, sizes.sum(), 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimum scaling tolerates float rounding`() {
|
||||
val state = SplitterState(
|
||||
paneCount = 2,
|
||||
stored = doubleArrayOf(0.48),
|
||||
minPanePx = 160f,
|
||||
) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 24.405657f,
|
||||
minimumPaneSizesPx = floatArrayOf(52f, 160f),
|
||||
)
|
||||
|
||||
assertTrue(sizes.all { it >= 0f })
|
||||
assertEquals(24.405657f, sizes.sum(), 0.0001f)
|
||||
}
|
||||
|
||||
private fun assertNull(value: Any?, message: String) {
|
||||
assertFalse(value != null, message)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StationContextMenuStateTest {
|
||||
|
||||
@Test
|
||||
fun `single row target is selected immediately with its context source`() {
|
||||
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||
val member = member("DL1ABC", 2)
|
||||
|
||||
state.open(listOf(member), StationSelectionSource.STATION_CONTEXT_MENU, IntOffset(10, 20))
|
||||
|
||||
assertSame(member, state.target)
|
||||
assertEquals(listOf(member to StationSelectionSource.STATION_CONTEXT_MENU), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grouped map target requires an explicit full callsign and category choice`() {
|
||||
val selections = mutableListOf<Pair<ChatMember, StationSelectionSource>>()
|
||||
val state = StationContextMenuState { member, source -> selections += member to source }
|
||||
val main = member("DN9APW-2", 2)
|
||||
val second = member("DN9APW-2", 3)
|
||||
|
||||
state.open(
|
||||
listOf(main, second, main),
|
||||
StationSelectionSource.MAP_CONTEXT_MENU,
|
||||
IntOffset(30, 40),
|
||||
)
|
||||
|
||||
assertNull(state.target)
|
||||
assertEquals(2, state.candidates.size)
|
||||
assertEquals(emptyList<Pair<ChatMember, StationSelectionSource>>(), selections)
|
||||
|
||||
state.chooseTarget(second)
|
||||
|
||||
assertSame(second, state.target)
|
||||
assertEquals(listOf(second to StationSelectionSource.MAP_CONTEXT_MENU), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `target label retains the full suffix and category`() {
|
||||
val target = member("DN9APW-70", 3)
|
||||
|
||||
val label = stationContextTargetLabel(target)
|
||||
|
||||
assertTrue(label.startsWith("DN9APW-70 | "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked menu state follows the existing per-band flags`() {
|
||||
val target = member("DL1ABC", 2)
|
||||
target.isWorked144 = true
|
||||
|
||||
assertTrue(target.isWorkedOn(Band.B_144))
|
||||
assertFalse(target.isWorkedOn(Band.B_432))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `popup anchor is clamped at the window edge`() {
|
||||
val provider = WindowPointPopupPositionProvider(IntOffset(990, 790))
|
||||
|
||||
val position = provider.calculatePosition(
|
||||
anchorBounds = IntRect.Zero,
|
||||
windowSize = IntSize(1_000, 800),
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
popupContentSize = IntSize(300, 250),
|
||||
)
|
||||
|
||||
assertEquals(IntOffset(700, 550), position)
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -227,4 +228,33 @@ class StationFilterBarScreenTest {
|
||||
"bearing buttons are not $expectedWidth px wide: $bearingWidths",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun functionalFilterGroupsRemainDistinctAndInsideANarrowBar() = runComposeUiTest {
|
||||
val width = 300.dp
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(width)) {
|
||||
StationFilterBar(
|
||||
filters = StationFilterState(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val limit = with(density) { width.toPx() }
|
||||
listOf(
|
||||
FILTER_DISTANCE_GROUP_TAG,
|
||||
FILTER_DIRECTION_GROUP_TAG,
|
||||
FILTER_STATION_GROUP_TAG,
|
||||
FILTER_WORKED_GROUP_TAG,
|
||||
FILTER_PROPAGATION_GROUP_TAG,
|
||||
).forEach { tag ->
|
||||
val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bounds.left >= 0f && bounds.right <= limit + 0.5f, "$tag crosses the filter bar")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.MouseButton
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performMouseInput
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import kst4contest.model.AirPlane
|
||||
import kst4contest.model.AirPlaneReflectionInfo
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.EnumSet
|
||||
|
||||
@@ -85,10 +91,15 @@ class StationListScreenTest {
|
||||
private fun ComposeUiTest.show(
|
||||
state: DataTableState<ChatMember>,
|
||||
onRowClick: (ChatMember) -> Unit = {},
|
||||
onRowContextMenu: ((ChatMember, IntOffset) -> Unit)? = null,
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
DataTableView(state = state, onRowClick = onRowClick)
|
||||
DataTableView(
|
||||
state = state,
|
||||
onRowClick = onRowClick,
|
||||
onRowContextMenu = onRowContextMenu,
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
@@ -107,7 +118,7 @@ class StationListScreenTest {
|
||||
val inSecond = station("DL1ABC", category = 3)
|
||||
val clicked = mutableListOf<ChatMember>()
|
||||
|
||||
show(state) { clicked += it }
|
||||
show(state, onRowClick = { clicked += it })
|
||||
runOnIdle { state.replaceRows(listOf(inMain, inSecond)) }
|
||||
waitForIdle()
|
||||
|
||||
@@ -206,6 +217,24 @@ class StationListScreenTest {
|
||||
assertEquals(null, state.selectedRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSecondaryClickReportsTheExactStationAndWindowPosition() = runComposeUiTest {
|
||||
val state = stationList()
|
||||
val member = station("DL1ABC", category = 3)
|
||||
state.replaceRows(listOf(member))
|
||||
val requests = mutableListOf<Pair<ChatMember, IntOffset>>()
|
||||
|
||||
show(state, onRowContextMenu = { target, position -> requests += target to position })
|
||||
onNodeWithText("DL1ABC").performMouseInput {
|
||||
click(button = MouseButton.Secondary)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(1, requests.size)
|
||||
assertSame(member, requests.single().first)
|
||||
assertTrue(requests.single().second.x >= 0 && requests.single().second.y >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest {
|
||||
val selection = StationSelectionState()
|
||||
@@ -259,4 +288,33 @@ class StationListScreenTest {
|
||||
onNodeWithText("DL1MOVE").assertDoesNotExist()
|
||||
onNodeWithText("DL2STAY").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheCallsignCellOfARecentlyJoinedStationIsEmphasized() = runComposeUiTest {
|
||||
val state = stationList()
|
||||
val joined = station("HB9SJV", 2)
|
||||
val existing = station("DL1OLD", 2)
|
||||
val recent = NewJoinedState(clock = { 5_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(listOf(joined, existing))
|
||||
updateEvents(listOf(RecentChatJoin("HB9SJV", 2, 1_000L)))
|
||||
}
|
||||
val emphasized = mutableSetOf<Pair<String, String>>()
|
||||
state.replaceRows(listOf(joined, existing))
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
DataTableView(
|
||||
state = state,
|
||||
emphasizeCell = { member, column ->
|
||||
val emphasize = column.id == "callsign" && recent.isRecent(member)
|
||||
if (emphasize) emphasized += member.callSign to column.id
|
||||
emphasize
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(setOf("HB9SJV" to "callsign"), emphasized)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,36 @@ class StationSelectionStateTest {
|
||||
assertEquals(StationSelectionSource.MAP, state.lastSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct station table selections do not request scrolling`() {
|
||||
val state = StationSelectionState()
|
||||
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
|
||||
|
||||
assertEquals(0L, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `station table context selection does not request scrolling`() {
|
||||
val state = StationSelectionState()
|
||||
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_CONTEXT_MENU)
|
||||
|
||||
assertEquals(0L, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map context selection requests scrolling again for the same station`() {
|
||||
val state = StationSelectionState()
|
||||
val station = station("DL1ABC", 2)
|
||||
|
||||
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
|
||||
val first = state.scrollRequestRevision
|
||||
state.select(station, StationSelectionSource.MAP_CONTEXT_MENU)
|
||||
|
||||
assertTrue(state.scrollRequestRevision > first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() {
|
||||
val state = StationSelectionState()
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.RecentChatJoin
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
|
||||
/** Produces review images for the station filters, join bar and selected-station splitter. */
|
||||
class StationWorkspaceVisualRenderingTest {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "station-workspace-ui")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
listOf(
|
||||
Scenario("wide", 700, 768),
|
||||
Scenario("narrow", 360, 768),
|
||||
Scenario("short", 570, 480),
|
||||
).forEach { scenario ->
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(scenario, darkMode, scale)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
val name = "${scenario.name}-$palette-$scaling.png"
|
||||
Files.write(reportDirectory.resolve(name), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$name did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(scenario: Scenario, darkMode: Boolean, scale: Float): ByteArray {
|
||||
val members = (1..7).map { index -> member("DL${index}ABC", if (index == 7) 3 else 2) }
|
||||
val selectedMember = members.last().apply {
|
||||
qrb = 438.2
|
||||
setQTFdirection(76.0)
|
||||
isQrv432 = false
|
||||
}
|
||||
val newJoined = NewJoinedState(clock = { 20_000L }, onSelect = {}).apply {
|
||||
updateActiveMembers(members)
|
||||
updateEvents(members.mapIndexed { index, member ->
|
||||
RecentChatJoin(member.callSign, member.chatCategory.categoryNumber, 10_000L + index)
|
||||
})
|
||||
}
|
||||
val stations = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "12 dB" },
|
||||
priorityScoreOf = { 500.0 },
|
||||
formatBand = { _, _, _ -> "" },
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
).apply { replaceRows(members) }
|
||||
val priority = TopPriorityState(
|
||||
topStations = { listOf(members[4] to 767.0, members[3] to 740.0) },
|
||||
onSelect = {},
|
||||
).apply { refresh() }
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { "Bands: 144/432 MHz" },
|
||||
).apply { select(selectedMember) }
|
||||
val selectedMessages = DataTableState(
|
||||
SelectedStationMessageColumns.all(),
|
||||
RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
val scene = ImageComposeScene(
|
||||
width = 1366,
|
||||
height = scenario.height,
|
||||
density = Density(scale),
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(scenario.width.dp)
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
) {
|
||||
StationFilterBar(
|
||||
filters = StationFilterState(),
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)),
|
||||
onChanged = {},
|
||||
)
|
||||
DataTableView(
|
||||
state = stations,
|
||||
modifier = Modifier.weight(0.45f),
|
||||
emphasizeCell = { member, column ->
|
||||
column.id == "callsign" && newJoined.isRecent(member)
|
||||
},
|
||||
)
|
||||
TopPriorityBar(
|
||||
state = priority,
|
||||
newJoined = newJoined,
|
||||
onShowMore = {},
|
||||
)
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = selectedMessages,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {},
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.weight(0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = "JO31AA"
|
||||
name = "Contest station"
|
||||
}
|
||||
|
||||
private data class Scenario(val name: String, val width: Int, val height: Int)
|
||||
}
|
||||
+51
-1
@@ -5,6 +5,7 @@ import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot
|
||||
import kst4contest.view.map.MapViewport
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -15,6 +16,7 @@ import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* What the map actually puts on the canvas.
|
||||
@@ -44,6 +46,8 @@ class ComposeStationMapDrawingTest {
|
||||
|
||||
private val darkMapBackground = 0x23282D
|
||||
|
||||
private val workedGridBorder = 0x000000
|
||||
|
||||
/*
|
||||
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
|
||||
* construction. The default fetcher read the user's disk cache and the real tile
|
||||
@@ -64,6 +68,8 @@ class ComposeStationMapDrawingTest {
|
||||
zoom: Float,
|
||||
markers: List<MapCallsignRawSnapshot> = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
|
||||
ownLocator6: String = "",
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
): BufferedImage {
|
||||
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
|
||||
ComposeStationMap(
|
||||
@@ -77,7 +83,8 @@ class ComposeStationMapDrawingTest {
|
||||
antennaBeamWidthDeg = 0.0,
|
||||
maxQrbKm = 0.0,
|
||||
onMarkerClicked = null,
|
||||
darkMode = true,
|
||||
workedGrossFields = workedGrossFields,
|
||||
darkMode = darkMode,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -229,6 +236,49 @@ class ComposeStationMapDrawingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked gross fields use a visible black border in both themes`() {
|
||||
val dark = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"))
|
||||
val light = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"), darkMode = false)
|
||||
|
||||
assertTrue(countColour(dark, workedGridBorder) > 0) {
|
||||
"the Evening map has no black Worked-grid border"
|
||||
}
|
||||
assertTrue(countColour(light, workedGridBorder) > 0) {
|
||||
"the Day map has no black Worked-grid border"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `worked gross field interior is covered by thirty percent black`() {
|
||||
val viewport = MapViewport(10.0, 51.0, 6.0, canvasWidth.toDouble(), canvasHeight.toDouble())
|
||||
val cell = visibleWorkedGrossFieldCells(viewport, setOf("JO51")).single()
|
||||
val x = viewport.screenX(cell.westLon() + (cell.eastLon() - cell.westLon()) * 0.25).toInt()
|
||||
val y = viewport.screenY(cell.northLat() + (cell.southLat() - cell.northLat()) * 0.25).toInt()
|
||||
|
||||
for (darkMode in listOf(false, true)) {
|
||||
val withoutWorked = render(6f, markers = emptyList(), darkMode = darkMode)
|
||||
val withWorked = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
workedGrossFields = setOf("JO51"),
|
||||
darkMode = darkMode,
|
||||
)
|
||||
val before = withoutWorked.getRGB(x, y)
|
||||
val after = withWorked.getRGB(x, y)
|
||||
|
||||
for (shift in listOf(16, 8, 0)) {
|
||||
val beforeChannel = (before shr shift) and 0xFF
|
||||
val afterChannel = (after shr shift) and 0xFF
|
||||
val expected = (beforeChannel * 0.70).roundToInt()
|
||||
assertTrue(abs(afterChannel - expected) <= 2) {
|
||||
"Worked fill in darkMode=$darkMode changed channel $shift from " +
|
||||
"$beforeChannel to $afterChannel; expected $expected for 30% black"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every grid line has to be drawn once.
|
||||
*
|
||||
|
||||
+63
@@ -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.
|
||||
|
||||
+25
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user