mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-24 08:06:25 +02:00
fix: address Compose UI regressions
This commit is contained in:
1 parent
936efdd8dd
commit
971fb35109
66 files changed
+4436
-766
No files matched your search
@@ -256,18 +256,21 @@ filter.onlyNewGrids=Nur neue Felder
|
||||
filter.gridColour=Feldfarbe
|
||||
filter.newBands=Neue Bänder
|
||||
filter.tropo=Tropo >=0dB
|
||||
table.empty=Keine Einträge
|
||||
selected.none=Keine Station gewählt.
|
||||
selected.priority=Priorität:
|
||||
selected.more=mehr
|
||||
selected.showMessages=Nachrichten zeigen
|
||||
selected.createSked=Sked anlegen
|
||||
selected.sked=Sked
|
||||
selected.skedInMinutes=Sked in (Min.)
|
||||
selected.sked=Sked alert
|
||||
selected.skedIn=Sked in
|
||||
selected.skedMinutesAt=Min., auf
|
||||
selected.skedModeLink=, Mode
|
||||
selected.minutes=Min.
|
||||
selected.skedFail=Sked fehlgeschlagen
|
||||
selected.remindPmIn=Erinnerungs-PM in
|
||||
selected.reminderOffsets=Erinnerungsabstände
|
||||
selected.resetFail=Zurücksetzen fehlgeschlagen
|
||||
selected.tagNotQrv=Als NOT QRV markieren
|
||||
selected.tagNotQrv={0} an folgenden Bändern als NOT QRV markieren:
|
||||
selected.band=Band
|
||||
selected.mode=Mode
|
||||
selected.lookUpAndShow=Nachschlagen und zeigen
|
||||
@@ -280,6 +283,12 @@ selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.)
|
||||
candidates.title=Stationen mit höchster Priorität
|
||||
candidates.none=Noch keine Kandidaten.
|
||||
candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus.
|
||||
timeline.inBeam1=Im Strahl 1
|
||||
timeline.inBeam2=Im Strahl 2
|
||||
timeline.priority1=Priorität 1
|
||||
timeline.priority2=Priorität 2
|
||||
timeline.skeds=Skeds
|
||||
timeline.minuteSuffix=min
|
||||
update.title=Aktualisierungsinformationen
|
||||
update.available=Aktualisierung verfügbar!
|
||||
update.installedVersion=Installierte Version:
|
||||
@@ -294,7 +303,9 @@ update.versions=Versionen
|
||||
map.title=Stationskarte
|
||||
map.tab=Karte
|
||||
map.resetView=Ansicht zurücksetzen
|
||||
map.groupNearby=Nahe Stationen gruppieren
|
||||
map.groupNearby=Gruppierung
|
||||
map.groupNearbyTooltip=Nahe beieinanderliegende Stationen auf der Karte gruppieren.
|
||||
map.alwaysOnTop=Immer im Vordergrund
|
||||
map.triggerClusterSpot=Cluster-Spot auslösen
|
||||
map.showPathAnalysis=Streckenanalyse zeigen
|
||||
map.hidePathAnalysis=Streckenanalyse ausblenden
|
||||
|
||||
@@ -265,18 +265,21 @@ filter.onlyNewGrids=Only new grids
|
||||
filter.gridColour=Grid color
|
||||
filter.newBands=New bands
|
||||
filter.tropo=Tropo >=0dB
|
||||
table.empty=No entries
|
||||
selected.none=No station selected.
|
||||
selected.priority=Priority:
|
||||
selected.more=more
|
||||
selected.showMessages=Show messages
|
||||
selected.createSked=Create sked
|
||||
selected.sked=Sked
|
||||
selected.skedInMinutes=Sked in (min)
|
||||
selected.sked=Sked alert
|
||||
selected.skedIn=Sked in
|
||||
selected.skedMinutesAt=min, at
|
||||
selected.skedModeLink=, mode
|
||||
selected.minutes=min
|
||||
selected.skedFail=Sked fail
|
||||
selected.remindPmIn=Remind-PM in
|
||||
selected.reminderOffsets=Reminder offsets
|
||||
selected.remindPmIn=Remind pm in
|
||||
selected.resetFail=Reset fail
|
||||
selected.tagNotQrv=Tag not QRV
|
||||
selected.tagNotQrv=Tag {0} not QRV at bands:
|
||||
selected.band=Band
|
||||
selected.mode=Mode
|
||||
selected.lookUpAndShow=Look up and show
|
||||
@@ -289,6 +292,12 @@ selected.lastActivity=Last activity: {0} ({1} min ago)
|
||||
candidates.title=Top priority candidates
|
||||
candidates.none=No candidates yet.
|
||||
candidates.doubleClick=Double-click a candidate to select it.
|
||||
timeline.inBeam1=In beam 1
|
||||
timeline.inBeam2=In beam 2
|
||||
timeline.priority1=Priority 1
|
||||
timeline.priority2=Priority 2
|
||||
timeline.skeds=Skeds
|
||||
timeline.minuteSuffix=min
|
||||
update.title=Update information
|
||||
update.available=Update available!
|
||||
update.installedVersion=Installed version:
|
||||
@@ -303,7 +312,9 @@ update.versions=Versions
|
||||
map.title=Station Map
|
||||
map.tab=Map
|
||||
map.resetView=Reset view
|
||||
map.groupNearby=Group nearby stations
|
||||
map.groupNearby=Grouping
|
||||
map.groupNearbyTooltip=Group stations that are close together on the map.
|
||||
map.alwaysOnTop=Always on top
|
||||
map.triggerClusterSpot=Trigger cluster spot
|
||||
map.showPathAnalysis=Show path analysis
|
||||
map.hidePathAnalysis=Hide path analysis
|
||||
|
||||
@@ -10,6 +10,7 @@ import kst4contest.view.compose.DataTableState;
|
||||
import kst4contest.view.compose.MonitorColumns;
|
||||
import kst4contest.view.compose.MonitorWindow;
|
||||
import kst4contest.view.compose.RowKeys;
|
||||
import kst4contest.view.compose.ReachabilityState;
|
||||
import kst4contest.view.compose.BlinkingNotice;
|
||||
import kst4contest.view.compose.ThreadStatusButtons;
|
||||
import kst4contest.view.compose.StationColumns;
|
||||
@@ -18,7 +19,12 @@ import kst4contest.view.compose.DirectedMessageColumns;
|
||||
import kst4contest.view.compose.PublicMessageColumns;
|
||||
import kst4contest.view.compose.SelectedMessageFilter;
|
||||
import kst4contest.view.compose.SelectedStationMessageColumns;
|
||||
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||
import kst4contest.view.compose.SelectedStationState;
|
||||
import kst4contest.view.compose.DirectedMessageStationTarget;
|
||||
import kst4contest.view.compose.StationIdentity;
|
||||
import kst4contest.view.compose.StationSelectionSource;
|
||||
import kst4contest.view.compose.StationSelectionState;
|
||||
import kst4contest.view.compose.TopPriorityCandidatesWindow;
|
||||
import kst4contest.view.compose.TopPriorityState;
|
||||
import kst4contest.view.compose.TimelineState;
|
||||
@@ -121,8 +127,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
private Predicate<ChatMember> lastAppliedChatMemberFilterPredicate = null;
|
||||
|
||||
|
||||
private Band selectedReachabilityBandOverride = null;
|
||||
|
||||
private kst4contest.view.feed.TimelineFeed timelineFeed;
|
||||
private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed;
|
||||
private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed;
|
||||
@@ -264,6 +268,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies one radio mode to one exact station identity. */
|
||||
private void applySelectedStationMessageFilter(
|
||||
SelectedMessageFilter filter,
|
||||
ChatMember selectedMember) {
|
||||
if (filter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
applySelectedCallSignInfoFilter(SelectedStationMessagePredicate.forSelection(
|
||||
filter,
|
||||
selectedMember,
|
||||
chatcontroller.getChatPreferences().getStn_loginCallSign()));
|
||||
}
|
||||
|
||||
/** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */
|
||||
|
||||
|
||||
@@ -2520,7 +2538,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path logFile = Path.of(ApplicationFileUtils.getFilePath(
|
||||
Path logFile = Path.of(ApplicationFileUtils.getDebugFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
"kst4contest-errors.log"));
|
||||
// Start conservatively until the persisted preference has been loaded.
|
||||
@@ -3050,20 +3068,45 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
* arrange separately while both windows are on screen; sharing the ids would let a
|
||||
* drag in one silently rearrange the other.
|
||||
*/
|
||||
EnumSet<Band> enabledStationBands =
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||
ReachabilityState reachability = new ReachabilityState(
|
||||
enabledStationBands,
|
||||
member -> chatcontroller.getReachabilityService().resolveAutoBand(member),
|
||||
() -> composeMainWindowState == null
|
||||
? null : composeMainWindowState.getSelectedStation().getSelected(),
|
||||
(member, band) -> {
|
||||
chatcontroller.getReachabilityService()
|
||||
.calculateSelectedStationOnDemand(member, band);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
() -> {
|
||||
forceChatMemberFilterRefresh();
|
||||
if (composeMainWindowState != null) {
|
||||
composeMainWindowState.getStations().forceRedraw();
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
StationSelectionState stationSelection = new StationSelectionState();
|
||||
|
||||
DataTableState<ChatMember> stations = new DataTableState<>(
|
||||
StationColumns.INSTANCE.all(
|
||||
member -> {
|
||||
Band band = resolveReachabilityBandForUi(member);
|
||||
Band band = reachability.resolveBand(member);
|
||||
chatcontroller.getReachabilityService()
|
||||
.ensureTropoMarginCalculated(member, band);
|
||||
return member.formatTropoSsbMarginForBand(band);
|
||||
},
|
||||
member -> parseTableDouble(formatPriorityScore(member)),
|
||||
(member, band, worked) -> formatBandCellStatus(member, band, worked),
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
|
||||
enabledStationBands),
|
||||
RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey),
|
||||
"compose-stations",
|
||||
widths);
|
||||
widths,
|
||||
"callsign",
|
||||
true,
|
||||
stationSelection,
|
||||
true);
|
||||
|
||||
DataTableState<ChatMessage> directed = new DataTableState<>(
|
||||
DirectedMessageColumns.INSTANCE.all(
|
||||
@@ -3115,44 +3158,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
if (filter == null) {
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
Predicate<ChatMessage> predicate;
|
||||
switch (filter) {
|
||||
case PM_TO_ME:
|
||||
predicate = chatMessage -> {
|
||||
try {
|
||||
if (chatMessage.getReceiver().getCallSign().equals("ALL") && !(chatMessage.getMessageText().toLowerCase().contains(chatcontroller.getChatPreferences().getStn_loginCallSign().toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
return ((chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) || (chatMessage.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()))) &&
|
||||
((chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())) || (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign())));
|
||||
} catch (Exception e) { return false; }
|
||||
};
|
||||
break;
|
||||
case PM_TO_OTHER:
|
||||
predicate = chatMessage -> {
|
||||
try {
|
||||
return (chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) ||
|
||||
(chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && !chatMessage.getReceiver().getCallSign().equals("ALL") && !chatMessage.getReceiver().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign()));
|
||||
} catch (Exception e) { return false; }
|
||||
};
|
||||
break;
|
||||
case PUBLIC:
|
||||
predicate = chatMessage -> {
|
||||
try {
|
||||
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) && chatMessage.getReceiver().getCallSign().equals("ALL");
|
||||
} catch (Exception e) { return false; }
|
||||
};
|
||||
break;
|
||||
case NOTHING:
|
||||
default:
|
||||
predicate = chatMessage -> {
|
||||
try {
|
||||
return chatMessage.getSender().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign()) || chatMessage.getReceiver().getCallSign().equals(selectedCallSignInfoStageChatMember.getCallSign());
|
||||
} catch (Exception e) { return false; }
|
||||
};
|
||||
break;
|
||||
}
|
||||
applySelectedCallSignInfoFilter(predicate);
|
||||
applySelectedStationMessageFilter(filter, selectedCallSignInfoStageChatMember);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
member -> {
|
||||
@@ -3174,24 +3180,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
surroundings::getSecondAntennaOffered);
|
||||
surroundings::getSecondAntennaOffered,
|
||||
stationSelection);
|
||||
|
||||
if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
|
||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
|
||||
} else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) {
|
||||
selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_OTHER);
|
||||
} else {
|
||||
selectedStation.setMessageFilter(SelectedMessageFilter.NOTHING);
|
||||
}
|
||||
selectedStation.setMessageFilter(
|
||||
SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences()));
|
||||
|
||||
TopPriorityState topPriority = new TopPriorityState(
|
||||
this::composeTopStations,
|
||||
member -> selectStationInCompose(selectedStation, stations, member));
|
||||
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY));
|
||||
|
||||
ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
|
||||
|
||||
composeMainWindowState = new MainWindowState(
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()),
|
||||
enabledStationBands,
|
||||
chatcontroller.getChatPreferences(),
|
||||
stations,
|
||||
new StationFilterState(),
|
||||
@@ -3238,7 +3240,8 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
.setGUImainWindowRightSplitPane_dividerposition(positions);
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}));
|
||||
}),
|
||||
reachability);
|
||||
|
||||
/*
|
||||
* The feed, not the window construction, owns the timeline's data from here.
|
||||
@@ -3249,6 +3252,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
composeMainWindowState.getTimeline(), uiDispatcher);
|
||||
selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed(
|
||||
composeMainWindowState.getSelectedStationMessages(), uiDispatcher);
|
||||
/*
|
||||
* Build the live message mirror before the first Compose selection. The
|
||||
* selection path already records the selected member before its legacy focus
|
||||
* callback runs, so waiting for that callback leaves the mirror null and the
|
||||
* table cannot receive either existing or newly arriving messages.
|
||||
*/
|
||||
rebuildSelectedCallSignInfoMessageBinding();
|
||||
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
|
||||
connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed(
|
||||
composeMainWindowState.getSurroundings(), uiDispatcher);
|
||||
|
||||
@@ -3261,8 +3272,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
|
||||
chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
|
||||
|
||||
composeMainWindowState.setStationSelector(member -> {
|
||||
selectStationInCompose(selectedStation, stations, member);
|
||||
composeMainWindowState.setStationSelector((member, source) -> {
|
||||
selectStationInCompose(selectedStation, member, source);
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
composeMainWindowState.setStationFiltersChanged(() -> {
|
||||
updateComposeFilters();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
composeMainWindowState.setLayoutSaveRequested(() -> {
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
|
||||
@@ -3336,7 +3355,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
closeWindowEvent();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
() -> composeSkedBands(selectedStation.getSelected()),
|
||||
address -> runOnUi(() -> ExternalDocuments.open(address)),
|
||||
() -> {
|
||||
runOnUi(() -> showSelectedCallsignOnMap());
|
||||
@@ -3348,8 +3366,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
}
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
member -> selectStationInCompose(selectedStation, stations, member),
|
||||
message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message),
|
||||
member -> selectStationInCompose(selectedStation, member, StationSelectionSource.STATION_TABLE),
|
||||
message -> onDirectedMessageClickedInCompose(selectedStation, composeMainWindowState.getChatInput(), message),
|
||||
(message, source) -> onMessageSenderClickedInCompose(selectedStation, message, source),
|
||||
this::isOwnChatMessage,
|
||||
this::chatMessageAgeSeconds,
|
||||
() -> {
|
||||
@@ -3367,7 +3386,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
ChatMember resolved = resolveChatMemberForCallRawAndCategory(
|
||||
candidate.getCallSignRaw(), candidate.getPreferredChatCategory());
|
||||
if (resolved != null) {
|
||||
selectStationInCompose(selectedStation, stations, resolved);
|
||||
selectStationInCompose(selectedStation, resolved, StationSelectionSource.TIMELINE);
|
||||
composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false);
|
||||
|
||||
// Sync with JavaFX state just in case
|
||||
@@ -3462,28 +3481,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
return activeProfile.getProfile().getDisplayName();
|
||||
}
|
||||
|
||||
/** The bands a sked can be arranged on for this station: enabled here, available there. */
|
||||
private java.util.List<Band> composeSkedBands(ChatMember selectedMember) {
|
||||
if (selectedMember == null) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
|
||||
java.util.List<ChatMember> variants =
|
||||
chatcontroller.findActiveChatMembersByRawCall(selectedMember.getCallSignRaw());
|
||||
if (variants.isEmpty()) {
|
||||
variants = java.util.List.of(selectedMember);
|
||||
}
|
||||
|
||||
BandOpportunityResolver.Resolution resolution =
|
||||
BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
|
||||
EnumSet<Band> enabledBands =
|
||||
BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
|
||||
|
||||
EnumSet<Band> available = EnumSet.copyOf(resolution.getAvailableBands());
|
||||
available.retainAll(enabledBands);
|
||||
return new ArrayList<>(available);
|
||||
}
|
||||
|
||||
/** The score service's ranking, as the Compose priority bar wants it. */
|
||||
private java.util.List<kotlin.Pair<ChatMember, Double>> composeTopStations() {
|
||||
java.util.List<kotlin.Pair<ChatMember, Double>> out = new ArrayList<>();
|
||||
@@ -3515,21 +3512,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
*/
|
||||
private kotlin.Unit selectStationInCompose(
|
||||
SelectedStationState panel,
|
||||
DataTableState<ChatMember> table,
|
||||
ChatMember member) {
|
||||
ChatMember member,
|
||||
StationSelectionSource source) {
|
||||
|
||||
panel.select(member);
|
||||
table.select(member);
|
||||
panel.select(member, source);
|
||||
|
||||
selectedCallSignInfoStageChatMember = member;
|
||||
if (selectedCallSignInfoMessageBinding != null) {
|
||||
selectedCallSignInfoMessageBinding.refresh();
|
||||
uiDispatcher.runOnUi(() -> {
|
||||
if (selectedStationMessagesFeed != null) {
|
||||
selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list());
|
||||
}
|
||||
});
|
||||
}
|
||||
applySelectedStationMessageFilter(panel.getMessageFilter(), member);
|
||||
|
||||
if (member != null && chatcontroller.getScoreService() != null) {
|
||||
chatcontroller.getScoreService().setSelectedChatMember(member);
|
||||
@@ -3545,26 +3534,41 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
|
||||
private kotlin.Unit onDirectedMessageClickedInCompose(
|
||||
SelectedStationState panel,
|
||||
DataTableState<ChatMember> stationsTable,
|
||||
ChatInputState chatInput,
|
||||
ChatMessage message) {
|
||||
|
||||
|
||||
String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign();
|
||||
StationIdentity target = DirectedMessageStationTarget.forMessage(message, ownCall);
|
||||
if (target == null) {
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
|
||||
ChatMember activeTarget = chatcontroller.findActiveChatMember(target.getCallSign(), message.getChatCategory());
|
||||
if (activeTarget != null) {
|
||||
selectStationInCompose(panel, activeTarget, StationSelectionSource.DIRECTED_MESSAGE);
|
||||
} else {
|
||||
chatInput.prepareCq(target.getCallSign(), false);
|
||||
if (message.getSender() != null && message.getSender().getCallSign().equalsIgnoreCase(ownCall)) {
|
||||
rememberAutoPreparedCqTarget(target.getCallSign(), message.getChatCategory());
|
||||
}
|
||||
}
|
||||
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
|
||||
private kotlin.Unit onMessageSenderClickedInCompose(
|
||||
SelectedStationState panel,
|
||||
ChatMessage message,
|
||||
StationSelectionSource source) {
|
||||
if (message == null || message.getSender() == null) {
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
|
||||
if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) {
|
||||
String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")"));
|
||||
chatInput.prepareCq(receiverCallsign, false);
|
||||
rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory());
|
||||
} else {
|
||||
selectStationInCompose(panel, stationsTable, message.getSender());
|
||||
|
||||
chatInput.prepareCq(message.getSender().getCallSign(), false);
|
||||
prepareCqTextForSelectedChatMember(message.getSender(), false);
|
||||
focusChatMemberAndPrepareCq(message.getSender(), false);
|
||||
ChatMember sender = chatcontroller.findActiveChatMember(
|
||||
message.getSender().getCallSign(), message.getChatCategory());
|
||||
if (sender != null) {
|
||||
selectStationInCompose(panel, sender, source);
|
||||
}
|
||||
|
||||
return kotlin.Unit.INSTANCE;
|
||||
}
|
||||
|
||||
@@ -3825,7 +3829,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
|
||||
SettingsTabsKt.settingsButtons(
|
||||
chatcontroller,
|
||||
this,
|
||||
settingsNotices,
|
||||
draft,
|
||||
this::settingsConnectionState));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -4222,31 +4231,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
* @return manually selected band or automatic band
|
||||
*/
|
||||
private Band resolveReachabilityBandForUi(ChatMember member) {
|
||||
if (selectedReachabilityBandOverride != null) {
|
||||
return selectedReachabilityBandOverride;
|
||||
if (composeMainWindowState != null) {
|
||||
return composeMainWindowState.getReachability().resolveBand(member);
|
||||
}
|
||||
return chatcontroller.getReachabilityService().resolveAutoBand(member);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the reachability ComboBox selection.
|
||||
*
|
||||
* @param selectedLabel selected UI text
|
||||
* @return selected band, or null for Auto
|
||||
*/
|
||||
private Band parseReachabilityBandSelection(String selectedLabel) {
|
||||
if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (Band band : Band.values()) {
|
||||
if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the projected priority score for the station table.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,6 @@ package kst4contest.view.compose
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,9 +35,8 @@ import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
@@ -59,9 +56,13 @@ fun ShortcutButtonRow(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
|
||||
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
shortcuts.forEach { shortcut ->
|
||||
when (shortcut) {
|
||||
@@ -109,38 +110,9 @@ private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit
|
||||
return
|
||||
}
|
||||
|
||||
val palette = LocalJavaFxPalette.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||
.background(
|
||||
brush = if (palette.windowBackground.luminance() < 0.5f) {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||
)
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||
)
|
||||
},
|
||||
shape = Density.BUTTON_SHAPE
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(Density.BUTTON_CONTENT_PADDING),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = MYQRG_LABEL_COLOR,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Form.button(text = label, accented = true, onClick = onClick)
|
||||
}
|
||||
|
||||
/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
|
||||
private val MYQRG_LABEL_COLOR = Color(0xFF395306)
|
||||
|
||||
/**
|
||||
* The send row: the message field, TX, clear, and the three small fields the operator
|
||||
* keeps their own frequencies and antenna heading in.
|
||||
@@ -207,6 +179,7 @@ fun ChatInputRow(
|
||||
stored = antennaQtf.value?.toString().orEmpty(),
|
||||
onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
|
||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip,
|
||||
width = QTF_FIELD_WIDTH,
|
||||
)
|
||||
if (so2rEnabled) {
|
||||
// Display only: rotator 2 writes it, the operator does not.
|
||||
@@ -215,6 +188,7 @@ fun ChatInputRow(
|
||||
onCommit = { },
|
||||
tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip,
|
||||
editable = false,
|
||||
width = QTF_FIELD_WIDTH,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -234,6 +208,7 @@ private fun InlineCommittedField(
|
||||
onCommit: (String) -> Unit,
|
||||
tooltip: String,
|
||||
editable: Boolean = true,
|
||||
width: Dp = QRG_FIELD_WIDTH,
|
||||
) {
|
||||
var draft by remember(stored) { mutableStateOf(stored) }
|
||||
|
||||
@@ -243,7 +218,7 @@ private fun InlineCommittedField(
|
||||
onValueChange = { draft = it },
|
||||
enabled = editable,
|
||||
modifier = Modifier
|
||||
.width(QRG_FIELD_WIDTH)
|
||||
.width(width)
|
||||
.onFocusChanged { focus ->
|
||||
if (!focus.isFocused && draft != stored) onCommit(draft)
|
||||
},
|
||||
@@ -267,7 +242,8 @@ private fun TooltipChip(text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private val QRG_FIELD_WIDTH = 70.dp
|
||||
private val QRG_FIELD_WIDTH = 96.dp
|
||||
private val QTF_FIELD_WIDTH = 56.dp
|
||||
|
||||
/**
|
||||
* The message field.
|
||||
@@ -276,6 +252,7 @@ private val QRG_FIELD_WIDTH = 70.dp
|
||||
* both were on the scene in JavaFX, which means they worked wherever the focus was; here
|
||||
* they are on the field, and the window handles them for the rest of the surface.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun MessageField(
|
||||
state: ChatInputState,
|
||||
@@ -284,12 +261,21 @@ private fun MessageField(
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
if (state.value.text.isNotBlank()) {
|
||||
TooltipChip(state.value.text)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = Density.FIELD_MIN_HEIGHT)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||
.border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
|
||||
.background(scheme.surface, Density.BUTTON_SHAPE)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 6.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
BasicTextField(
|
||||
@@ -307,10 +293,11 @@ private fun MessageField(
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
|
||||
textStyle = compactControlTextStyle().copy(color = scheme.onSurface),
|
||||
cursorBrush = SolidColor(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The vertical separator that sets the frequency fields apart from the send controls. */
|
||||
@@ -319,7 +306,7 @@ private fun VerticalRule() {
|
||||
Box(
|
||||
Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.height(Density.FIELD_MIN_HEIGHT)
|
||||
.height(Density.CONTROL_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
@@ -7,15 +7,9 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.runtime.Composable
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import kst4contest.view.i18n.Strings
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The connection state the button bar shows. Derived from the controller rather than
|
||||
* held, because the session also connects and drops outside this window.
|
||||
*/
|
||||
enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
|
||||
|
||||
/**
|
||||
* The button bar at the bottom of the settings window.
|
||||
*
|
||||
@@ -26,16 +20,9 @@ enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
|
||||
*/
|
||||
class ConnectionBarState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val controller: ChatController,
|
||||
private val connectionState: () -> On4KstConnectionState,
|
||||
) {
|
||||
|
||||
val connectionState: ConnectionState
|
||||
get() = when {
|
||||
controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
|
||||
controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
|
||||
else -> ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
/**
|
||||
* The label of the Connect button, which names the chat the station will land in —
|
||||
* and both chats when the second one is enabled, so the operator sees before
|
||||
@@ -62,13 +49,13 @@ class ConnectionBarState(
|
||||
return label
|
||||
}
|
||||
|
||||
/** Only a disconnected session can connect; the JavaFX button locked itself. */
|
||||
/** A new attempt is safe only after the manager has completed its disconnect. */
|
||||
val canConnect: Boolean
|
||||
get() = connectionState != ConnectionState.LOGGED_IN
|
||||
get() = connectionState() == On4KstConnectionState.DISCONNECTED
|
||||
|
||||
/** Disconnecting needs a session that is actually logged in. */
|
||||
/** A running attempt, online session or reconnect wait can be stopped by the operator. */
|
||||
val canDisconnect: Boolean
|
||||
get() = connectionState == ConnectionState.LOGGED_IN
|
||||
get() = connectionState().isConnectionAttemptActive
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
|
||||
@@ -162,6 +162,28 @@ interface ColumnWidthStore {
|
||||
fun requestSave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional shared row selection for tables whose selected entity is also displayed by
|
||||
* other components. Ordinary event tables keep their selection inside [DataTableState].
|
||||
*/
|
||||
interface TableSelectionModel<T> {
|
||||
|
||||
val selected: T?
|
||||
|
||||
/** Changes whenever [selected] changes, including a repeated explicit selection. */
|
||||
val selectionRevision: Long
|
||||
|
||||
/** Changes only when the table should scroll to the current selection. */
|
||||
val scrollRequestRevision: Long
|
||||
|
||||
fun select(row: T?)
|
||||
|
||||
/** Replaces the selected instance after a roster refresh without requesting a scroll. */
|
||||
fun reconcile(row: T?)
|
||||
|
||||
fun requestScroll()
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a multi-column table: its rows, its sorting and its selection.
|
||||
*
|
||||
@@ -170,12 +192,16 @@ interface ColumnWidthStore {
|
||||
* data that shows up as a stale display rather than as an unresponsive one — harder
|
||||
* to notice and worse in effect.
|
||||
*/
|
||||
class DataTableState<T>(
|
||||
class DataTableState<T> @JvmOverloads constructor(
|
||||
val columns: List<DataColumn<T>>,
|
||||
private val rowKey: RowKeySource<T>,
|
||||
/** Part of the stored layout: renaming it loses the operator's column sizing. */
|
||||
private val tableId: String = "",
|
||||
private val widths: ColumnWidthStore? = null,
|
||||
initialSortColumnId: String? = null,
|
||||
initialSortAscending: Boolean = true,
|
||||
private val selectionModel: TableSelectionModel<T>? = null,
|
||||
private val keepSelectionVisible: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -251,19 +277,29 @@ class DataTableState<T>(
|
||||
var rows: List<T> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
var sortedColumnId: String? by mutableStateOf(null)
|
||||
var sortedColumnId: String? by mutableStateOf(
|
||||
initialSortColumnId?.takeIf { requested -> columns.any { it.id == requested } },
|
||||
)
|
||||
private set
|
||||
|
||||
var sortAscending: Boolean by mutableStateOf(true)
|
||||
var sortAscending: Boolean by mutableStateOf(initialSortAscending)
|
||||
private set
|
||||
|
||||
private var selectedKey: Any? by mutableStateOf(null)
|
||||
|
||||
private var sortedCache: List<T>? = null
|
||||
private var internalSelectionRevision: Long by mutableStateOf(0L)
|
||||
|
||||
private var internalScrollRequestRevision: Long by mutableStateOf(0L)
|
||||
|
||||
private var filteredCache: List<T>? = null
|
||||
|
||||
private var visibleCache: List<T>? = null
|
||||
|
||||
private var cachedSelectionRevision: Long = Long.MIN_VALUE
|
||||
|
||||
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
|
||||
|
||||
/** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
|
||||
/** Optional filter predicate. If set, [filteredRows] only includes matching rows. */
|
||||
var rowFilter: ((T) -> Boolean)?
|
||||
get() = _rowFilter
|
||||
set(value) {
|
||||
@@ -294,7 +330,7 @@ class DataTableState<T>(
|
||||
* per inbound message on the thread that draws — with a column that has no
|
||||
* comparator calling its value function once per comparison on top.
|
||||
*/
|
||||
val visibleRows: List<T>
|
||||
val filteredRows: List<T>
|
||||
get() {
|
||||
/*
|
||||
* Read before the cache is consulted, and that order is the whole point.
|
||||
@@ -307,45 +343,84 @@ class DataTableState<T>(
|
||||
val currentRows = rows
|
||||
val currentFilter = rowFilter
|
||||
contentRevision // read for the subscription: forceRedraw drops the cache
|
||||
val currentColumn = sortedColumnId
|
||||
val currentColumnId = sortedColumnId
|
||||
val ascending = sortAscending
|
||||
filteredCache?.let { return it }
|
||||
|
||||
sortedCache?.let { return it }
|
||||
val filtered = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
||||
val sorted = sort(filtered, currentColumnId, ascending)
|
||||
|
||||
val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
|
||||
|
||||
val column = columns.firstOrNull { it.id == currentColumn }
|
||||
val sorted = if (column == null) {
|
||||
filteredRows
|
||||
} else {
|
||||
/*
|
||||
* The displayed text is read once per row rather than once per
|
||||
* comparison, and sortedBy is stable, so rows that compare equal keep
|
||||
* the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
|
||||
* inserted by binary search and put ties wherever it landed, which made
|
||||
* rows jump.
|
||||
*/
|
||||
val order = column.comparator
|
||||
?: compareBy(filteredRows.associateWith { column.value(it) }::getValue)
|
||||
|
||||
if (ascending) {
|
||||
filteredRows.sortedWith(order)
|
||||
} else {
|
||||
filteredRows.sortedWith(order.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
sortedCache = sorted
|
||||
filteredCache = sorted
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows drawn by this table. A station selected outside the table can optionally be
|
||||
* included even when the active filter rejects it; [filteredRows] remains the exact
|
||||
* shared filter result used by the map.
|
||||
*/
|
||||
val visibleRows: List<T>
|
||||
get() {
|
||||
val currentRows = rows
|
||||
val filtered = filteredRows
|
||||
val revision = selectionRevision
|
||||
|
||||
if (!keepSelectionVisible) {
|
||||
return filtered
|
||||
}
|
||||
|
||||
if (visibleCache != null && cachedSelectionRevision == revision) {
|
||||
return visibleCache.orEmpty()
|
||||
}
|
||||
|
||||
val selected = selectedRow
|
||||
val visible = if (selected == null || filtered.any { keyOf(it) == keyOf(selected) }) {
|
||||
filtered
|
||||
} else if (currentRows.any { keyOf(it) == keyOf(selected) }) {
|
||||
val filteredKeys = filtered.mapTo(HashSet<Any>()) { row -> keyOf(row) }
|
||||
val selectedKey = keyOf(selected)
|
||||
sort(currentRows.filter { row -> keyOf(row) == selectedKey || keyOf(row) in filteredKeys })
|
||||
} else {
|
||||
filtered
|
||||
}
|
||||
|
||||
cachedSelectionRevision = revision
|
||||
visibleCache = visible
|
||||
return visible
|
||||
}
|
||||
|
||||
private fun sort(
|
||||
source: List<T>,
|
||||
columnId: String? = sortedColumnId,
|
||||
ascending: Boolean = sortAscending,
|
||||
): List<T> {
|
||||
val column = columns.firstOrNull { it.id == columnId } ?: return source
|
||||
|
||||
/*
|
||||
* The displayed text is read once per row rather than once per comparison, and
|
||||
* Kotlin's sort is stable, so equal values keep their source order.
|
||||
*/
|
||||
val order = column.comparator
|
||||
?: compareBy(source.associateWith { column.value(it) }::getValue)
|
||||
|
||||
return if (ascending) source.sortedWith(order) else source.sortedWith(order.reversed())
|
||||
}
|
||||
|
||||
private fun invalidateOrder() {
|
||||
sortedCache = null
|
||||
filteredCache = null
|
||||
visibleCache = null
|
||||
}
|
||||
|
||||
/** The selected row, or null when nothing is selected or it has gone. */
|
||||
val selectedRow: T?
|
||||
get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||
get() = selectionModel?.selected
|
||||
?: selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||
|
||||
val selectionRevision: Long
|
||||
get() = selectionModel?.selectionRevision ?: internalSelectionRevision
|
||||
|
||||
val scrollRequestRevision: Long
|
||||
get() = selectionModel?.scrollRequestRevision ?: internalScrollRequestRevision
|
||||
|
||||
/**
|
||||
* Replaces the rows.
|
||||
@@ -356,18 +431,43 @@ class DataTableState<T>(
|
||||
* neighbour, which would silently point the operator at someone else.
|
||||
*/
|
||||
fun replaceRows(newRows: List<T>) {
|
||||
val selectedIdentity = selectionModel?.selected?.let(rowKey::keyOf) ?: selectedKey
|
||||
|
||||
rows = newRows
|
||||
rowKey.retainOnly(newRows)
|
||||
invalidateOrder()
|
||||
|
||||
val key = selectedKey
|
||||
if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
|
||||
selectedKey = null
|
||||
val replacement = selectedIdentity?.let { key -> newRows.firstOrNull { rowKey.keyOf(it) == key } }
|
||||
|
||||
if (selectionModel != null) {
|
||||
if (selectedIdentity != null) {
|
||||
selectionModel.reconcile(replacement)
|
||||
}
|
||||
} else if (selectedIdentity != null) {
|
||||
selectedKey = replacement?.let(rowKey::keyOf)
|
||||
internalSelectionRevision++
|
||||
}
|
||||
}
|
||||
|
||||
fun select(row: T?) {
|
||||
selectedKey = row?.let(rowKey::keyOf)
|
||||
if (selectionModel != null) {
|
||||
selectionModel.select(row)
|
||||
} else {
|
||||
selectedKey = row?.let(rowKey::keyOf)
|
||||
internalSelectionRevision++
|
||||
}
|
||||
visibleCache = null
|
||||
}
|
||||
|
||||
/** Requests navigation to the current selection without changing it. */
|
||||
fun requestScrollToSelection() {
|
||||
if (selectedRow == null) return
|
||||
|
||||
if (selectionModel != null) {
|
||||
selectionModel.requestScroll()
|
||||
} else {
|
||||
internalScrollRequestRevision++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,7 +477,10 @@ class DataTableState<T>(
|
||||
*/
|
||||
fun keyOf(row: T): Any = rowKey.keyOf(row)
|
||||
|
||||
fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
|
||||
fun isSelected(row: T): Boolean {
|
||||
val selected = selectedRow ?: return false
|
||||
return rowKey.keyOf(row) == rowKey.keyOf(selected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts by a column, cycling ascending, descending, unsorted.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -49,7 +48,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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 kst4contest.view.i18n.LocalStrings
|
||||
|
||||
/**
|
||||
* A multi-column table.
|
||||
@@ -78,8 +79,10 @@ fun <T> DataTableView(
|
||||
modifier: Modifier = Modifier,
|
||||
rowStyle: ((T) -> CellAccent?)? = null,
|
||||
onRowClick: ((T) -> Unit)? = null,
|
||||
selectOnRowClick: Boolean = true,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val automaticWidths = rememberAutomaticColumnWidths(state)
|
||||
|
||||
val horizontal = rememberScrollState()
|
||||
val vertical = rememberLazyListState()
|
||||
@@ -113,16 +116,36 @@ fun <T> DataTableView(
|
||||
}
|
||||
}
|
||||
|
||||
var handledScrollRequest by remember(state) { mutableStateOf(0L) }
|
||||
val scrollRequest = state.scrollRequestRevision
|
||||
val displayedRows = state.visibleRows
|
||||
|
||||
/*
|
||||
* Resolve the index only after reading the current filtered and sorted rows. If the
|
||||
* selected row has not arrived yet, leave the request pending and retry when that
|
||||
* list changes. A handled request is never repeated by an ordinary data refresh.
|
||||
*/
|
||||
LaunchedEffect(scrollRequest, displayedRows) {
|
||||
if (scrollRequest <= handledScrollRequest) return@LaunchedEffect
|
||||
|
||||
val index = displayedRows.indexOfFirst(state::isSelected)
|
||||
if (index >= 0) {
|
||||
vertical.scrollToItem(index)
|
||||
handledScrollRequest = scrollRequest
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Both directions scroll. The columns of these two tables need about 890dp and
|
||||
* 1000dp, and the stored window width starts at 700: without this the last columns
|
||||
* are simply unreachable, and so are the handles that would narrow them. The JavaFX
|
||||
* tables had a horizontal scrollbar.
|
||||
*/
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val slack = unusedWidth(state, maxWidth)
|
||||
val lastColumnId = state.orderedColumns.lastOrNull()?.id
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
/*
|
||||
* A second header row above the first, spanning the columns that share a
|
||||
@@ -150,9 +173,8 @@ fun <T> DataTableView(
|
||||
|
||||
val width = (index until index + span).sumOf { position ->
|
||||
val column = state.orderedColumns[position]
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||
effectiveColumnWidthDp(state, column, automaticWidths).toDouble() +
|
||||
Density.COLUMN_HANDLE_WIDTH.value
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -202,7 +224,7 @@ fun <T> DataTableView(
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||
.columnWidth(state, column, automaticWidths)
|
||||
.fillMaxHeight()
|
||||
.clickable { state.toggleSort(column.id) }
|
||||
.pointerInput(column.id, index) {
|
||||
@@ -211,7 +233,7 @@ fun <T> DataTableView(
|
||||
onDragCancel = { dragged = 0f },
|
||||
) { _, dragAmount ->
|
||||
dragged += dragAmount
|
||||
val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
|
||||
val step = with(density) { DEFAULT_COLUMN_WIDTH_DP.dp.toPx() } / 2f
|
||||
|
||||
if (dragged > step) {
|
||||
state.moveColumn(column.id, index + 1)
|
||||
@@ -258,8 +280,11 @@ fun <T> DataTableView(
|
||||
* and writes that into preferences.xml.
|
||||
*/
|
||||
val deltaDp = with(density) { dragAmount.toDp().value }
|
||||
val current = state.widthOf(column.id)
|
||||
?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
val current = effectiveColumnWidthDp(
|
||||
state,
|
||||
column,
|
||||
automaticWidths,
|
||||
).toDouble()
|
||||
|
||||
state.resizeColumn(column.id, current + deltaDp)
|
||||
}
|
||||
@@ -278,7 +303,12 @@ fun <T> DataTableView(
|
||||
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = vertical,
|
||||
modifier = Modifier
|
||||
@@ -306,7 +336,9 @@ fun <T> DataTableView(
|
||||
}
|
||||
)
|
||||
.clickable {
|
||||
state.select(row)
|
||||
if (selectOnRowClick) {
|
||||
state.select(row)
|
||||
}
|
||||
onRowClick?.invoke(row)
|
||||
}
|
||||
.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
@@ -318,7 +350,7 @@ fun <T> DataTableView(
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
|
||||
.columnWidth(state, column, automaticWidths)
|
||||
.fillMaxHeight()
|
||||
.background(cell?.background ?: Color.Transparent)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
@@ -378,6 +410,15 @@ fun <T> DataTableView(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.visibleRows.isEmpty()) {
|
||||
Text(
|
||||
text = LocalStrings.current.tableEmpty,
|
||||
modifier = Modifier.align(Alignment.Center).padding(8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(vertical),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
@@ -402,39 +443,71 @@ fun <T> DataTableView(
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A stored width when the operator has sized this column, otherwise the column's own
|
||||
* weight. The stored value wins because it is the operator's decision.
|
||||
*
|
||||
* @param extra added to the last column so the table fills the window instead of
|
||||
* leaving a band of empty background to the right. The JavaFX table did the
|
||||
* same through `flexibleInitialWidth`.
|
||||
*/
|
||||
/** A stored width wins over the measured automatic width, even when it is narrower. */
|
||||
@Composable
|
||||
private fun <T> Modifier.columnWidth(
|
||||
state: DataTableState<T>,
|
||||
column: DataColumn<T>,
|
||||
extra: Dp = 0.dp,
|
||||
automaticWidths: Map<String, Float>,
|
||||
): Modifier {
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
|
||||
|
||||
return this.width(own + extra)
|
||||
return this.width(effectiveColumnWidthDp(state, column, automaticWidths).dp)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much is left over once every column has its width. Zero when the columns are
|
||||
* already wider than the window, in which case the table scrolls.
|
||||
* Measures automatic columns once from their headers and once more when the first
|
||||
* source rows arrive. Later chat events do not resize a live table.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> unusedWidth(state: DataTableState<T>, available: Dp): Dp {
|
||||
val used = state.orderedColumns.sumOf { column ->
|
||||
val stored = state.widthOf(column.id)
|
||||
val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
|
||||
own + Density.COLUMN_HANDLE_WIDTH.value
|
||||
}
|
||||
private fun <T> rememberAutomaticColumnWidths(state: DataTableState<T>): Map<String, Float> {
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val headerStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)
|
||||
val cellStyle = MaterialTheme.typography.bodyMedium
|
||||
val hasSourceRows = state.rows.isNotEmpty()
|
||||
val titles = state.orderedColumns.associate { column -> column.id to column.title() }
|
||||
|
||||
return (available.value - used).coerceAtLeast(0.0).dp
|
||||
return remember(
|
||||
state,
|
||||
density.density,
|
||||
density.fontScale,
|
||||
headerStyle,
|
||||
cellStyle,
|
||||
titles,
|
||||
hasSourceRows,
|
||||
) {
|
||||
val sample = if (hasSourceRows) state.rows.take(AUTO_WIDTH_SAMPLE_SIZE) else emptyList()
|
||||
|
||||
state.orderedColumns.associate { column ->
|
||||
val title = titles.getValue(column.id) + SORT_INDICATOR_SAMPLE
|
||||
val headerWidthPx = measurer.measure(title, headerStyle).size.width
|
||||
val contentWidthPx = sample.maxOfOrNull { row ->
|
||||
val value = runCatching { column.value(row) }.getOrDefault("")
|
||||
measurer.measure(value, cellStyle).size.width
|
||||
} ?: 0
|
||||
val measuredDp = with(density) {
|
||||
maxOf(headerWidthPx, contentWidthPx).toDp().value + CELL_HORIZONTAL_PADDING_DP
|
||||
}
|
||||
val maximumDp = (AUTO_WIDTH_MAX_BASE_DP * column.weight.coerceAtLeast(0.5f))
|
||||
.coerceIn(AUTO_WIDTH_MIN_MAX_DP, AUTO_WIDTH_ABSOLUTE_MAX_DP)
|
||||
|
||||
column.id to measuredDp.coerceIn(AUTO_WIDTH_MIN_DP, maximumDp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_COLUMN_WIDTH = 85.dp
|
||||
internal fun <T> effectiveColumnWidthDp(
|
||||
state: DataTableState<T>,
|
||||
column: DataColumn<T>,
|
||||
automaticWidths: Map<String, Float>,
|
||||
): Float = state.widthOf(column.id)?.toFloat()
|
||||
?: automaticWidths[column.id]
|
||||
?: DEFAULT_COLUMN_WIDTH_DP * column.weight
|
||||
|
||||
private const val AUTO_WIDTH_SAMPLE_SIZE = 160
|
||||
private const val AUTO_WIDTH_MIN_DP = 32f
|
||||
private const val AUTO_WIDTH_MIN_MAX_DP = 90f
|
||||
private const val AUTO_WIDTH_MAX_BASE_DP = 180f
|
||||
private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f
|
||||
private const val CELL_HORIZONTAL_PADDING_DP = 10f
|
||||
private const val DEFAULT_COLUMN_WIDTH_DP = 85f
|
||||
private const val SORT_INDICATOR_SAMPLE = " ▲"
|
||||
@@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp
|
||||
* over thirty fields, so those defaults turn a settings window into a scrolling
|
||||
* exercise and make a desktop application look like a phone.
|
||||
*
|
||||
* Every height here is a **minimum**, never a fixed size. The configured base font
|
||||
* size scales all text through Typography.scaledTo, and a fixed height would clip a
|
||||
* larger font instead of growing with it.
|
||||
* Interactive controls share one minimum visible height. Their common font and padding
|
||||
* keep the standard size exact while still allowing a larger configured font to grow.
|
||||
*/
|
||||
internal object Density {
|
||||
|
||||
/** Text fields, pickers and anything else on the field column. */
|
||||
val FIELD_MIN_HEIGHT = 30.dp
|
||||
/** Minimum for buttons, text fields, choices and checkbox rows in the desktop UI. */
|
||||
val CONTROL_HEIGHT = 26.dp
|
||||
|
||||
/** Inside a text field. The vertical value is what buys back the 56dp. */
|
||||
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
|
||||
/** Text stays vertically centred by the control instead of by extra padding. */
|
||||
val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
|
||||
|
||||
val BUTTON_MIN_HEIGHT = 24.dp
|
||||
|
||||
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
|
||||
val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp)
|
||||
|
||||
/** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
|
||||
val BUTTON_SHAPE = RoundedCornerShape(4.dp)
|
||||
|
||||
/** One row of a list or table. */
|
||||
val ROW_MIN_HEIGHT = 26.dp
|
||||
val ROW_MIN_HEIGHT = CONTROL_HEIGHT
|
||||
|
||||
/** Equal width for all eight compass-direction filter buttons. */
|
||||
val DIRECTION_BUTTON_WIDTH = 28.dp
|
||||
|
||||
/** 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 TAB_MIN_HEIGHT = 30.dp
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -17,7 +18,11 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -43,17 +48,29 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.semantics.Role
|
||||
|
||||
/** One font size and natural line box for every compact interactive control. */
|
||||
@Composable
|
||||
internal fun compactControlTextStyle(): TextStyle =
|
||||
MaterialTheme.typography.bodyMedium.copy(lineHeight = TextUnit.Unspecified)
|
||||
|
||||
/**
|
||||
* Form controls shared by the settings tabs.
|
||||
@@ -232,6 +249,7 @@ object Form {
|
||||
val draft = rememberDraft(selected)
|
||||
|
||||
labelled(label) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
@@ -240,18 +258,20 @@ object Form {
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = compactControlTextStyle(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||
text = { Text(describe(item), style = compactControlTextStyle()) },
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
onClick = {
|
||||
expanded = false
|
||||
draft.value = item
|
||||
@@ -261,6 +281,7 @@ object Form {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,30 +292,39 @@ object Form {
|
||||
selected: T?,
|
||||
describe: (T) -> String,
|
||||
enabled: Boolean = true,
|
||||
trailingText: String = "",
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val draft = rememberDraft(selected)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
if (label.isNotBlank()) {
|
||||
Text(label, style = compactControlTextStyle(), modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
Box {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
enabled = enabled,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = compactControlTextStyle(),
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
items.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
|
||||
text = { Text(describe(item), style = compactControlTextStyle()) },
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
onClick = {
|
||||
expanded = false
|
||||
draft.value = item
|
||||
@@ -304,6 +334,14 @@ object Form {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailingText.isNotBlank()) {
|
||||
Text(
|
||||
trailingText,
|
||||
style = compactControlTextStyle(),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,16 +351,38 @@ object Form {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
CompactCheckbox(draft.value) { ticked ->
|
||||
draft.value = ticked
|
||||
onChange(ticked)
|
||||
}
|
||||
Text(label, modifier = Modifier.padding(start = 6.dp))
|
||||
Text(
|
||||
label,
|
||||
style = compactControlTextStyle(),
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact two-state button for dense desktop option groups. */
|
||||
@Composable
|
||||
fun toggle(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
CompactToggleButton(
|
||||
text = text,
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun int(label: String, value: Int, onChange: (Int) -> Unit) =
|
||||
number(label, value, { it.trim().toIntOrNull() }, onChange)
|
||||
@@ -430,26 +490,49 @@ object Form {
|
||||
}
|
||||
|
||||
/**
|
||||
* The button of the settings windows: square-ish corners and desktop padding.
|
||||
*
|
||||
* Material's own Button is a 40dp pill, which is the single strongest reason the
|
||||
* window looked like a phone application.
|
||||
*/
|
||||
/**
|
||||
* The button of the settings windows.
|
||||
* The shared desktop button used in settings, filter and shortcut areas.
|
||||
*
|
||||
* Material's own Button is a 40dp pill; this one is square-ish and at desktop
|
||||
* height. It also carries the two states the JavaFX stylesheets give every button:
|
||||
* a gradient while the pointer is over it and a red border while it is held down.
|
||||
*/
|
||||
@Composable
|
||||
fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
fun button(
|
||||
text: String,
|
||||
enabled: Boolean = true,
|
||||
accented: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalPadding: Dp = 6.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val palette = LocalJavaFxPalette.current
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
val pressed by interactions.collectIsPressedAsState()
|
||||
|
||||
val hoverGradient = palette.buttonHoverGradient
|
||||
val accentGradient = if (palette.windowBackground.luminance() < 0.5f) {
|
||||
listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||
} else {
|
||||
listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||
}
|
||||
val backgroundGradient = when {
|
||||
!enabled -> emptyList()
|
||||
hovered && hoverGradient.size >= 2 -> hoverGradient
|
||||
accented -> accentGradient
|
||||
else -> emptyList()
|
||||
}
|
||||
val containerColor = when {
|
||||
backgroundGradient.isNotEmpty() -> Color.Transparent
|
||||
selected -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = when {
|
||||
hovered && enabled && hoverGradient.isNotEmpty() -> Color.Black
|
||||
accented -> Color(0xFF395306)
|
||||
selected -> MaterialTheme.colorScheme.onPrimary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMinimumInteractiveComponentSize provides 0.dp
|
||||
@@ -458,35 +541,28 @@ object Form {
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
contentPadding = Density.BUTTON_CONTENT_PADDING,
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 0.dp),
|
||||
interactionSource = interactions,
|
||||
border = if (pressed) {
|
||||
BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
|
||||
/*
|
||||
* Compose paints a solid container, so the gradient is applied as the
|
||||
* background below and the container itself is cleared. The text turns
|
||||
* dark because the gradient the sheets use is a bright lime.
|
||||
*/
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = Color.Black,
|
||||
)
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
border = when {
|
||||
pressed -> BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
|
||||
enabled -> BorderStroke(Density.HAIRLINE, MaterialTheme.colorScheme.outline)
|
||||
else -> BorderStroke(
|
||||
Density.HAIRLINE,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.38f),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.BUTTON_MIN_HEIGHT)
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
),
|
||||
modifier = modifier
|
||||
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||
.then(
|
||||
if (hovered && enabled && hoverGradient.size >= 2) {
|
||||
if (backgroundGradient.size >= 2) {
|
||||
Modifier.background(
|
||||
brush = Brush.verticalGradient(hoverGradient),
|
||||
brush = Brush.verticalGradient(backgroundGradient),
|
||||
shape = Density.BUTTON_SHAPE,
|
||||
)
|
||||
} else {
|
||||
@@ -499,7 +575,11 @@ object Form {
|
||||
* bar instead of simply being too narrow, and that bar then pushes the rest
|
||||
* of the window out of view.
|
||||
*/
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
Text(
|
||||
text,
|
||||
style = compactControlTextStyle(),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,6 +618,137 @@ object Form {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-sized toggle with the same typography, height and palette as the shared
|
||||
* desktop buttons. Checkbox semantics keep keyboard and accessibility behaviour while
|
||||
* avoiding the extra symbol and label width of a checkbox row.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CompactToggleButton(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
val focused by interactions.collectIsFocusedAsState()
|
||||
val pressed by interactions.collectIsPressedAsState()
|
||||
val containerColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f)
|
||||
pressed -> MaterialTheme.colorScheme.primaryContainer
|
||||
hovered && checked -> MaterialTheme.colorScheme.primaryContainer
|
||||
checked -> MaterialTheme.colorScheme.primary
|
||||
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
(pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
checked -> MaterialTheme.colorScheme.onPrimary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val outlineColor = when {
|
||||
focused || checked -> MaterialTheme.colorScheme.primary
|
||||
enabled -> MaterialTheme.colorScheme.outline
|
||||
else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.38f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = Density.CONTROL_HEIGHT)
|
||||
.background(containerColor, Density.BUTTON_SHAPE)
|
||||
.border(
|
||||
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
|
||||
outlineColor,
|
||||
Density.BUTTON_SHAPE,
|
||||
)
|
||||
.toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Checkbox,
|
||||
interactionSource = interactions,
|
||||
indication = null,
|
||||
onValueChange = onCheckedChange,
|
||||
)
|
||||
.padding(horizontal = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = compactControlTextStyle(),
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact desktop tab with the same interaction palette as the shared controls.
|
||||
* The selected surface visually continues into the content below it, while the border
|
||||
* keeps inactive entries recognizable as tabs in both palettes.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CompactTab(
|
||||
text: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactions = remember { MutableInteractionSource() }
|
||||
val hovered by interactions.collectIsHoveredAsState()
|
||||
val focused by interactions.collectIsFocusedAsState()
|
||||
val pressed by interactions.collectIsPressedAsState()
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = when {
|
||||
pressed -> MaterialTheme.colorScheme.primaryContainer
|
||||
selected -> MaterialTheme.colorScheme.surface
|
||||
hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val outline = when {
|
||||
selected || focused -> primary
|
||||
else -> MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||
.background(background)
|
||||
.border(
|
||||
if (focused) Density.SELECTION_BORDER else Density.HAIRLINE,
|
||||
outline,
|
||||
)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
interactionSource = interactions,
|
||||
indication = null,
|
||||
role = Role.Tab,
|
||||
onClick = onClick,
|
||||
)
|
||||
.drawBehind {
|
||||
if (selected) {
|
||||
val thickness = 2.dp.toPx()
|
||||
drawRect(
|
||||
color = primary,
|
||||
topLeft = Offset(0f, size.height - thickness),
|
||||
size = Size(size.width, thickness),
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 3.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = compactControlTextStyle(),
|
||||
color = if (selected) primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A text field at desktop density.
|
||||
*
|
||||
@@ -563,6 +774,7 @@ internal fun CompactTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
placeholder: String = "",
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
borderColor: Color? = null,
|
||||
borderThickness: Dp? = null,
|
||||
@@ -596,11 +808,11 @@ internal fun CompactTextField(
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
textStyle = compactControlTextStyle().copy(color = textColor),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
|
||||
modifier = modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) { innerTextField ->
|
||||
OutlinedTextFieldDefaults.DecorationBox(
|
||||
value = value,
|
||||
@@ -612,6 +824,19 @@ internal fun CompactTextField(
|
||||
isError = isError,
|
||||
colors = colors,
|
||||
contentPadding = Density.FIELD_CONTENT_PADDING,
|
||||
placeholder = if (placeholder.isNotEmpty()) {
|
||||
{
|
||||
Text(
|
||||
placeholder,
|
||||
style = compactControlTextStyle().copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
container = {
|
||||
OutlinedTextFieldDefaults.Container(
|
||||
enabled = enabled,
|
||||
@@ -628,16 +853,19 @@ internal fun CompactTextField(
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkbox without the 48dp touch target Material reserves around it. On a desktop
|
||||
* form that target is dead space between every two rows.
|
||||
* A checkbox on the shared compact control surface instead of Material's 48dp touch target.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
|
||||
internal fun CompactCheckbox(
|
||||
checked: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
modifier = Modifier.size(Density.CHECK_SIZE),
|
||||
modifier = modifier.size(Density.CONTROL_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.FrameWindowScope
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.utils.PlatformUtils
|
||||
@@ -31,7 +31,6 @@ import java.util.function.Consumer
|
||||
fun FrameWindowScope.MainWindow(
|
||||
state: MainWindowState,
|
||||
actions: MainMenuActions,
|
||||
skedBands: List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
@@ -42,6 +41,7 @@ fun FrameWindowScope.MainWindow(
|
||||
* message aims at whoever it was addressed to rather than at oneself.
|
||||
*/
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||
/** Whether a message came from this station; decides its row colour. */
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
/** How old a message is, for the fading highlight. */
|
||||
@@ -133,6 +133,7 @@ fun FrameWindowScope.MainWindow(
|
||||
state = state,
|
||||
onCandidateClicked = onCandidateClicked,
|
||||
onDirectedMessageClicked = onDirectedMessageClicked,
|
||||
onMessageStationClicked = onMessageStationClicked,
|
||||
isOwnMessage = isOwnMessage,
|
||||
messageAgeSeconds = messageAgeSeconds,
|
||||
)
|
||||
@@ -140,7 +141,6 @@ fun FrameWindowScope.MainWindow(
|
||||
{
|
||||
StationColumn(
|
||||
state = state,
|
||||
skedBands = skedBands,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
onShowPathInAirScout = onShowPathInAirScout,
|
||||
@@ -167,6 +167,7 @@ private fun MessageColumn(
|
||||
state: MainWindowState,
|
||||
onCandidateClicked: (TimelineCandidate) -> Unit,
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
messageAgeSeconds: (ChatMessage) -> Long,
|
||||
) {
|
||||
@@ -174,6 +175,13 @@ private fun MessageColumn(
|
||||
state = state.messageSplitter,
|
||||
orientation = SplitterOrientation.VERTICAL,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
minimumPaneSizes = listOf(
|
||||
SplitterState.DEFAULT_MIN_PANE_PX.dp,
|
||||
Density.CONTROL_HEIGHT,
|
||||
TimelineGeometry.HEIGHT.dp,
|
||||
Density.CONTROL_HEIGHT,
|
||||
SplitterState.DEFAULT_MIN_PANE_PX.dp,
|
||||
),
|
||||
panes = listOf(
|
||||
{
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
@@ -216,6 +224,7 @@ private fun MessageColumn(
|
||||
publicMessages = state.publicMessages,
|
||||
clusterMessages = state.clusterMessages,
|
||||
qsoOfTheOther = state.qsoOfTheOther,
|
||||
onStationMessageClicked = onMessageStationClicked,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -230,7 +239,6 @@ private fun MessageColumn(
|
||||
@Composable
|
||||
private fun StationColumn(
|
||||
state: MainWindowState,
|
||||
skedBands: List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
@@ -248,18 +256,21 @@ private fun StationColumn(
|
||||
StationFilterBar(
|
||||
filters = state.stationFilter,
|
||||
activeBands = state.activeBands,
|
||||
reachability = state.reachability,
|
||||
onChanged = {
|
||||
onFiltersChanged()
|
||||
},
|
||||
onReset = state::resetStationFilters,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(thickness = Density.HAIRLINE)
|
||||
SnippetContextMenu(state.snippets.entries, state.chatInput) {
|
||||
DataTableView(
|
||||
state = state.stations,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = onStationSelected,
|
||||
)
|
||||
DataTableView(
|
||||
state = state.stations,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = onStationSelected,
|
||||
selectOnRowClick = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -274,7 +285,6 @@ private fun StationColumn(
|
||||
SelectedStationPanel(
|
||||
state = state.selectedStation,
|
||||
messages = state.selectedStationMessages,
|
||||
skedBands = skedBands,
|
||||
activeBands = state.activeBands,
|
||||
openInBrowser = openInBrowser,
|
||||
onShowOnMap = onShowOnMap,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import java.util.function.Consumer
|
||||
@@ -34,12 +33,12 @@ object MainWindowHost {
|
||||
*/
|
||||
private var currentState by androidx.compose.runtime.mutableStateOf<MainWindowState?>(null)
|
||||
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
|
||||
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
|
||||
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
|
||||
private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
|
||||
private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
|
||||
private var currentOnMessageStationClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage, StationSelectionSource) -> Unit)?>(null)
|
||||
private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
|
||||
private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
|
||||
private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
|
||||
@@ -79,12 +78,12 @@ object MainWindowHost {
|
||||
* leave the client running with the chat connected and nothing to see it in.
|
||||
*/
|
||||
onCloseRequest: () -> Unit,
|
||||
skedBands: () -> List<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
onShowPathInAirScout: () -> Unit,
|
||||
onStationSelected: (ChatMember) -> Unit,
|
||||
onDirectedMessageClicked: (ChatMessage) -> Unit,
|
||||
onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit,
|
||||
isOwnMessage: (ChatMessage) -> Boolean,
|
||||
messageAgeSeconds: (ChatMessage) -> Long,
|
||||
onFiltersChanged: () -> Unit,
|
||||
@@ -93,12 +92,12 @@ object MainWindowHost {
|
||||
) {
|
||||
currentState = state
|
||||
currentActions = actions
|
||||
currentSkedBands = skedBands
|
||||
currentOpenInBrowser = openInBrowser
|
||||
currentOnShowOnMap = onShowOnMap
|
||||
currentOnShowPathInAirScout = onShowPathInAirScout
|
||||
currentOnStationSelected = onStationSelected
|
||||
currentOnDirectedMessageClicked = onDirectedMessageClicked
|
||||
currentOnMessageStationClicked = onMessageStationClicked
|
||||
currentIsOwnMessage = isOwnMessage
|
||||
currentMessageAgeSeconds = messageAgeSeconds
|
||||
currentOnFiltersChanged = onFiltersChanged
|
||||
@@ -151,12 +150,12 @@ object MainWindowHost {
|
||||
MainWindow(
|
||||
state = s,
|
||||
actions = a,
|
||||
skedBands = currentSkedBands?.invoke() ?: emptyList(),
|
||||
openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
|
||||
onShowOnMap = currentOnShowOnMap ?: {},
|
||||
onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
|
||||
onStationSelected = currentOnStationSelected ?: {},
|
||||
onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
|
||||
onMessageStationClicked = currentOnMessageStationClicked ?: { _, _ -> },
|
||||
isOwnMessage = currentIsOwnMessage ?: { false },
|
||||
messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
|
||||
onFiltersChanged = currentOnFiltersChanged ?: {},
|
||||
|
||||
@@ -59,6 +59,7 @@ class MainWindowState(
|
||||
val outerSplitter: SplitterState,
|
||||
val messageSplitter: SplitterState,
|
||||
val rightSplitter: SplitterState,
|
||||
val reachability: ReachabilityState = ReachabilityState(activeBands),
|
||||
) {
|
||||
|
||||
/** The shortcut buttons above the send line. */
|
||||
@@ -67,7 +68,35 @@ class MainWindowState(
|
||||
* service, send line). Set by the host after construction; other windows, such as the
|
||||
* map, go through it instead of touching [selectedStation] alone. Null clears.
|
||||
*/
|
||||
var stationSelector: ((ChatMember?) -> Unit)? = null
|
||||
var stationSelector: ((ChatMember?, StationSelectionSource) -> Unit)? = null
|
||||
|
||||
/** Application callback that rebuilds the controller and Compose station filters. */
|
||||
var stationFiltersChanged: (() -> Unit)? = null
|
||||
|
||||
/** Debounced persistence hook shared by the main and map windows. */
|
||||
var layoutSaveRequested: (() -> Unit)? = null
|
||||
|
||||
val stationSelection: StationSelectionState
|
||||
get() = selectedStation.stationSelection
|
||||
|
||||
/** Routes every explicit station choice through the application-level action. */
|
||||
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
|
||||
val selector = stationSelector
|
||||
if (selector != null) {
|
||||
selector(member, source)
|
||||
} else {
|
||||
selectedStation.select(member, source)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetStationFilters() {
|
||||
stationFilter.reset()
|
||||
stationFiltersChanged?.invoke()
|
||||
}
|
||||
|
||||
fun requestLayoutSave() {
|
||||
layoutSaveRequested?.invoke()
|
||||
}
|
||||
|
||||
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@ package kst4contest.view.compose
|
||||
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -18,13 +16,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.border
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
|
||||
@@ -40,6 +33,7 @@ fun MessageTabs(
|
||||
publicMessages: DataTableState<ChatMessage>,
|
||||
clusterMessages: DataTableState<ClusterMessage>,
|
||||
qsoOfTheOther: DataTableState<ChatMessage>,
|
||||
onStationMessageClicked: (ChatMessage, StationSelectionSource) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
@@ -51,35 +45,20 @@ fun MessageTabs(
|
||||
strings.messagesQsoOfTheOther,
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
titles.forEachIndexed { index, title ->
|
||||
val isSelected = index == selected
|
||||
val underline = MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = Density.TAB_MIN_HEIGHT)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
|
||||
else Color.Transparent
|
||||
)
|
||||
.clickable { selected = index }
|
||||
.drawBehind {
|
||||
if (isSelected) {
|
||||
val thickness = 2.dp.toPx()
|
||||
drawRect(
|
||||
color = underline,
|
||||
topLeft = Offset(0f, size.height - thickness),
|
||||
size = Size(size.width, thickness),
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
}
|
||||
CompactTab(
|
||||
text = title,
|
||||
selected = isSelected,
|
||||
onClick = { selected = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +66,21 @@ fun MessageTabs(
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when (selected) {
|
||||
0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
|
||||
0 -> DataTableView(
|
||||
publicMessages,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = { message ->
|
||||
onStationMessageClicked(message, StationSelectionSource.PUBLIC_MESSAGE)
|
||||
},
|
||||
)
|
||||
1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
|
||||
else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
|
||||
else -> DataTableView(
|
||||
qsoOfTheOther,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onRowClick = { message ->
|
||||
onStationMessageClicked(message, StationSelectionSource.QSO_OF_THE_OTHER)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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
|
||||
|
||||
/** One selectable entry in the reachability-band control. A null band means Auto. */
|
||||
data class ReachabilityBandOption(val band: Band?, val label: String)
|
||||
|
||||
/**
|
||||
* Operator-controlled reachability selection and the one explicit calculation action.
|
||||
*
|
||||
* Band selection only invalidates cached table/filter presentation. Online terrain work
|
||||
* stays behind [calculateSelected], so recomposition and sorting cannot consume API calls.
|
||||
*/
|
||||
class ReachabilityState(
|
||||
activeBands: Set<Band>,
|
||||
private val resolveAutoBand: (ChatMember) -> Band? = { null },
|
||||
private val selectedStation: () -> ChatMember? = { null },
|
||||
private val calculate: (ChatMember, Band) -> Unit = { _, _ -> },
|
||||
private val refresh: () -> Unit = { },
|
||||
) {
|
||||
private val supportedBands = activeBands.toSet()
|
||||
|
||||
val options: List<ReachabilityBandOption> =
|
||||
listOf(ReachabilityBandOption(null, "Auto")) +
|
||||
activeBands.sortedBy(Band::ordinal).map { band ->
|
||||
ReachabilityBandOption(band, band.displayLabel)
|
||||
}
|
||||
|
||||
var bandOverride: Band? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
val selectedOption: ReachabilityBandOption
|
||||
get() = options.first { it.band == bandOverride }
|
||||
|
||||
val canCalculate: Boolean
|
||||
get() = selectedStation()?.let(::resolveBand) != null
|
||||
|
||||
fun selectBand(band: Band?) {
|
||||
val accepted = band?.takeIf(supportedBands::contains)
|
||||
if (bandOverride == accepted) return
|
||||
bandOverride = accepted
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun resolveBand(member: ChatMember): Band? = bandOverride ?: resolveAutoBand(member)
|
||||
|
||||
/** Returns whether a complete station/band request was handed to the service. */
|
||||
fun calculateSelected(): Boolean {
|
||||
val member = selectedStation() ?: return false
|
||||
val band = resolveBand(member) ?: return false
|
||||
calculate(member, band)
|
||||
return true
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import java.util.Locale
|
||||
import java.util.function.Predicate
|
||||
|
||||
/** Builds the selected-station message filter without changing the source history. */
|
||||
object SelectedStationMessagePredicate {
|
||||
|
||||
@JvmStatic
|
||||
fun forSelection(
|
||||
filter: SelectedMessageFilter,
|
||||
selected: ChatMember?,
|
||||
ownCallSign: String?,
|
||||
): Predicate<ChatMessage> {
|
||||
val selectedIdentity = StationIdentity.of(selected)
|
||||
val ownCall = ownCallSign.normalizedCall()
|
||||
|
||||
return Predicate { message ->
|
||||
if (selectedIdentity?.categoryNumber == null) {
|
||||
return@Predicate false
|
||||
}
|
||||
|
||||
val senderCall = message?.sender?.callSign.normalizedCall()
|
||||
val receiverCall = message?.receiver?.callSign.normalizedCall()
|
||||
val messageCategory = message?.chatCategory?.categoryNumber
|
||||
if (senderCall.isBlank() || receiverCall.isBlank() ||
|
||||
messageCategory != selectedIdentity.categoryNumber
|
||||
) {
|
||||
return@Predicate false
|
||||
}
|
||||
|
||||
val selectedIsSender = senderCall == selectedIdentity.callSign
|
||||
val selectedIsReceiver = receiverCall == selectedIdentity.callSign
|
||||
val ownIsSender = senderCall == ownCall
|
||||
val ownIsReceiver = receiverCall == ownCall
|
||||
val isPublic = message.isMessageDirectedToCommunity || receiverCall == PUBLIC_RECEIVER
|
||||
|
||||
when (filter) {
|
||||
SelectedMessageFilter.PM_TO_ME ->
|
||||
ownCall.isNotBlank() && (
|
||||
(!isPublic && ((selectedIsSender && ownIsReceiver) ||
|
||||
(ownIsSender && selectedIsReceiver))) ||
|
||||
(isPublic && selectedIsSender && message.isMessageDirectedToMe)
|
||||
)
|
||||
|
||||
SelectedMessageFilter.PM_TO_OTHER ->
|
||||
ownCall.isNotBlank() && selectedIsSender && !isPublic && !ownIsReceiver
|
||||
|
||||
SelectedMessageFilter.PUBLIC -> selectedIsSender && isPublic
|
||||
SelectedMessageFilter.NOTHING -> selectedIsSender || selectedIsReceiver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.normalizedCall(): String = this?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||
|
||||
private const val PUBLIC_RECEIVER = "ALL"
|
||||
}
|
||||
@@ -2,12 +2,24 @@ package kst4contest.view.compose
|
||||
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -22,9 +34,22 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import kst4contest.model.Band
|
||||
import java.util.function.Consumer
|
||||
|
||||
internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel"
|
||||
internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group"
|
||||
internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group"
|
||||
|
||||
internal fun skedBandOptions(activeBands: Set<Band>): List<Band> {
|
||||
val options = ArrayList<Band>(activeBands.size)
|
||||
Band.entries.forEach { band ->
|
||||
if (activeBands.contains(band)) options.add(band)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel below the station list, for whichever station is selected.
|
||||
*
|
||||
@@ -40,9 +65,7 @@ import java.util.function.Consumer
|
||||
fun SelectedStationPanel(
|
||||
state: SelectedStationState,
|
||||
messages: DataTableState<kst4contest.model.ChatMessage>,
|
||||
/** The bands this station is set up for; BandOpportunityResolver decides them. */
|
||||
skedBands: List<Band>,
|
||||
/** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
|
||||
/** My own station's enabled bands; used for NOT-QRV and sked controls. */
|
||||
activeBands: Set<Band>,
|
||||
openInBrowser: Consumer<String>,
|
||||
onShowOnMap: () -> Unit,
|
||||
@@ -52,9 +75,21 @@ fun SelectedStationPanel(
|
||||
val strings = LocalStrings.current
|
||||
val member = state.selected
|
||||
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(SELECTED_STATION_PANEL_TAG)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 4.dp, top = 3.dp, end = Density.SCROLLBAR_GUTTER, bottom = 4.dp)
|
||||
.verticalScroll(scroll),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
if (member == null) {
|
||||
Text(
|
||||
@@ -65,120 +100,84 @@ fun SelectedStationPanel(
|
||||
return@Column
|
||||
}
|
||||
|
||||
Text(
|
||||
member.callSign.orEmpty() + " " + member.qra.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
if (category != null) {
|
||||
Text(category, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (member.getQTFdirection() != null) {
|
||||
Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (member.qrb != null) {
|
||||
Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
|
||||
val detectedBands = state.detectedBands()
|
||||
val metadata = buildList {
|
||||
category?.let(::add)
|
||||
member.getQTFdirection()?.let { add("QTF: $it°") }
|
||||
member.qrb?.let { add("QRB: $it km") }
|
||||
val lastActSec = member.activityTimeLastInEpoch
|
||||
if (lastActSec > 0L) {
|
||||
val actStr = MessageFormats.clockTime(lastActSec.toString())
|
||||
val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
||||
Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall)
|
||||
val minAgo = kst4contest.controller.Utils4KST
|
||||
.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
|
||||
add(strings.selectedLastActivity(actStr, minAgo))
|
||||
}
|
||||
|
||||
val detectedBands = state.detectedBands()
|
||||
if (detectedBands.isNotBlank()) {
|
||||
Text(detectedBands, style = MaterialTheme.typography.bodySmall)
|
||||
add(detectedBands.ifBlank { strings.tooltipBandsNone })
|
||||
}
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
member.callSign.orEmpty(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
metadata.forEach { value ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"|",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(end = Density.BUTTON_GAP),
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Form.section(strings.selectedShowMessages) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
SelectedMessageFilter.entries.forEach { filter ->
|
||||
RadioChoice(
|
||||
label = filter.label,
|
||||
selected = state.messageFilter == filter,
|
||||
onSelect = { state.messageFilter = filter },
|
||||
)
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
Text(
|
||||
"${strings.selectedShowMessages}:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(end = Density.BUTTON_GAP),
|
||||
)
|
||||
RadioChoice(
|
||||
label = SelectedMessageFilter.PM_TO_ME.label,
|
||||
selected = state.messageFilter == SelectedMessageFilter.PM_TO_ME,
|
||||
onSelect = { state.messageFilter = SelectedMessageFilter.PM_TO_ME },
|
||||
)
|
||||
}
|
||||
SelectedMessageFilter.entries.drop(1).forEach { filter ->
|
||||
RadioChoice(
|
||||
label = filter.label,
|
||||
selected = state.messageFilter == filter,
|
||||
onSelect = { state.messageFilter = filter },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DataTableView(
|
||||
state = messages,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(Density.SELECTED_MESSAGE_TABLE_HEIGHT),
|
||||
)
|
||||
|
||||
Form.section(strings.selectedTagNotQrv) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
||||
|
||||
activeNotQrvBands.forEach { band ->
|
||||
Form.check(band.label, state.isTaggedNotQrv(band)) {
|
||||
state.tagNotQrv(band, it)
|
||||
}
|
||||
}
|
||||
|
||||
Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
|
||||
state.tagNotQrvAll(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Form.section("Sked") {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
|
||||
) {
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedSkedInMinutes,
|
||||
items = SelectedStationState.SKED_MINUTES,
|
||||
selected = state.skedMinutes,
|
||||
describe = { it.toString() },
|
||||
onSelect = { state.skedMinutes = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedBand,
|
||||
items = skedBands,
|
||||
selected = state.skedBand,
|
||||
describe = { it.prefix + " MHz" },
|
||||
onSelect = { state.skedBand = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedMode,
|
||||
items = SelectedStationState.SKED_MODES,
|
||||
selected = state.skedMode,
|
||||
describe = { it },
|
||||
onSelect = { state.skedMode = it },
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it }
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedReminderOffsets,
|
||||
items = SelectedStationState.REMINDER_OFFSETS,
|
||||
selected = state.reminderOffsets,
|
||||
describe = { it },
|
||||
enabled = state.remindPm,
|
||||
onSelect = { state.reminderOffsets = it },
|
||||
)
|
||||
}
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_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() }
|
||||
}
|
||||
}
|
||||
SelectedStationActionGroups(
|
||||
state = state,
|
||||
activeBands = activeBands,
|
||||
)
|
||||
|
||||
Form.section(strings.selectedLookUpAndShow) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
|
||||
@@ -202,8 +201,175 @@ fun SelectedStationPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SelectedStationActionGroups(
|
||||
state: SelectedStationState,
|
||||
activeBands: Set<Band>,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
val member = state.selected ?: return
|
||||
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
|
||||
val notQrvContent: @Composable () -> Unit = {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
activeNotQrvBands.forEach { band ->
|
||||
Form.toggle(band.label, state.isTaggedNotQrv(band)) {
|
||||
state.tagNotQrv(band, it)
|
||||
}
|
||||
}
|
||||
Form.toggle(
|
||||
strings.selectedNotQrvAll,
|
||||
activeNotQrvBands.all { state.isTaggedNotQrv(it) },
|
||||
) {
|
||||
state.tagNotQrvAll(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val skedContent: @Composable () -> Unit = {
|
||||
SkedAlertControls(state, activeBands)
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
if (maxWidth >= Density.SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
CompactFunctionGroup(
|
||||
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
|
||||
modifier = Modifier.weight(0.35f).testTag(SELECTED_NOT_QRV_GROUP_TAG),
|
||||
content = notQrvContent,
|
||||
)
|
||||
CompactFunctionGroup(
|
||||
title = strings.selectedSked,
|
||||
modifier = Modifier.weight(0.65f).testTag(SELECTED_SKED_GROUP_TAG),
|
||||
content = skedContent,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
CompactFunctionGroup(
|
||||
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
|
||||
modifier = Modifier.fillMaxWidth().testTag(SELECTED_NOT_QRV_GROUP_TAG),
|
||||
content = notQrvContent,
|
||||
)
|
||||
CompactFunctionGroup(
|
||||
title = strings.selectedSked,
|
||||
modifier = Modifier.fillMaxWidth().testTag(SELECTED_SKED_GROUP_TAG),
|
||||
content = skedContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactFunctionGroup(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
|
||||
.padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
|
||||
) {
|
||||
Form.inlineChoice(
|
||||
label = strings.selectedSkedIn,
|
||||
items = SelectedStationState.SKED_MINUTES,
|
||||
selected = state.skedMinutes,
|
||||
describe = { it.toString() },
|
||||
trailingText = strings.selectedSkedMinutesAt,
|
||||
onSelect = { state.skedMinutes = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = skedBandOptions(activeBands),
|
||||
selected = state.skedBand,
|
||||
describe = { it.prefix + " MHz" },
|
||||
trailingText = strings.selectedSkedModeLink,
|
||||
onSelect = { state.skedBand = it },
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = SelectedStationState.SKED_MODES,
|
||||
selected = state.skedMode,
|
||||
describe = { it },
|
||||
trailingText = ".",
|
||||
onSelect = { state.skedMode = it },
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.height(Density.CONTROL_HEIGHT),
|
||||
) {
|
||||
CompactCheckbox(state.remindPm) { state.remindPm = it }
|
||||
Text(
|
||||
strings.selectedRemindPmIn,
|
||||
style = compactControlTextStyle(),
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
)
|
||||
Form.inlineChoice(
|
||||
label = "",
|
||||
items = SelectedStationState.REMINDER_OFFSETS,
|
||||
selected = state.reminderOffsets,
|
||||
describe = { it },
|
||||
enabled = state.remindPm,
|
||||
trailingText = strings.selectedMinutes,
|
||||
onSelect = { state.reminderOffsets = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +386,10 @@ fun TopPriorityBar(
|
||||
val strings = LocalStrings.current
|
||||
|
||||
Row(
|
||||
modifier = modifier.padding(2.dp),
|
||||
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,
|
||||
) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* The bands a station can be tagged as not being on, under the labels the panel shows.
|
||||
@@ -39,7 +40,17 @@ enum class SelectedMessageFilter(val label: String) {
|
||||
PM_TO_ME("pm to me"),
|
||||
PM_TO_OTHER("pm to other"),
|
||||
PUBLIC("public msgs"),
|
||||
NOTHING("nothing"),
|
||||
NOTHING("nothing");
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromPreferences(prefs: ChatPreferences): SelectedMessageFilter = when {
|
||||
prefs.isGuiOptions_defaultFilterPmToMe -> PM_TO_ME
|
||||
prefs.isGuiOptions_defaultFilterPmToOther -> PM_TO_OTHER
|
||||
prefs.isGuiOptions_defaultFilterPublicMsgs -> PUBLIC
|
||||
else -> NOTHING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,7 +72,7 @@ enum class SelectedMessageFilter(val label: String) {
|
||||
* every draw, so the supplied lambda has to read Compose state for the button to
|
||||
* follow a settings Apply
|
||||
*/
|
||||
class SelectedStationState(
|
||||
class SelectedStationState @JvmOverloads constructor(
|
||||
private val propagateNotQrv: (ChatMember) -> Unit,
|
||||
private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
|
||||
private val markSkedFail: (ChatMember) -> Unit = { },
|
||||
@@ -72,10 +83,11 @@ class SelectedStationState(
|
||||
private val turnAntennaTo: ((Double) -> Unit)? = null,
|
||||
private val turnSecondAntennaTo: ((Double) -> Unit)? = null,
|
||||
private val secondAntennaOffered: () -> Boolean = { false },
|
||||
val stationSelection: StationSelectionState = StationSelectionState(),
|
||||
) {
|
||||
|
||||
var selected: ChatMember? by mutableStateOf(null)
|
||||
private set
|
||||
val selected: ChatMember?
|
||||
get() = stationSelection.selected
|
||||
|
||||
/** Bumped on a tag so the panel redraws; the flags live on the station itself. */
|
||||
private var tagRevision: Int by mutableStateOf(0)
|
||||
@@ -136,11 +148,15 @@ class SelectedStationState(
|
||||
if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading)
|
||||
}
|
||||
|
||||
fun select(member: ChatMember?) {
|
||||
if (selected?.callSignRaw != member?.callSignRaw) {
|
||||
@JvmOverloads
|
||||
fun select(
|
||||
member: ChatMember?,
|
||||
source: StationSelectionSource = StationSelectionSource.PROGRAMMATIC,
|
||||
) {
|
||||
if (StationIdentity.of(selected) != StationIdentity.of(member)) {
|
||||
skedBand = member?.let { resolveDefaultSkedBand(it) }
|
||||
}
|
||||
selected = member
|
||||
stationSelection.select(member, source)
|
||||
}
|
||||
|
||||
fun isTaggedNotQrv(band: NotQrvBand): Boolean {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.controller.ActiveOperatorProfile
|
||||
import kst4contest.controller.OperatorProfileManagementService
|
||||
@@ -324,18 +321,11 @@ fun settingsButtons(
|
||||
host: SettingsHost,
|
||||
notices: SettingsNotices,
|
||||
draft: SettingsDraft,
|
||||
connectionState: () -> On4KstConnectionState,
|
||||
): @Composable (close: () -> Unit) -> Unit {
|
||||
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
||||
val barState = ConnectionBarState(controller.chatPreferences, connectionState)
|
||||
|
||||
return { close ->
|
||||
/*
|
||||
* The bar is rebuilt after every action so its labels and enabled states follow
|
||||
* the session. The JavaFX bar was told by onConnectionStateChanged instead.
|
||||
*/
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
@Suppress("UNUSED_EXPRESSION") revision
|
||||
|
||||
ConnectionBar(
|
||||
state = barState,
|
||||
onConnect = {
|
||||
@@ -343,14 +333,12 @@ fun settingsButtons(
|
||||
val refusal = host.connectFromSettings()
|
||||
if (refusal != null) notices.problem(refusal)
|
||||
}
|
||||
revision++
|
||||
},
|
||||
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
|
||||
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
|
||||
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
||||
onDisconnectOnly = {
|
||||
host.disconnectOnlyFromSettings()
|
||||
revision++
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.awt.Cursor
|
||||
|
||||
@@ -41,6 +42,7 @@ fun SplitterPane(
|
||||
orientation: SplitterOrientation,
|
||||
panes: List<@Composable () -> Unit>,
|
||||
modifier: Modifier = Modifier,
|
||||
minimumPaneSizes: List<Dp> = emptyList(),
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
val density = LocalDensity.current
|
||||
@@ -55,7 +57,10 @@ fun SplitterPane(
|
||||
* cursor rather than drifting from it by a handle width per divider.
|
||||
*/
|
||||
val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
|
||||
val sizes = state.paneSizes(totalPx)
|
||||
val minimumPaneSizesPx = FloatArray(minimumPaneSizes.size) { index ->
|
||||
with(density) { minimumPaneSizes[index].toPx() }
|
||||
}
|
||||
val sizes = state.paneSizes(totalPx, minimumPaneSizesPx)
|
||||
|
||||
val body: @Composable () -> Unit = {
|
||||
panes.forEachIndexed { index, pane ->
|
||||
@@ -77,6 +82,7 @@ fun SplitterPane(
|
||||
index = index,
|
||||
orientation = orientation,
|
||||
totalPx = totalPx,
|
||||
minimumPaneSizesPx = minimumPaneSizesPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +106,7 @@ private fun SplitterHandle(
|
||||
index: Int,
|
||||
orientation: SplitterOrientation,
|
||||
totalPx: Float,
|
||||
minimumPaneSizesPx: FloatArray,
|
||||
) {
|
||||
val horizontal = orientation == SplitterOrientation.HORIZONTAL
|
||||
val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
|
||||
@@ -111,15 +118,20 @@ private fun SplitterHandle(
|
||||
else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
|
||||
)
|
||||
.pointerHoverIcon(PointerIcon(Cursor(cursor)))
|
||||
.pointerInput(index, totalPx) {
|
||||
.pointerInput(index, totalPx, minimumPaneSizesPx.contentHashCode()) {
|
||||
detectDragGestures(
|
||||
onDragEnd = { state.dragFinished() },
|
||||
onDragCancel = { state.dragFinished() },
|
||||
) { _, dragAmount ->
|
||||
if (totalPx <= 0f) return@detectDragGestures
|
||||
val delta = if (horizontal) dragAmount.x else dragAmount.y
|
||||
val current = state.positions.getOrElse(index) { 0f }
|
||||
state.drag(index, current + delta / totalPx, totalPx)
|
||||
val current = state.dividerPosition(index, totalPx, minimumPaneSizesPx)
|
||||
state.drag(
|
||||
index = index,
|
||||
to = current + delta / totalPx,
|
||||
totalPx = totalPx,
|
||||
minimumPaneSizesPx = minimumPaneSizesPx,
|
||||
)
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -39,9 +39,27 @@ class SplitterState(
|
||||
* @param to the wanted position as a fraction of the whole
|
||||
* @param totalPx the splitter's length, needed to turn the minimum into a fraction
|
||||
*/
|
||||
fun drag(index: Int, to: Float, totalPx: Float) {
|
||||
fun drag(
|
||||
index: Int,
|
||||
to: Float,
|
||||
totalPx: Float,
|
||||
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
|
||||
) {
|
||||
if (index !in 0 until dividerCount) return
|
||||
|
||||
if (minimumPaneSizesPx.isNotEmpty()) {
|
||||
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
|
||||
val effective = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
|
||||
val lowerBound = (effective.getOrElse(index - 1) { 0f } + minimums[index]) / totalPx
|
||||
val upperBound = (effective.getOrElse(index + 1) { totalPx } - minimums[index + 1]) / totalPx
|
||||
livePositions[index] = if (lowerBound > upperBound) {
|
||||
(lowerBound + upperBound) / 2f
|
||||
} else {
|
||||
to.coerceIn(lowerBound, upperBound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
|
||||
val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
|
||||
val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
|
||||
@@ -64,18 +82,64 @@ class SplitterState(
|
||||
}
|
||||
|
||||
/** The pane lengths for a given splitter length; they always add up to the whole. */
|
||||
fun paneSizes(totalPx: Float): List<Float> {
|
||||
fun paneSizes(totalPx: Float, minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS): List<Float> {
|
||||
if (paneCount <= 1) return listOf(totalPx)
|
||||
val edges = buildList {
|
||||
add(0f)
|
||||
livePositions.forEach { add(it * totalPx) }
|
||||
effectiveDividerPositions(totalPx, minimumPaneSizesPx).forEach { add(it) }
|
||||
add(totalPx)
|
||||
}
|
||||
return (0 until paneCount).map { edges[it + 1] - edges[it] }
|
||||
}
|
||||
|
||||
/** The position the pointer sees after pane-specific minimums have been applied. */
|
||||
internal fun dividerPosition(
|
||||
index: Int,
|
||||
totalPx: Float,
|
||||
minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS,
|
||||
): Float = effectiveDividerPositions(totalPx, minimumPaneSizesPx)
|
||||
.getOrElse(index) { 0f } / totalPx.coerceAtLeast(1f)
|
||||
|
||||
private fun effectiveDividerPositions(
|
||||
totalPx: Float,
|
||||
minimumPaneSizesPx: FloatArray,
|
||||
): FloatArray {
|
||||
if (minimumPaneSizesPx.isEmpty()) {
|
||||
return FloatArray(dividerCount) { index -> livePositions[index] * totalPx }
|
||||
}
|
||||
|
||||
val minimums = scaledMinimums(totalPx, minimumPaneSizesPx)
|
||||
val effective = FloatArray(dividerCount)
|
||||
var previous = 0f
|
||||
repeat(dividerCount) { index ->
|
||||
val lowerBound = previous + minimums[index]
|
||||
var trailingMinimum = 0f
|
||||
for (remainingIndex in index + 1 until paneCount) {
|
||||
trailingMinimum += minimums[remainingIndex]
|
||||
}
|
||||
val upperBound = totalPx - trailingMinimum
|
||||
val wanted = livePositions[index] * totalPx
|
||||
val position = wanted.coerceIn(lowerBound, upperBound)
|
||||
effective[index] = position
|
||||
previous = position
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
private fun scaledMinimums(totalPx: Float, minimumPaneSizesPx: FloatArray): FloatArray {
|
||||
val minimums = FloatArray(paneCount) { index ->
|
||||
minimumPaneSizesPx.getOrNull(index)?.coerceAtLeast(minPanePx) ?: minPanePx
|
||||
}
|
||||
val sum = minimums.sum()
|
||||
if (sum <= totalPx || sum <= 0f) return minimums
|
||||
val scale = totalPx.coerceAtLeast(0f) / sum
|
||||
return FloatArray(paneCount) { index -> minimums[index] * scale }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val EMPTY_MINIMUMS = FloatArray(0)
|
||||
|
||||
/** Enough to still see a table header and grab the divider again. */
|
||||
const val DEFAULT_MIN_PANE_PX = 24f
|
||||
|
||||
|
||||
@@ -45,7 +45,14 @@ object StationColumns {
|
||||
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
|
||||
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
|
||||
): List<DataColumn<ChatMember>> = buildList {
|
||||
add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() }))
|
||||
add(DataColumn(
|
||||
id = "callsign",
|
||||
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
|
||||
value = { it.callSign.orEmpty() },
|
||||
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { member: ChatMember ->
|
||||
member.callSign?.trim().orEmpty()
|
||||
},
|
||||
))
|
||||
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() }))
|
||||
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
|
||||
add(DataColumn(
|
||||
|
||||
@@ -3,29 +3,21 @@ package kst4contest.view.compose
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
@@ -39,13 +31,19 @@ import androidx.compose.ui.unit.dp
|
||||
fun StationFilterBar(
|
||||
filters: StationFilterState,
|
||||
activeBands: Set<kst4contest.model.Band>,
|
||||
reachability: ReachabilityState,
|
||||
onChanged: () -> Unit,
|
||||
onReset: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f))
|
||||
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant)
|
||||
.padding(horizontal = 2.dp, vertical = 1.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
/*
|
||||
@@ -64,8 +62,12 @@ fun StationFilterBar(
|
||||
) {
|
||||
// Reset filters
|
||||
FilterButton(strings.filterReset, on = false, isAccent = true) {
|
||||
filters.reset()
|
||||
onChanged()
|
||||
if (onReset != null) {
|
||||
onReset()
|
||||
} else {
|
||||
filters.reset()
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// QRB maximum + minimum (#21): together they make a range, so they stay one group
|
||||
@@ -84,7 +86,7 @@ fun StationFilterBar(
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall)
|
||||
Text(strings.filterShowOnlyQrb, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
|
||||
onValueChange = {
|
||||
@@ -108,7 +110,7 @@ fun StationFilterBar(
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall)
|
||||
Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(),
|
||||
onValueChange = {
|
||||
@@ -137,7 +139,7 @@ fun StationFilterBar(
|
||||
onChanged()
|
||||
},
|
||||
)
|
||||
Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall)
|
||||
Text(strings.filterShowOnlyQtf, style = compactControlTextStyle())
|
||||
CompactTextField(
|
||||
value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
|
||||
onValueChange = {
|
||||
@@ -154,7 +156,7 @@ fun StationFilterBar(
|
||||
},
|
||||
modifier = Modifier.width(50.dp)
|
||||
)
|
||||
Text("± BW/2", style = MaterialTheme.typography.bodySmall)
|
||||
Text("± BW/2", style = compactControlTextStyle())
|
||||
}
|
||||
|
||||
// Bearings
|
||||
@@ -163,7 +165,12 @@ fun StationFilterBar(
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
Bearing.entries.forEach { bearing ->
|
||||
FilterButton(bearing.name, on = filters.bearing == 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()
|
||||
}
|
||||
@@ -172,10 +179,11 @@ fun StationFilterBar(
|
||||
}
|
||||
|
||||
// ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CompactTextField(
|
||||
value = filters.searchText,
|
||||
@@ -187,100 +195,102 @@ fun StationFilterBar(
|
||||
modifier = Modifier.width(90.dp),
|
||||
)
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall)
|
||||
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
||||
filters.hideWorkedAny = !filters.hideWorkedAny
|
||||
onChanged()
|
||||
}
|
||||
WorkedBandFilter.entries.forEach { band ->
|
||||
if (band.modelBand in activeBands) {
|
||||
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
|
||||
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
|
||||
onChanged()
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
Text(strings.filterHideWorked, style = compactControlTextStyle())
|
||||
FilterButton("wkdany", on = filters.hideWorkedAny) {
|
||||
filters.hideWorkedAny = !filters.hideWorkedAny
|
||||
onChanged()
|
||||
}
|
||||
WorkedBandFilter.entries.forEach { band ->
|
||||
if (band.modelBand in activeBands) {
|
||||
FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
|
||||
filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalSeparator()
|
||||
|
||||
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
|
||||
filters.hideInactive = !filters.hideInactive
|
||||
onChanged()
|
||||
}
|
||||
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
|
||||
filters.onlyNewGrids = !filters.onlyNewGrids
|
||||
onChanged()
|
||||
FilterGroup {
|
||||
GroupSeparator()
|
||||
FilterButton(strings.filterInactiveStations, on = filters.hideInactive) {
|
||||
filters.hideInactive = !filters.hideInactive
|
||||
onChanged()
|
||||
}
|
||||
FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) {
|
||||
filters.onlyNewGrids = !filters.onlyNewGrids
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROW 3: Grid color | Tropo | New bands | AS next 5m
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
|
||||
// ROW 3: Grid color | Tropo | New bands | AS next 5m | Reachability
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
|
||||
filters.gridColouring = !filters.gridColouring
|
||||
FilterGroup {
|
||||
FilterButton(strings.filterGridColour, on = filters.gridColouring) {
|
||||
filters.gridColouring = !filters.gridColouring
|
||||
}
|
||||
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
|
||||
filters.tropoReachable = !filters.tropoReachable
|
||||
onChanged()
|
||||
}
|
||||
FilterButton(strings.filterNewBands, on = filters.newBands) {
|
||||
filters.newBands = !filters.newBands
|
||||
onChanged()
|
||||
}
|
||||
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
|
||||
filters.airScoutNext5Min = !filters.airScoutNext5Min
|
||||
onChanged()
|
||||
}
|
||||
}
|
||||
FilterButton(strings.filterTropo, on = filters.tropoReachable) {
|
||||
filters.tropoReachable = !filters.tropoReachable
|
||||
onChanged()
|
||||
}
|
||||
FilterButton(strings.filterNewBands, on = filters.newBands) {
|
||||
filters.newBands = !filters.newBands
|
||||
onChanged()
|
||||
}
|
||||
FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
|
||||
filters.airScoutNext5Min = !filters.airScoutNext5Min
|
||||
onChanged()
|
||||
|
||||
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
|
||||
* Instead it is a plain clickable Box with border and background, matching the tiny
|
||||
* JavaFX filter buttons.
|
||||
* A compact toggle using the same interaction states as every other desktop button.
|
||||
*/
|
||||
@Composable
|
||||
private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
|
||||
val palette = LocalJavaFxPalette.current
|
||||
val isDark = palette.windowBackground.luminance() < 0.5f
|
||||
|
||||
val accentBrush = if (isDark) {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
|
||||
)
|
||||
} else {
|
||||
androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
|
||||
)
|
||||
}
|
||||
|
||||
val bgModifier = if (isAccent) {
|
||||
Modifier.background(accentBrush, Density.BUTTON_SHAPE)
|
||||
} else {
|
||||
val bg = if (on) MaterialTheme.colorScheme.primary else Color.Transparent
|
||||
Modifier.background(bg, Density.BUTTON_SHAPE)
|
||||
}
|
||||
|
||||
val fg = if (isAccent) Color(0xFF395306) else if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
val borderColor = if (isAccent) Color.Transparent else if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.heightIn(min = 20.dp)
|
||||
.then(if (borderColor != Color.Transparent) Modifier.border(Density.HAIRLINE, borderColor, Density.BUTTON_SHAPE) else Modifier)
|
||||
.then(bgModifier)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall, color = fg, maxLines = 1)
|
||||
}
|
||||
private fun FilterButton(
|
||||
label: String,
|
||||
on: Boolean,
|
||||
isAccent: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalPadding: androidx.compose.ui.unit.Dp = 6.dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Form.button(
|
||||
text = label,
|
||||
enabled = enabled,
|
||||
accented = isAccent,
|
||||
selected = on,
|
||||
modifier = modifier,
|
||||
horizontalPadding = horizontalPadding,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Controls that belong together and move to the next line of a wrapping row as one. */
|
||||
@@ -307,49 +317,3 @@ private fun GroupSeparator() {
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
|
||||
/** Thin vertical separator matching JavaFX's group dividers. */
|
||||
@Composable
|
||||
private fun VerticalSeparator() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(Density.HAIRLINE)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun CompactTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String = "",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (value.isEmpty() && placeholder.isNotEmpty()) {
|
||||
Text(
|
||||
placeholder,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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.ChatMessage
|
||||
import java.util.Locale
|
||||
|
||||
/** Full ON4KST station identity used across tables, messages and the map. */
|
||||
data class StationIdentity(
|
||||
val callSign: String,
|
||||
val categoryNumber: Int?,
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun of(member: ChatMember?): StationIdentity? =
|
||||
of(member?.callSign, member?.chatCategory?.categoryNumber)
|
||||
|
||||
@JvmStatic
|
||||
fun of(callSignValue: String?, categoryNumber: Int?): StationIdentity? {
|
||||
val callSign = callSignValue?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||
if (callSign.isBlank()) return null
|
||||
|
||||
return StationIdentity(callSign, categoryNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the station navigated to by a directed-message row click. */
|
||||
object DirectedMessageStationTarget {
|
||||
|
||||
@JvmStatic
|
||||
fun forMessage(message: ChatMessage?, ownCallSign: String?): StationIdentity? {
|
||||
val senderCallSign = message?.sender?.callSign ?: return null
|
||||
val ownCall = ownCallSign?.trim()?.uppercase(Locale.ROOT).orEmpty()
|
||||
val senderCall = senderCallSign.trim().uppercase(Locale.ROOT)
|
||||
val targetCall = if (senderCall == ownCall) {
|
||||
message.receiver?.callSign ?: receiverFromPayload(message.messageText)
|
||||
} else {
|
||||
senderCallSign
|
||||
}
|
||||
|
||||
return StationIdentity.of(targetCall, message.chatCategory?.categoryNumber)
|
||||
}
|
||||
|
||||
private fun receiverFromPayload(messageText: String?): String? {
|
||||
if (messageText == null || !messageText.startsWith('(')) return null
|
||||
val closing = messageText.indexOf(')')
|
||||
return if (closing > 1) messageText.substring(1, closing).trim() else null
|
||||
}
|
||||
}
|
||||
|
||||
/** Where an explicit station selection came from. */
|
||||
enum class StationSelectionSource {
|
||||
STATION_TABLE,
|
||||
DIRECTED_MESSAGE,
|
||||
PUBLIC_MESSAGE,
|
||||
QSO_OF_THE_OTHER,
|
||||
MAP,
|
||||
PRIORITY,
|
||||
TIMELINE,
|
||||
RESET_VIEW,
|
||||
PROGRAMMATIC,
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical station selection shared by the station table and detail views.
|
||||
*
|
||||
* Data refreshes reconcile the selected instance without moving the table. Explicit
|
||||
* user navigation increments [scrollRequestRevision], even when the identity is the
|
||||
* same as before.
|
||||
*/
|
||||
class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
|
||||
private var current: ChatMember? by mutableStateOf(null)
|
||||
|
||||
override val selected: ChatMember?
|
||||
get() = current
|
||||
|
||||
override var selectionRevision: Long by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
override var scrollRequestRevision: Long by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
|
||||
private set
|
||||
|
||||
val identity: StationIdentity?
|
||||
get() = StationIdentity.of(current)
|
||||
|
||||
override fun select(row: ChatMember?) {
|
||||
select(row, StationSelectionSource.STATION_TABLE)
|
||||
}
|
||||
|
||||
fun select(row: ChatMember?, source: StationSelectionSource) {
|
||||
current = row
|
||||
lastSource = source
|
||||
selectionRevision++
|
||||
if (row != null) {
|
||||
scrollRequestRevision++
|
||||
}
|
||||
}
|
||||
|
||||
override fun reconcile(row: ChatMember?) {
|
||||
current = row
|
||||
selectionRevision++
|
||||
}
|
||||
|
||||
override fun requestScroll() {
|
||||
if (current != null) {
|
||||
scrollRequestRevision++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,23 @@ data class TimelineCandidate(
|
||||
val tooltipText: String?,
|
||||
)
|
||||
|
||||
/** The four candidate groups produced for every minute bucket, in display order. */
|
||||
enum class TimelineLane(val index: Int) {
|
||||
IN_BEAM_1(0),
|
||||
IN_BEAM_2(1),
|
||||
PRIORITY_1(2),
|
||||
PRIORITY_2(3);
|
||||
|
||||
companion object {
|
||||
fun forIndex(index: Int): TimelineLane = entries[index.coerceIn(0, entries.lastIndex)]
|
||||
}
|
||||
}
|
||||
|
||||
data class TimelineTick(
|
||||
val minutes: Int,
|
||||
val x: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Where things sit on the strip.
|
||||
*
|
||||
@@ -67,27 +84,27 @@ object TimelineGeometry {
|
||||
* is why the candidate labels and the sked labels landed on top of each other. The
|
||||
* lanes below are laid out for the real height.
|
||||
*/
|
||||
const val HEIGHT: Float = 80f
|
||||
const val HEIGHT: Float = 104f
|
||||
|
||||
/** The time axis, with the candidate lanes above it and the skeds below. */
|
||||
const val AXIS_Y: Float = 44f
|
||||
const val AXIS_Y: Float = 70f
|
||||
const val TICK_LABEL_Y: Float = 72f
|
||||
|
||||
/** Candidates keep further from the edges than skeds do, because their labels are wider. */
|
||||
const val CANDIDATE_MARGIN: Float = 30f
|
||||
const val SKED_MARGIN: Float = 10f
|
||||
|
||||
const val LANE_BASE_Y: Float = 2f
|
||||
const val LANE_HEIGHT: Float = 20f
|
||||
const val LANE_HEIGHT: Float = 16f
|
||||
|
||||
/**
|
||||
* How many candidate lanes fit above the axis. The producer keeps at most two
|
||||
* candidates per minute, so this is a guard rather than a limit in practice: a third
|
||||
* lane shares the second one instead of being drawn across the axis.
|
||||
* How many candidate lanes the producer defines: two in-beam choices followed by
|
||||
* two global priority choices.
|
||||
*/
|
||||
const val LANE_COUNT: Int = 2
|
||||
const val LANE_COUNT: Int = 4
|
||||
|
||||
/** Skeds get their own lane below the axis, which is what frees the labels. */
|
||||
const val SKED_Y: Float = 52f
|
||||
const val SKED_Y: Float = 86f
|
||||
|
||||
const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
|
||||
|
||||
@@ -124,6 +141,23 @@ object TimelineGeometry {
|
||||
|
||||
fun laneY(laneIndex: Int): Float =
|
||||
LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
|
||||
|
||||
/**
|
||||
* Relative minute ticks for the current preview window. Five-minute ticks are used
|
||||
* whenever their labels fit; compact layouts increase the interval instead of
|
||||
* drawing overlapping text.
|
||||
*/
|
||||
fun minuteTicks(axisWidth: Float, minimumLabelSpacing: Float): List<TimelineTick> {
|
||||
if (axisWidth <= 0f) return emptyList()
|
||||
|
||||
val previewMinutes = (PREVIEW_MILLIS / 60_000L).toInt()
|
||||
val interval = listOf(5, 10, 15, previewMinutes).first { minutes ->
|
||||
axisWidth * minutes / previewMinutes >= minimumLabelSpacing
|
||||
}
|
||||
return (0..previewMinutes step interval).map { minutes ->
|
||||
TimelineTick(minutes, axisWidth * minutes / previewMinutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kst4contest.model.ContestSked
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** The strip is read like a radar screen, so it stays dark in both designs. */
|
||||
@@ -47,6 +49,8 @@ private val MARKER_SIZE = 12.dp
|
||||
private val LABEL_GAP = 4.dp
|
||||
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
|
||||
private val CHIP_BACKGROUND = Color(0xA6000000)
|
||||
private val LANE_LABEL_WIDTH = 68.dp
|
||||
private val AXIS_LABEL_PADDING = 6.dp
|
||||
|
||||
/**
|
||||
* The strip above the send field: what is coming in the next half hour.
|
||||
@@ -82,6 +86,7 @@ fun TimelineView(
|
||||
|
||||
val density = LocalDensity.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
val strings = LocalStrings.current
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
@@ -89,17 +94,60 @@ fun TimelineView(
|
||||
.background(STRIP_BACKGROUND),
|
||||
) {
|
||||
val widthPx = with(density) { maxWidth.toPx() }
|
||||
val labelGutterPx = with(density) { LANE_LABEL_WIDTH.toPx() }.coerceAtMost(widthPx)
|
||||
val axisWidthPx = (widthPx - labelGutterPx).coerceAtLeast(0f)
|
||||
val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
|
||||
val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
|
||||
val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
|
||||
val tickLabels = TimelineGeometry.minuteTicks(
|
||||
axisWidth = axisWidthPx,
|
||||
minimumLabelSpacing = measurer.measure("+30 ${strings.timelineMinuteSuffix}", AXIS_TEXT_STYLE)
|
||||
.size.width.toFloat() + with(density) { AXIS_LABEL_PADDING.toPx() },
|
||||
).map { tick ->
|
||||
tick to if (tick.minutes == 0) {
|
||||
"0 ${strings.timelineMinuteSuffix}"
|
||||
} else {
|
||||
"+${tick.minutes} ${strings.timelineMinuteSuffix}"
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
|
||||
drawLine(AXIS_COLOR, Offset(labelGutterPx, axisY), Offset(size.width, axisY))
|
||||
tickLabels.forEach { (tick, _) ->
|
||||
val x = labelGutterPx + tick.x
|
||||
drawLine(AXIS_COLOR, Offset(x, axisY - 2.dp.toPx()), Offset(x, axisY + 3.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
val laneLabels = listOf(
|
||||
strings.timelineInBeam1,
|
||||
strings.timelineInBeam2,
|
||||
strings.timelinePriority1,
|
||||
strings.timelinePriority2,
|
||||
)
|
||||
laneLabels.forEachIndexed { index, label ->
|
||||
TimelineAxisLabel(label, TimelineGeometry.laneY(index).dp)
|
||||
}
|
||||
TimelineAxisLabel(strings.timelineSkeds, TimelineGeometry.SKED_Y.dp)
|
||||
|
||||
tickLabels.forEach { (tick, label) ->
|
||||
val labelWidthPx = measurer.measure(label, AXIS_TEXT_STYLE).size.width.toFloat()
|
||||
val x = (labelGutterPx + tick.x - labelWidthPx / 2f)
|
||||
.coerceIn(labelGutterPx, (widthPx - labelWidthPx).coerceAtLeast(labelGutterPx))
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(x.roundToInt(), with(density) { TimelineGeometry.TICK_LABEL_Y.dp.toPx() }.roundToInt())
|
||||
},
|
||||
style = AXIS_TEXT_STYLE,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
// The candidate lanes, above the axis.
|
||||
state.candidates.forEach { candidate ->
|
||||
val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
|
||||
val x = TimelineGeometry.xFor(candidate.timeUntilMs, axisWidthPx, candidateMargin)
|
||||
?.plus(labelGutterPx)
|
||||
?: return@forEach
|
||||
|
||||
TimelineMarker(
|
||||
@@ -120,7 +168,8 @@ fun TimelineView(
|
||||
// above it, which is why their labels sat on top of the candidates'.
|
||||
val now = nowMillis.value
|
||||
state.skeds.forEach { sked ->
|
||||
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
|
||||
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin)
|
||||
?.plus(labelGutterPx)
|
||||
?: return@forEach
|
||||
|
||||
TimelineMarker(
|
||||
@@ -157,6 +206,23 @@ private val CHIP_TEXT_STYLE = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
private val AXIS_TEXT_STYLE = TextStyle(
|
||||
color = Color(0xFFB8B8B8),
|
||||
fontSize = 8.sp,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TimelineAxisLabel(text: String, top: Dp) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.offset(x = 4.dp, y = top)
|
||||
.width(LANE_LABEL_WIDTH - 8.dp),
|
||||
style = AXIS_TEXT_STYLE,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One marker: the symbol on the time axis and its callsign beside it.
|
||||
*
|
||||
|
||||
@@ -20,13 +20,18 @@ import kst4contest.view.ApplicationRuntimeLauncher
|
||||
import kst4contest.view.compose.PaletteStore
|
||||
import kst4contest.view.compose.ComposeWindowHost
|
||||
import kst4contest.view.compose.MainWindowState
|
||||
import kst4contest.view.compose.StationIdentity
|
||||
import kst4contest.view.compose.StationSelectionSource
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.StationMapStatusText
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import java.util.EnumSet
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@OptIn(
|
||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
|
||||
)
|
||||
object StationMapWindow {
|
||||
|
||||
private val host = ComposeWindowHost("Map")
|
||||
@@ -60,14 +65,16 @@ object StationMapWindow {
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
alwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop,
|
||||
) { _ ->
|
||||
val window = this.window
|
||||
/*
|
||||
* Never larger than the smallest screen: AWT enforces a minimum size even past
|
||||
* the edge, and some operators work on 640x480.
|
||||
*/
|
||||
androidx.compose.runtime.LaunchedEffect(window) {
|
||||
androidx.compose.runtime.LaunchedEffect(window, mainWindowState.prefs.isGUIstationMapAlwaysOnTop) {
|
||||
window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600)
|
||||
window?.isAlwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop
|
||||
}
|
||||
val tileFetcher = remember { TileFetcher() }
|
||||
|
||||
@@ -80,7 +87,11 @@ object StationMapWindow {
|
||||
onDispose { tileFetcher.close() }
|
||||
}
|
||||
|
||||
Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher)
|
||||
Content(
|
||||
mainWindowState = mainWindowState,
|
||||
tileFetcher = tileFetcher,
|
||||
onAlwaysOnTopChanged = { enabled -> window?.isAlwaysOnTop = enabled },
|
||||
)
|
||||
} // host.show content
|
||||
}
|
||||
|
||||
@@ -108,6 +119,7 @@ object StationMapWindow {
|
||||
},
|
||||
mapState: MapState = remember { MapState() },
|
||||
pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis,
|
||||
onAlwaysOnTopChanged: (Boolean) -> Unit = { },
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
@@ -128,6 +140,7 @@ object StationMapWindow {
|
||||
|
||||
var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
|
||||
var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
|
||||
var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) }
|
||||
|
||||
val selectedMember = mainWindowState.selectedStation.selected
|
||||
|
||||
@@ -159,7 +172,7 @@ object StationMapWindow {
|
||||
}
|
||||
|
||||
builder.buildSnapshots(
|
||||
mainWindowState.stations.visibleRows,
|
||||
mainWindowState.stations.filteredRows,
|
||||
selectedMember,
|
||||
bandSet
|
||||
)
|
||||
@@ -237,10 +250,11 @@ object StationMapWindow {
|
||||
* the theme's primary colour, which in the evening sheet is a bright
|
||||
* green, and the map window was the only one wearing it.
|
||||
*/
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val statusText = StationMapStatusText.build(
|
||||
markers.size,
|
||||
@@ -274,8 +288,7 @@ object StationMapWindow {
|
||||
}
|
||||
|
||||
kst4contest.view.compose.Form.button(strings.mapResetView) {
|
||||
mainWindowState.stationSelector?.invoke(null)
|
||||
?: mainWindowState.selectedStation.select(null)
|
||||
mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW)
|
||||
val home = runCatching {
|
||||
kst4contest.locatorUtils.Location(ownLocator6)
|
||||
}.getOrNull()
|
||||
@@ -284,12 +297,38 @@ object StationMapWindow {
|
||||
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
kst4contest.view.compose.CompactCheckbox(groupingEnabled) {
|
||||
groupingEnabled = it
|
||||
prefs.isGUIstationMapClusteringEnabled = it
|
||||
kst4contest.view.compose.Form.button(strings.filterReset) {
|
||||
mainWindowState.resetStationFilters()
|
||||
}
|
||||
|
||||
androidx.compose.foundation.TooltipArea(
|
||||
tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
kst4contest.view.compose.CompactCheckbox(
|
||||
checked = groupingEnabled,
|
||||
modifier = Modifier.testTag(GROUPING_TAG),
|
||||
) {
|
||||
groupingEnabled = it
|
||||
prefs.isGUIstationMapClusteringEnabled = it
|
||||
mainWindowState.requestLayoutSave()
|
||||
}
|
||||
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
kst4contest.view.compose.CompactCheckbox(
|
||||
checked = alwaysOnTop,
|
||||
onCheckedChange = { enabled ->
|
||||
alwaysOnTop = enabled
|
||||
prefs.isGUIstationMapAlwaysOnTop = enabled
|
||||
onAlwaysOnTopChanged(enabled)
|
||||
mainWindowState.requestLayoutSave()
|
||||
},
|
||||
modifier = Modifier.testTag(ALWAYS_ON_TOP_TAG),
|
||||
)
|
||||
Text(strings.mapAlwaysOnTop, modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
|
||||
if (!pathAnalysisVisible) {
|
||||
@@ -305,6 +344,7 @@ object StationMapWindow {
|
||||
) {
|
||||
pathAnalysisVisible = !pathAnalysisVisible
|
||||
prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
|
||||
mainWindowState.requestLayoutSave()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,15 +380,15 @@ object StationMapWindow {
|
||||
* A marker stands for a raw callsign, not a category. Keep the
|
||||
* category already selected when it is one of the variants.
|
||||
*/
|
||||
val member = variants.firstOrNull { it === mainWindowState.selectedStation.selected }
|
||||
?: variants.firstOrNull()
|
||||
val selectedIdentity = StationIdentity.of(mainWindowState.selectedStation.selected)
|
||||
val member = variants.firstOrNull {
|
||||
StationIdentity.of(it) == selectedIdentity
|
||||
} ?: variants.firstOrNull {
|
||||
it.chatCategory?.categoryNumber ==
|
||||
prefs.loginChatCategoryMain?.categoryNumber
|
||||
} ?: variants.singleOrNull()
|
||||
if (member != null) {
|
||||
val selector = mainWindowState.stationSelector
|
||||
if (selector != null) {
|
||||
selector(member)
|
||||
} else {
|
||||
mainWindowState.selectedStation.select(member)
|
||||
}
|
||||
mainWindowState.selectStation(member, StationSelectionSource.MAP)
|
||||
// Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
|
||||
mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
|
||||
}
|
||||
@@ -420,6 +460,8 @@ object StationMapWindow {
|
||||
|
||||
/** Marks the map canvas so a test can click a marker on it. */
|
||||
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
||||
internal const val GROUPING_TAG = "station-map-grouping"
|
||||
internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top"
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleRoster;
|
||||
import kst4contest.view.compose.SelectedMessageFilter;
|
||||
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -136,6 +143,39 @@ class RosterListBindingTest {
|
||||
"a newly set filter must be visible without waiting for the next roster change");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectedStationMessagesFollowNewMessagesAndSelectionChanges() {
|
||||
ChatMember first = member("DL1FIRST", 2);
|
||||
ChatMember second = member("DL2SECOND", 2);
|
||||
SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||
AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>(
|
||||
SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC, first, "DL0OWN"));
|
||||
|
||||
RosterListBinding<ChatMessage> binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||
messages);
|
||||
|
||||
ChatMessage unrelated = publicMessage(second);
|
||||
messages.add(unrelated);
|
||||
assertTrue(binding.list().isEmpty());
|
||||
|
||||
ChatMessage matching = publicMessage(first);
|
||||
messages.add(matching);
|
||||
assertEquals(List.of(matching), List.copyOf(binding.list()));
|
||||
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC, second, "DL0OWN"));
|
||||
binding.refresh();
|
||||
assertEquals(List.of(unrelated), List.copyOf(binding.list()),
|
||||
"the former station's messages must disappear immediately");
|
||||
|
||||
ChatMessage next = publicMessage(second);
|
||||
messages.add(next);
|
||||
assertEquals(List.of(unrelated, next), List.copyOf(binding.list()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDisposedDerivedBindingDetachesFromEverySource() {
|
||||
SimpleRoster<String> members = new SimpleRoster<>();
|
||||
@@ -150,4 +190,20 @@ class RosterListBindingTest {
|
||||
|
||||
assertTrue(binding.list().isEmpty());
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign, int category) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
member.setChatCategory(new ChatCategory(category));
|
||||
return member;
|
||||
}
|
||||
|
||||
private static ChatMessage publicMessage(ChatMember sender) {
|
||||
ChatMessage message = new ChatMessage();
|
||||
message.setSender(sender);
|
||||
message.setReceiver(member("ALL", sender.getChatCategory().getCategoryNumber()));
|
||||
message.setChatCategory(sender.getChatCategory());
|
||||
message.setMessageText("test");
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package kst4contest.view;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.ChatController;
|
||||
import kst4contest.controller.MessageBusManagementThread;
|
||||
import kst4contest.controller.ScoreService;
|
||||
import kst4contest.controller.StationMetricsService;
|
||||
import kst4contest.controller.ThreadStatusCallback;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.ChatCategory;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatMessage;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.observe.DirectUiDispatcher;
|
||||
import kst4contest.observe.SimpleRoster;
|
||||
import kst4contest.observe.SimpleValue;
|
||||
import kst4contest.view.compose.DataTableState;
|
||||
import kst4contest.view.compose.RowKeys;
|
||||
import kst4contest.view.compose.SelectedMessageFilter;
|
||||
import kst4contest.view.compose.SelectedStationMessagePredicate;
|
||||
import kst4contest.view.feed.SelectedStationMessagesFeed;
|
||||
|
||||
/** Exercises protocol parsing, selected-station filtering and the displayed table together. */
|
||||
class SelectedStationMessageFlowTest {
|
||||
|
||||
private static final String OWN_CALL = "DL0OWN";
|
||||
private static final String SELECTED_CALL = "DM5M";
|
||||
|
||||
private final LinkedBlockingQueue<ChatMessage> receiveQueue = new LinkedBlockingQueue<>();
|
||||
private final SimpleRoster<ChatMessage> messages = new SimpleRoster<>();
|
||||
private final AtomicReference<Predicate<ChatMessage>> filter = new AtomicReference<>();
|
||||
|
||||
private ChatController controller;
|
||||
private ChatPreferences preferences;
|
||||
private ChatMember selected;
|
||||
private RosterListBinding<ChatMessage> binding;
|
||||
private DataTableState<ChatMessage> displayed;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
preferences = mock(ChatPreferences.class);
|
||||
when(preferences.getStn_loginCallSign()).thenReturn(OWN_CALL);
|
||||
when(preferences.getStn_loginLocatorMainCat()).thenReturn("JO50LL");
|
||||
when(preferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.300"));
|
||||
when(preferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("1296.200"));
|
||||
|
||||
controller = mock(ChatController.class);
|
||||
when(controller.getChatPreferences()).thenReturn(preferences);
|
||||
when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF));
|
||||
when(controller.getChatCategorySecondChat()).thenReturn(new ChatCategory(ChatCategory.MICROWAVE));
|
||||
when(controller.getStationMetricsService()).thenReturn(mock(StationMetricsService.class));
|
||||
when(controller.getScoreService()).thenReturn(mock(ScoreService.class));
|
||||
when(controller.findActiveChatMember(any(ChatMember.class)))
|
||||
.thenAnswer(invocation -> activeMember(invocation.getArgument(0)));
|
||||
doAnswer(invocation -> {
|
||||
messages.add(invocation.getArgument(0));
|
||||
return null;
|
||||
}).when(controller).publishChatMessage(any(ChatMessage.class));
|
||||
|
||||
selected = member(SELECTED_CALL, ChatCategory.VUHF);
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME, selected, OWN_CALL));
|
||||
|
||||
displayed = new DataTableState<>(List.of(), RowKeys.INSTANCE.byReference(), "selected-flow-test");
|
||||
SelectedStationMessagesFeed feed =
|
||||
new SelectedStationMessagesFeed(displayed, new DirectUiDispatcher());
|
||||
binding = RosterListBinding.derived(
|
||||
new DirectUiDispatcher(),
|
||||
() -> messages.snapshot().stream().filter(filter.get()).toList(),
|
||||
messages);
|
||||
binding.onChanged(feed::push);
|
||||
feed.push(binding.list());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(10)
|
||||
void realisticFramesReachTheDisplayedRowsUnderAllFourModes() throws InterruptedException {
|
||||
runThrough(
|
||||
chatFrame(2, SELECTED_CALL, "private", OWN_CALL),
|
||||
chatFrame(2, SELECTED_CALL, "(" + OWN_CALL + ") public reply", "0"),
|
||||
chatFrame(2, SELECTED_CALL, "general public message", "0"),
|
||||
chatFrame(2, SELECTED_CALL, "private to another station", "DL2OTHER"),
|
||||
chatFrame(2, "DL2OTHER", "other station", "0"),
|
||||
chatFrame(3, SELECTED_CALL, "same call, other category", "0"));
|
||||
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"pm to me must contain the genuine PM and the explicitly addressed public line");
|
||||
|
||||
selectMode(SelectedMessageFilter.PUBLIC);
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"the two public lines overlap with the addressed-public PM view");
|
||||
|
||||
selectMode(SelectedMessageFilter.PM_TO_OTHER);
|
||||
assertEquals(1, displayed.getRows().size(),
|
||||
"pm to other keeps private traffic sent by the selected station to another station");
|
||||
|
||||
selectMode(SelectedMessageFilter.NOTHING);
|
||||
assertEquals(4, displayed.getRows().size(),
|
||||
"nothing removes only the type/receiver restriction");
|
||||
|
||||
selectStation(member("DL2OTHER", ChatCategory.VUHF));
|
||||
assertEquals(2, displayed.getRows().size(),
|
||||
"changing the selected station must replace the displayed station scope immediately");
|
||||
selectStation(member(SELECTED_CALL, ChatCategory.VUHF));
|
||||
|
||||
runThrough(chatFrame(2, SELECTED_CALL, "new while the panel is open", OWN_CALL));
|
||||
assertEquals(5, displayed.getRows().size(),
|
||||
"a new matching line must reach the already open table");
|
||||
}
|
||||
|
||||
private void selectMode(SelectedMessageFilter mode) {
|
||||
filter.set(SelectedStationMessagePredicate.forSelection(mode, selected, OWN_CALL));
|
||||
binding.refresh();
|
||||
}
|
||||
|
||||
private void selectStation(ChatMember station) {
|
||||
selected = station;
|
||||
selectMode(SelectedMessageFilter.NOTHING);
|
||||
}
|
||||
|
||||
private void runThrough(ChatMessage... frames) throws InterruptedException {
|
||||
MessageBusManagementThread thread = new MessageBusManagementThread(
|
||||
controller,
|
||||
mock(ThreadStatusCallback.class),
|
||||
11L,
|
||||
receiveQueue,
|
||||
ignored -> true);
|
||||
for (ChatMessage frame : frames) {
|
||||
receiveQueue.add(frame);
|
||||
}
|
||||
receiveQueue.add(poisonPill());
|
||||
thread.start();
|
||||
thread.join();
|
||||
}
|
||||
|
||||
private static ChatMessage chatFrame(int category, String sender, String text, String receiver) {
|
||||
return wireMessage("CH|" + category + "|1700000000|" + sender
|
||||
+ "|Operator|0|" + text + "|" + receiver + "|");
|
||||
}
|
||||
|
||||
private static ChatMessage poisonPill() {
|
||||
ChatMessage message = wireMessage(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||
message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ChatMessage wireMessage(String text) {
|
||||
ChatMessage message = new ChatMessage();
|
||||
message.setMessageText(text);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static ChatMember activeMember(ChatMember lookup) {
|
||||
return member(lookup.getCallSign(), lookup.getChatCategory().getCategoryNumber());
|
||||
}
|
||||
|
||||
private static ChatMember member(String callSign, int category) {
|
||||
ChatMember member = new ChatMember();
|
||||
member.setCallSign(callSign);
|
||||
member.setChatCategory(new ChatCategory(category));
|
||||
member.setQra("JO31AA");
|
||||
member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
|
||||
return member;
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,23 @@ package kst4contest.view.compose
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.abs
|
||||
|
||||
/** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@@ -66,4 +70,53 @@ class ChatInputRowSo2rScreenTest {
|
||||
onNodeWithText("270.0").assertExists()
|
||||
onNodeWithText("270.0").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `send buttons message and station fields share height and preserve realistic values`() = runComposeUiTest {
|
||||
val prefs = prefs().apply {
|
||||
getMYQRGFirstCat().set("1296.123.000")
|
||||
getMYQRGSecondCat().set("144.310.000")
|
||||
getActualQTF().set(360.0)
|
||||
}
|
||||
val state = chatInputStateFor(prefs).apply {
|
||||
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||
}
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
ChatInputRow(
|
||||
state = state,
|
||||
prefs = prefs,
|
||||
ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()),
|
||||
ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()),
|
||||
antennaQtf = ObservedValue(prefs.getActualQTF()),
|
||||
antennaQtf2 = ObservedValue(prefs.getActualQTF2()),
|
||||
so2rEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val values = listOf("1296.123.000", "144.310.000", "360.0")
|
||||
val heights = listOf("TX", "clear")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||
values.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"send-row control heights differ: $heights",
|
||||
)
|
||||
|
||||
val minimumQrgWidth = with(density) { 96.dp.toPx() }
|
||||
values.take(2).forEach { value ->
|
||||
val width = onNodeWithText(value).fetchSemanticsNode().boundsInRoot.width
|
||||
assertTrue(width >= minimumQrgWidth - 1f, "$value has only $width px")
|
||||
}
|
||||
val qtfWidth = onNodeWithText("360.0").fetchSemanticsNode().boundsInRoot.width
|
||||
assertTrue(
|
||||
abs(qtfWidth - with(density) { 56.dp.toPx() }) <= 1f,
|
||||
"360.0 field is $qtfWidth px wide",
|
||||
)
|
||||
assertTrue(
|
||||
abs(onNodeWithText("TX").fetchSemanticsNode().boundsInRoot.height -
|
||||
with(density) { 26.dp.toPx() }) <= 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Shared fields and choices use the same compact desktop height. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class CompactControlsScreenTest {
|
||||
|
||||
@Test
|
||||
fun fieldsChoicesAndButtonsShareTheDesktopControlHeight() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
CompactTextField(value = "144.310.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
Form.button(text = "Filter", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("144.310.000", "Auto", "TX", "Filter")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
val targetHeight = with(density) { 26.dp.toPx() }
|
||||
|
||||
assertTrue(
|
||||
heights.all { abs(it - targetHeight) <= 1f },
|
||||
"controls are $heights px tall instead of $targetHeight px",
|
||||
)
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"control heights differ by more than one pixel: $heights",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accentAndToggleButtonsShareEnabledAndDisabledClickBehaviour() = runComposeUiTest {
|
||||
var accentClicks = 0
|
||||
var disabledClicks = 0
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
Form.button(
|
||||
text = "Accent",
|
||||
accented = true,
|
||||
onClick = { accentClicks++ },
|
||||
)
|
||||
Form.button(
|
||||
text = "Selected",
|
||||
selected = true,
|
||||
onClick = {},
|
||||
)
|
||||
Form.button(
|
||||
text = "Disabled accent",
|
||||
enabled = false,
|
||||
accented = true,
|
||||
onClick = { disabledClicks++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Accent").performClick()
|
||||
onNodeWithText("Disabled accent").performClick()
|
||||
|
||||
assertEquals(1, accentClicks)
|
||||
assertEquals(0, disabledClicks)
|
||||
assertTrue(onNodeWithText("Selected").fetchSemanticsNode().boundsInRoot.height < 40f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlsStayAlignedAt125PercentDensityInEveningDesign() = runComposeUiTest {
|
||||
setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides androidx.compose.ui.unit.Density(1.25f),
|
||||
) {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Column {
|
||||
CompactTextField(value = "10368.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("10368.000", "Auto", "TX")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
val expected = 26f * 1.25f
|
||||
assertTrue(
|
||||
heights.all { abs(it - expected) <= 1f },
|
||||
"125% controls are $heights px tall instead of about $expected px",
|
||||
)
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"125% control heights differ by more than one pixel: $heights",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlsGrowTogetherInsteadOfClippingALargerConfiguredFont() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 24f) {
|
||||
Column {
|
||||
CompactTextField(value = "1296.123.000", onValueChange = {})
|
||||
Form.inlineChoice(
|
||||
label = "Reachability",
|
||||
items = listOf("Auto"),
|
||||
selected = "Auto",
|
||||
describe = { it },
|
||||
onSelect = {},
|
||||
)
|
||||
Form.button(text = "TX", onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val heights = listOf("1296.123.000", "Auto", "TX")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height }
|
||||
assertTrue(heights.min() > with(density) { 26.dp.toPx() })
|
||||
assertTrue(
|
||||
heights.max() - heights.min() <= 1f,
|
||||
"large-font controls do not grow together: $heights",
|
||||
)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
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.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.EnumSet
|
||||
|
||||
/** Renders the compact control strips for visual inspection in both supported palettes. */
|
||||
class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningControlsAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "compact-controls")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(darkMode, scale)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
Files.write(reportDirectory.resolve("$palette-$scaling.png"), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$palette at $scaling% did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(darkMode: Boolean, scale: Float): ByteArray {
|
||||
val preferences = ChatPreferences(tempDir.resolve("preferences-$darkMode-$scale.xml")).apply {
|
||||
getMYQRGFirstCat().set("1296.123.000")
|
||||
getMYQRGSecondCat().set("144.310.000")
|
||||
getActualQTF().set(360.0)
|
||||
}
|
||||
val chatInputState = ChatInputState(RenderActions(preferences)).apply {
|
||||
value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg")
|
||||
}
|
||||
val filters = StationFilterState()
|
||||
|
||||
val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Column(Modifier.padding(4.dp)) {
|
||||
TimelineView(
|
||||
state = TimelineState(),
|
||||
onCandidateClicked = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
reachability = ReachabilityState(
|
||||
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
),
|
||||
onChanged = {},
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ChatInputRow(
|
||||
state = chatInputState,
|
||||
prefs = preferences,
|
||||
ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()),
|
||||
ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()),
|
||||
antennaQtf = ObservedValue(preferences.getActualQTF()),
|
||||
antennaQtf2 = ObservedValue(preferences.getActualQTF2()),
|
||||
so2rEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions {
|
||||
override fun panelSelection(): ChatMember? = null
|
||||
override fun tableSelection(): ChatMember? = null
|
||||
override fun scoreSelection(): ChatMember? = null
|
||||
override fun resolveVariables(template: String, member: ChatMember?): String = template
|
||||
override fun isAddressedToOwnCallsign(text: String): Boolean = false
|
||||
override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null
|
||||
override fun mainCategory(): ChatCategory? = null
|
||||
override fun recordOutboundCq(text: String) = Unit
|
||||
override fun requestRecompute(reason: String) = Unit
|
||||
override fun queue(message: ChatMessage) = Unit
|
||||
override fun ownQrgMain(): String = preferences.getMYQRGFirstCat().get().orEmpty()
|
||||
override fun ownQrgSecond(): String = preferences.getMYQRGSecondCat().get().orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.tabs.showTab
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class ConnectionBarScreenTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun `button availability follows every manager lifecycle state`() {
|
||||
var connectionState = On4KstConnectionState.DISCONNECTED
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
On4KstConnectionState.entries.forEach { lifecycle ->
|
||||
connectionState = lifecycle
|
||||
assertEqualsLifecycleRules(lifecycle, state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `async disconnect redraws the open settings connection bar`() = runComposeUiTest {
|
||||
var connectionState by mutableStateOf(On4KstConnectionState.ONLINE)
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
showTab {
|
||||
ConnectionBar(
|
||||
state = state,
|
||||
onConnect = { },
|
||||
onSave = { },
|
||||
onApplyAndClose = { },
|
||||
onDisconnectAndCloseChat = { },
|
||||
onDisconnectOnly = { },
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
onNodeWithText("Disconnect").assertIsEnabled()
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.STOPPING }
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||
onNodeWithText("Connect", substring = true).assertIsEnabled()
|
||||
onNodeWithText("Disconnect").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed attempt enables one new connection attempt`() = runComposeUiTest {
|
||||
var connectionState by mutableStateOf(On4KstConnectionState.CONNECTING)
|
||||
var attempts = 0
|
||||
val state = ConnectionBarState(preferences()) { connectionState }
|
||||
|
||||
showTab {
|
||||
ConnectionBar(
|
||||
state = state,
|
||||
onConnect = {
|
||||
attempts++
|
||||
connectionState = On4KstConnectionState.CONNECTING
|
||||
},
|
||||
onSave = { },
|
||||
onApplyAndClose = { },
|
||||
onDisconnectAndCloseChat = { },
|
||||
onDisconnectOnly = { },
|
||||
)
|
||||
}
|
||||
|
||||
runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED }
|
||||
onNodeWithText("Connect", substring = true).performClick()
|
||||
|
||||
assertTrue(attempts == 1)
|
||||
onNodeWithText("Connect", substring = true).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private fun assertEqualsLifecycleRules(
|
||||
lifecycle: On4KstConnectionState,
|
||||
state: ConnectionBarState,
|
||||
) {
|
||||
assertTrue(state.canConnect == (lifecycle == On4KstConnectionState.DISCONNECTED), lifecycle.name)
|
||||
assertTrue(state.canDisconnect == lifecycle.isConnectionAttemptActive, lifecycle.name)
|
||||
if (lifecycle == On4KstConnectionState.STOPPING) {
|
||||
assertFalse(state.canConnect)
|
||||
assertFalse(state.canDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
private fun preferences() = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
}
|
||||
@@ -34,6 +34,97 @@ class DataTableStateTest {
|
||||
rowKey = RowKeys.byValue { s: Station -> s.callSign },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an initial sort is applied once and keeps later rows in order`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
id = "callsign",
|
||||
title = "Callsign",
|
||||
value = { station: Station -> station.callSign },
|
||||
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { station: Station ->
|
||||
station.callSign.trim()
|
||||
},
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
initialSortColumnId = "callsign",
|
||||
)
|
||||
|
||||
table.replaceRows(listOf(Station("do5amf", 1), Station("DL1ABC", 2)))
|
||||
assertEquals(listOf("DL1ABC", "do5amf"), table.visibleRows.map { it.callSign })
|
||||
|
||||
table.replaceRows(
|
||||
listOf(Station("ZZ9ZZZ", 3), Station("do5amf", 1), Station("AA1AAA", 4), Station("DL1ABC", 2)),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("AA1AAA", "DL1ABC", "do5amf", "ZZ9ZZZ"),
|
||||
table.visibleRows.map { it.callSign },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a user sort replaces the initial sort and survives row and filter updates`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(id = "callsign", title = "Callsign", value = { station: Station -> station.callSign }),
|
||||
DataColumn(
|
||||
id = "qrb",
|
||||
title = "QRB",
|
||||
value = { station: Station -> station.qrb.toString() },
|
||||
comparator = compareBy { station: Station -> station.qrb },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
initialSortColumnId = "callsign",
|
||||
)
|
||||
table.replaceRows(listOf(Station("B", 300), Station("A", 100)))
|
||||
|
||||
table.toggleSort("qrb")
|
||||
table.rowFilter = { it.qrb >= 100 }
|
||||
table.replaceRows(listOf(Station("C", 200), Station("B", 300), Station("A", 100)))
|
||||
|
||||
assertEquals("qrb", table.sortedColumnId)
|
||||
assertEquals(listOf("A", "C", "B"), table.visibleRows.map { it.callSign })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a selected filtered row can be pinned only in the table view`() {
|
||||
val table = DataTableState(
|
||||
columns = listOf(DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign })),
|
||||
rowKey = RowKeys.byValue { station: Station -> station.callSign },
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val hidden = Station("DL1HIDE", 100)
|
||||
val shown = Station("DL2SHOW", 500)
|
||||
table.replaceRows(listOf(hidden, shown))
|
||||
table.rowFilter = { it.qrb >= 300 }
|
||||
|
||||
table.select(hidden)
|
||||
|
||||
assertEquals(listOf("DL2SHOW"), table.filteredRows.map { it.callSign }, "the shared map filter changed")
|
||||
assertEquals(
|
||||
listOf("DL1HIDE", "DL2SHOW"),
|
||||
table.visibleRows.map { it.callSign },
|
||||
"the selected station was not made reachable in the table",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same selected row can request scrolling more than once`() {
|
||||
val table = stationTable()
|
||||
val selected = Station("DL1ABC", 100)
|
||||
table.replaceRows(listOf(selected))
|
||||
table.select(selected)
|
||||
|
||||
table.requestScrollToSelection()
|
||||
val first = table.scrollRequestRevision
|
||||
table.requestScrollToSelection()
|
||||
|
||||
assertTrue(table.scrollRequestRevision > first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the selection follows the row, not its position`() {
|
||||
val table = stationTable()
|
||||
@@ -388,6 +479,24 @@ class DataTableColumnWidthTest {
|
||||
assertEquals(140.0, table(store).widthOf("callsign"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored narrow width wins over the automatic width`() {
|
||||
val store = RecordingStore()
|
||||
store.stored["dx-cluster-monitor/callsign"] = 36.0
|
||||
val table = table(store)
|
||||
val column = table.columns.first { it.id == "callsign" }
|
||||
|
||||
assertEquals(36f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the automatic width is used only without a stored width`() {
|
||||
val table = table(RecordingStore())
|
||||
val column = table.columns.first { it.id == "callsign" }
|
||||
|
||||
assertEquals(140f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a column never sized yet has no stored width`() {
|
||||
assertNull(table(RecordingStore()).widthOf("qra"),
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.tabs.GuiOptionsTabState
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import java.util.Locale
|
||||
|
||||
class GuiStartupDesignPersistenceTest(@param:TempDir private val tempDir: Path) {
|
||||
|
||||
@Test
|
||||
fun `root profile keeps evening startup design across save layout autosave and reload`() {
|
||||
assertStartupDesignSurvives(tempDir.resolve("preferences.xml"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `named profile keeps evening startup design across save layout autosave and reload`() {
|
||||
assertStartupDesignSurvives(tempDir.resolve("profiles/CONTEST/preferences.xml"))
|
||||
}
|
||||
|
||||
private fun assertStartupDesignSurvives(preferencesFile: Path) {
|
||||
val preferences = ChatPreferences(preferencesFile)
|
||||
val draft = SettingsDraft()
|
||||
val notices = SettingsNotices()
|
||||
val state = GuiOptionsTabState(preferences, draft, languageStore())
|
||||
|
||||
state.gUI_darkModeActiveByDefault = true
|
||||
applyAndSave(draft, notices) {
|
||||
preferences.writePreferencesToXmlFile()
|
||||
.takeIf { it }
|
||||
?.let { preferences.storeAndRestorePreferencesFileName }
|
||||
}
|
||||
|
||||
assertTrue(preferences.isGUI_darkModeActiveByDefault)
|
||||
|
||||
// The menu switch is session-only and must not alter the next startup.
|
||||
preferences.isGUI_darkModeActive = false
|
||||
preferences.getGUIsettingsStageSceneSizeHW()[0] = 777.0
|
||||
assertTrue(preferences.writeLayoutPreferencesToXmlFile())
|
||||
|
||||
val restored = ChatPreferences(preferencesFile)
|
||||
assertTrue(restored.readPreferencesFromXmlFile())
|
||||
assertTrue(restored.isGUI_darkModeActiveByDefault)
|
||||
assertFalse(restored.isGUI_darkModeActive)
|
||||
}
|
||||
|
||||
private fun languageStore() = LanguageStore(
|
||||
storedLanguage = { SYSTEM_LANGUAGE },
|
||||
storeLanguage = { },
|
||||
systemDefault = Locale.UK,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import kst4contest.model.ClusterMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MessageTabsScreenTest {
|
||||
|
||||
@Test
|
||||
fun `tab semantics distinguish the active tab and follow selection`() = runComposeUiTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = messageTable(),
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = messageTable(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Public messages").assertIsSelected()
|
||||
onNodeWithText("DXCluster messages").assertIsNotSelected().performClick()
|
||||
waitForIdle()
|
||||
onNodeWithText("Public messages").assertIsNotSelected()
|
||||
onNodeWithText("DXCluster messages").assertIsSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a public message row reports its sender selection source`() = runComposeUiTest {
|
||||
val public = messageTable()
|
||||
val message = messageFrom("DL1PUBLIC")
|
||||
public.replaceRows(listOf(message))
|
||||
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = public,
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = messageTable(),
|
||||
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("DL1PUBLIC").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(message, clicks.single().first)
|
||||
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, clicks.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a qso of the other row reports its sender selection source`() = runComposeUiTest {
|
||||
val qso = messageTable()
|
||||
val message = messageFrom("DL2QSO")
|
||||
qso.replaceRows(listOf(message))
|
||||
val clicks = mutableListOf<Pair<ChatMessage, StationSelectionSource>>()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
MessageTabs(
|
||||
publicMessages = messageTable(),
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = qso,
|
||||
onStationMessageClicked = { clicked, source -> clicks += clicked to source },
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("QSO of the other").performClick()
|
||||
waitForIdle()
|
||||
onNodeWithText("DL2QSO").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(message, clicks.single().first)
|
||||
assertEquals(StationSelectionSource.QSO_OF_THE_OTHER, clicks.single().second)
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
id = "sender",
|
||||
title = "Sender",
|
||||
value = { message: ChatMessage -> message.sender?.callSign.orEmpty() },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
private fun clusterTable() = DataTableState(
|
||||
columns = emptyList<DataColumn<ClusterMessage>>(),
|
||||
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||
)
|
||||
|
||||
private fun messageFrom(callSign: String): ChatMessage = ChatMessage().apply {
|
||||
sender = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(2)
|
||||
}
|
||||
receiver = ChatMember().apply { this.callSign = "ALL" }
|
||||
chatCategory = ChatCategory(2)
|
||||
messageText = "test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
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
|
||||
import java.util.EnumSet
|
||||
|
||||
class ReachabilityStateTest {
|
||||
|
||||
@Test
|
||||
fun `auto and only locally active bands are offered`() {
|
||||
val state = ReachabilityState(EnumSet.of(Band.B_144, Band.B_1296))
|
||||
|
||||
assertEquals(listOf(null, Band.B_144, Band.B_1296), state.options.map { it.band })
|
||||
assertNull(state.bandOverride)
|
||||
assertEquals("Auto", state.selectedOption.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `band changes redraw but never calculate`() {
|
||||
val station = ChatMember().apply { callSign = "DL1ABC" }
|
||||
val calculated = mutableListOf<Pair<ChatMember, Band>>()
|
||||
var redraws = 0
|
||||
val state = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
resolveAutoBand = { Band.B_144 },
|
||||
selectedStation = { station },
|
||||
calculate = { member, band -> calculated += member to band },
|
||||
refresh = { redraws++ },
|
||||
)
|
||||
|
||||
state.selectBand(Band.B_432)
|
||||
|
||||
assertEquals(Band.B_432, state.resolveBand(station))
|
||||
assertEquals(1, redraws)
|
||||
assertTrue(calculated.isEmpty())
|
||||
|
||||
assertTrue(state.calculateSelected())
|
||||
assertEquals(listOf(station to Band.B_432), calculated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto uses shared resolver and unavailable selections do not calculate`() {
|
||||
var selected: ChatMember? = ChatMember()
|
||||
var calculations = 0
|
||||
val state = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
resolveAutoBand = { null },
|
||||
selectedStation = { selected },
|
||||
calculate = { _, _ -> calculations++ },
|
||||
)
|
||||
|
||||
assertFalse(state.canCalculate)
|
||||
assertFalse(state.calculateSelected())
|
||||
selected = null
|
||||
assertFalse(state.calculateSelected())
|
||||
assertEquals(0, calculations)
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SelectedStationMessagePredicateTest {
|
||||
|
||||
private val ownCall = "DL0OWN"
|
||||
private val selected = member("DL1SEL", 2)
|
||||
|
||||
@Test
|
||||
fun `pm to me contains both private directions with the selected station`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pm to me also contains an explicitly addressed public message`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2, addressedToMe = false)))
|
||||
|
||||
val mentionOnly = publicMessage(selected, 2).apply {
|
||||
messageText = "Please ask $ownCall whether the path is open"
|
||||
}
|
||||
assertFalse(predicate.test(mentionOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pm to other contains only private messages sent by the selected station to others`() {
|
||||
val other = member("DL2OTHER", 2)
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_OTHER,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, other, 2)))
|
||||
assertFalse(predicate.test(message(other, selected, 2)))
|
||||
assertFalse(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertFalse(predicate.test(publicMessage(selected, 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public contains only public messages sent by the selected station`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||
assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true)))
|
||||
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||
assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing applies no additional message type restriction`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.NOTHING,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(selected, member(ownCall, 2), 2)))
|
||||
assertTrue(predicate.test(message(member(ownCall, 2), selected, 2)))
|
||||
assertTrue(predicate.test(publicMessage(selected, 2)))
|
||||
assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same callsign in another category does not match`() {
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertFalse(predicate.test(publicMessage(member("DL1SEL", 3), 3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message category is authoritative when a member snapshot has no category`() {
|
||||
val senderWithoutCategory = member("DL1SEL", 2).apply { chatCategory = null }
|
||||
val predicate = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PM_TO_ME,
|
||||
selected,
|
||||
ownCall,
|
||||
)
|
||||
|
||||
assertTrue(predicate.test(message(senderWithoutCategory, member(ownCall, 2), 2)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing selection or incomplete historical data fails closed`() {
|
||||
val withoutSelection = SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
null,
|
||||
ownCall,
|
||||
)
|
||||
val incomplete = ChatMessage().apply { chatCategory = ChatCategory(2) }
|
||||
|
||||
assertFalse(withoutSelection.test(publicMessage(selected, 2)))
|
||||
assertFalse(
|
||||
SelectedStationMessagePredicate.forSelection(SelectedMessageFilter.PUBLIC, selected, ownCall)
|
||||
.test(incomplete),
|
||||
)
|
||||
assertFalse(
|
||||
SelectedStationMessagePredicate.forSelection(
|
||||
SelectedMessageFilter.PUBLIC,
|
||||
ChatMember().apply { callSign = "DL1SEL" },
|
||||
ownCall,
|
||||
).test(publicMessage(selected, 2)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun message(sender: ChatMember, receiver: ChatMember, category: Int): ChatMessage = ChatMessage().apply {
|
||||
this.sender = sender
|
||||
this.receiver = receiver
|
||||
chatCategory = ChatCategory(category)
|
||||
messageText = "test"
|
||||
}
|
||||
|
||||
private fun publicMessage(
|
||||
sender: ChatMember,
|
||||
category: Int,
|
||||
addressedToMe: Boolean = false,
|
||||
): ChatMessage = message(sender, member("ALL", category), category).apply {
|
||||
isMessageDirectedToCommunity = true
|
||||
isMessageDirectedToMe = addressedToMe
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int): ChatMember = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.unit.Dp
|
||||
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.ClusterMessage
|
||||
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.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
|
||||
/** Produces review images for the selected-station controls and lower message tabs. */
|
||||
class SelectedStationMessageUiVisualRenderingTest {
|
||||
|
||||
@Test
|
||||
fun rendersDayAndEveningAt100And125Percent() {
|
||||
val reportDirectory = Path.of("build", "reports", "selected-station-message-ui")
|
||||
Files.createDirectories(reportDirectory)
|
||||
|
||||
val scenarios = listOf(
|
||||
RenderScenario(prefix = "", panelWidth = 570.dp, windowHeight = 768),
|
||||
RenderScenario(prefix = "narrow-", panelWidth = 360.dp, windowHeight = 768),
|
||||
RenderScenario(prefix = "short-", panelWidth = 570.dp, windowHeight = 480),
|
||||
)
|
||||
scenarios.forEach { scenario ->
|
||||
listOf(false, true).forEach { darkMode ->
|
||||
listOf(1f, 1.25f).forEach { scale ->
|
||||
val bytes = render(darkMode, scale, scenario.panelWidth, scenario.windowHeight)
|
||||
val palette = if (darkMode) "evening" else "day"
|
||||
val scaling = if (scale == 1f) "100" else "125"
|
||||
val name = "${scenario.prefix}$palette-$scaling.png"
|
||||
Files.write(reportDirectory.resolve(name), bytes)
|
||||
assertTrue(bytes.size > 10_000, "$name did not render")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(darkMode: Boolean, scale: Float, panelWidth: Dp, windowHeight: Int): ByteArray {
|
||||
val selectedMember = member("DM5M", 2).apply {
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
isQrv144 = false
|
||||
isQrv1240 = false
|
||||
}
|
||||
val selectedState = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { "Bands: 144/432 MHz" },
|
||||
).apply {
|
||||
select(selectedMember)
|
||||
skedBand = Band.B_144
|
||||
skedMode = "SSB"
|
||||
remindPm = true
|
||||
}
|
||||
val selectedMessages = messageTable().apply {
|
||||
replaceRows(
|
||||
listOf(
|
||||
message(selectedMember, member("DL0OWN", 2), "pse ur qrg"),
|
||||
message(selectedMember, member("ALL", 2), "(DL0OWN) qrv 144.300"),
|
||||
),
|
||||
)
|
||||
}
|
||||
val publicMessages = messageTable().apply {
|
||||
replaceRows(listOf(message(selectedMember, member("ALL", 2), "cq 144.300")))
|
||||
}
|
||||
val qsoMessages = messageTable().apply {
|
||||
replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked")))
|
||||
}
|
||||
|
||||
val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) {
|
||||
Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize().padding(6.dp)) {
|
||||
MessageTabs(
|
||||
publicMessages = publicMessages,
|
||||
clusterMessages = clusterTable(),
|
||||
qsoOfTheOther = qsoMessages,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
SelectedStationPanel(
|
||||
state = selectedState,
|
||||
messages = selectedMessages,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(panelWidth).padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
scene.render().encodeToData()!!.bytes
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn("sender", "Sender", value = { message: ChatMessage -> message.sender.callSign }),
|
||||
DataColumn("message", "Message", value = { message: ChatMessage -> message.messageText.orEmpty() }),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ChatMessage>(),
|
||||
)
|
||||
|
||||
private fun clusterTable() = DataTableState(
|
||||
columns = listOf(
|
||||
DataColumn(
|
||||
"message",
|
||||
"DXCluster",
|
||||
value = { message: ClusterMessage -> message.messageInhibited.orEmpty() },
|
||||
),
|
||||
),
|
||||
rowKey = RowKeys.byReference<ClusterMessage>(),
|
||||
)
|
||||
|
||||
private fun message(sender: ChatMember, receiver: ChatMember, text: String) = ChatMessage().apply {
|
||||
this.sender = sender
|
||||
this.receiver = receiver
|
||||
chatCategory = sender.chatCategory
|
||||
messageText = text
|
||||
}
|
||||
|
||||
private fun member(callSign: String, category: Int) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
qra = "JO31AA"
|
||||
}
|
||||
|
||||
private data class RenderScenario(
|
||||
val prefix: String,
|
||||
val panelWidth: Dp,
|
||||
val windowHeight: Int,
|
||||
)
|
||||
}
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.util.EnumSet
|
||||
import java.util.function.Consumer
|
||||
import kotlin.math.abs
|
||||
|
||||
/** The selected-station controls remain reachable when its splitter pane is short. */
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class SelectedStationPanelScreenTest {
|
||||
|
||||
@Test
|
||||
fun stationHeaderKeepsAllMetadataOnOneLineWhenWideAndOmitsTheLocator() = runComposeUiTest {
|
||||
val station = member("CT1EKD").apply {
|
||||
qra = "IM59IB"
|
||||
chatCategory = ChatCategory(2)
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
}
|
||||
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||
|
||||
setPanel(selected, Modifier.width(900.dp).height(420.dp))
|
||||
|
||||
val callsignTop = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot.top
|
||||
listOf(
|
||||
station.chatCategory.getChatCategoryName(station.chatCategory.categoryNumber),
|
||||
"QTF: 255.54°",
|
||||
"QRB: 1907.59 km",
|
||||
"Bands: 144/432 MHz",
|
||||
).forEach { text ->
|
||||
val top = onNodeWithText(text).assertIsDisplayed().fetchSemanticsNode().boundsInRoot.top
|
||||
assertTrue(abs(top - callsignTop) <= 4f, "$text did not remain in the wide title row")
|
||||
}
|
||||
onNodeWithText("Last activity:", substring = true).assertIsDisplayed()
|
||||
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationHeaderWrapsMetadataWithoutClippingWhenNarrow() = runComposeUiTest {
|
||||
val station = member("CT1EKD").apply {
|
||||
qra = "IM59IB"
|
||||
chatCategory = ChatCategory(2)
|
||||
setQTFdirection(255.54)
|
||||
qrb = 1907.59
|
||||
activityTimeLastInEpoch = Instant.now().epochSecond - 240
|
||||
}
|
||||
val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz")
|
||||
|
||||
setPanel(selected, Modifier.width(310.dp).height(620.dp))
|
||||
|
||||
val callsign = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot
|
||||
val bands = onNodeWithText("Bands: 144/432 MHz").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(bands.top > callsign.top, "the narrow header did not wrap")
|
||||
assertTrue(bands.right <= panel.right, "wrapped metadata crossed the panel edge")
|
||||
onNodeWithText("IM59IB").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationHeaderKeepsTheAlreadyLabelledEmptyBandsValueExactlyOnce() = runComposeUiTest {
|
||||
val selected = selectedState(member("DM5M"), detectedBands = "Bands: -")
|
||||
|
||||
setPanel(selected, Modifier.width(570.dp).height(420.dp))
|
||||
|
||||
onNodeWithText("Bands: -").assertIsDisplayed()
|
||||
onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageFilterLabelSharesTheFirstFlowItemWithPmToMe() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setPanel(selected, Modifier.width(250.dp).height(520.dp))
|
||||
|
||||
val label = onNodeWithText("Show messages:").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
val firstChoice = onNodeWithText("pm to me").assertIsDisplayed()
|
||||
.fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(420.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Tag DM5M not QRV at bands:").assertIsDisplayed()
|
||||
|
||||
runOnIdle { selected.select(member("DL1ABC")) }
|
||||
waitForIdle()
|
||||
onNodeWithText("Tag DM5M not QRV at bands:").assertDoesNotExist()
|
||||
onNodeWithText("Tag DL1ABC not QRV at bands:").assertIsDisplayed()
|
||||
|
||||
runOnIdle { selected.select(null) }
|
||||
waitForIdle()
|
||||
onNodeWithText("Tag DL1ABC not QRV at bands:").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skedSentenceUsesTheCompactControlsWithoutTheOldOffsetsLabel() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(420.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Sked alert").assertIsDisplayed()
|
||||
onNodeWithText("Sked in").assertIsDisplayed()
|
||||
onNodeWithText("Remind pm in").assertIsDisplayed()
|
||||
onNodeWithText("Reminder offsets").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skedBandChoiceUsesEnabledBandsAndPassesTheSelectionToSkedCreation() = runComposeUiTest {
|
||||
var createdMinutes: Int? = null
|
||||
var createdBand: Band? = null
|
||||
var createdMode: String? = null
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, minutes, band, mode ->
|
||||
createdMinutes = minutes
|
||||
createdBand = band
|
||||
createdMode = mode
|
||||
true
|
||||
},
|
||||
).apply {
|
||||
select(member("DM5M"))
|
||||
skedBand = Band.B_144
|
||||
skedMode = "SSB"
|
||||
}
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.width(570.dp).height(520.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("144 MHz").performClick()
|
||||
onNodeWithText("1296 MHz").assertDoesNotExist()
|
||||
onNodeWithText("432 MHz").assertIsDisplayed().performClick()
|
||||
assertEquals(Band.B_432, selected.skedBand)
|
||||
|
||||
onNodeWithText("5").performClick()
|
||||
onNodeWithText("8").assertIsDisplayed().performClick()
|
||||
onNodeWithText("SSB").performClick()
|
||||
onNodeWithText("CW").assertIsDisplayed().performClick()
|
||||
|
||||
onNodeWithText("Create sked").performScrollTo().performClick()
|
||||
assertEquals(8, createdMinutes)
|
||||
assertEquals(Band.B_432, createdBand)
|
||||
assertEquals("CW", createdMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvToggleButtonsKeepAllSemanticsAndFollowTheSelectedStation() = runComposeUiTest {
|
||||
val first = member("DM5M").apply {
|
||||
isQrv144 = false
|
||||
isQrv432 = true
|
||||
isQrv1240 = false
|
||||
}
|
||||
val second = member("DL1ABC").apply {
|
||||
isQrv144 = true
|
||||
isQrv432 = false
|
||||
isQrv1240 = true
|
||||
}
|
||||
val propagated = mutableListOf<ChatMember>()
|
||||
val selected = SelectedStationState(
|
||||
propagateNotQrv = { propagated += it },
|
||||
createSked = { _, _, _, _ -> true },
|
||||
).apply { select(first) }
|
||||
|
||||
setPanel(
|
||||
selected,
|
||||
Modifier.width(570.dp).height(520.dp),
|
||||
EnumSet.of(Band.B_144, Band.B_432, Band.B_1296),
|
||||
)
|
||||
|
||||
onNodeWithText("144").assertIsOn()
|
||||
onNodeWithText("432").assertIsOff()
|
||||
onNodeWithText("23cm").assertIsOn()
|
||||
onNodeWithText("all").assertIsOff().performClick().assertIsOn()
|
||||
assertFalse(first.isQrv10G, "all must retain its existing all-supported-bands semantics")
|
||||
assertTrue(propagated.last() === first)
|
||||
|
||||
onNodeWithText("all").performClick().assertIsOff()
|
||||
assertTrue(first.isQrv144)
|
||||
assertTrue(first.isQrv10G)
|
||||
|
||||
runOnIdle { selected.select(second) }
|
||||
waitForIdle()
|
||||
onNodeWithText("144").assertIsOff()
|
||||
onNodeWithText("432").assertIsOn()
|
||||
onNodeWithText("23cm").assertIsOff()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notQrvAndSkedGroupsFollowTheActualPanelWidth() = runComposeUiTest {
|
||||
val selected = selectedState("DM5M")
|
||||
var panelWidth by mutableStateOf(620.dp)
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Panel(selected, Modifier.width(panelWidth).height(560.dp))
|
||||
}
|
||||
}
|
||||
|
||||
var notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
var sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(abs(notQrv.top - sked.top) <= 1f, "wide groups were not arranged horizontally")
|
||||
assertTrue(sked.left >= notQrv.right, "wide groups overlap")
|
||||
|
||||
runOnIdle { panelWidth = 360.dp }
|
||||
waitForIdle()
|
||||
|
||||
notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(sked.top >= notQrv.bottom, "narrow groups were not stacked vertically")
|
||||
assertTrue(notQrv.right <= panel.right && sked.right <= panel.right, "a group crossed the panel edge")
|
||||
|
||||
listOf("Create sked", "Sked fail", "Reset fail").forEach {
|
||||
onNodeWithText(it).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lookupActionsCanBeScrolledIntoViewInAShortPane() = runComposeUiTest {
|
||||
val selected = selectedState("DL1ABC")
|
||||
val messages = messageTable()
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messages,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = Modifier.height(220.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Lookup on qrz.com")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun selectedState(callSign: String) = selectedState(member(callSign))
|
||||
|
||||
private fun selectedState(member: ChatMember, detectedBands: String = "") = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> true },
|
||||
detectBands = { detectedBands },
|
||||
).apply { select(member) }
|
||||
|
||||
private fun member(callSign: String) = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
qra = "JO50AA"
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.setPanel(
|
||||
selected: SelectedStationState,
|
||||
modifier: Modifier,
|
||||
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||
) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Panel(selected, modifier, activeBands)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun Panel(
|
||||
selected: SelectedStationState,
|
||||
modifier: Modifier,
|
||||
activeBands: Set<Band> = EnumSet.of(Band.B_144),
|
||||
) {
|
||||
SelectedStationPanel(
|
||||
state = selected,
|
||||
messages = messageTable(),
|
||||
activeBands = activeBands,
|
||||
openInBrowser = Consumer {},
|
||||
onShowOnMap = {},
|
||||
onShowPathInAirScout = {},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private fun messageTable() = DataTableState(
|
||||
SelectedStationMessageColumns.all(),
|
||||
RowKeys.byReference<ChatMessage>(),
|
||||
"selected-station-panel-test",
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Test
|
||||
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.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* The panel below the station list, for the station currently selected.
|
||||
@@ -16,6 +19,9 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
*/
|
||||
class SelectedStationStateTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private class Session {
|
||||
val propagated = mutableListOf<String>()
|
||||
var skedsCreated = 0
|
||||
@@ -178,6 +184,15 @@ class SelectedStationStateTest {
|
||||
"the JavaFX radio buttons were one toggle group; two cannot be on")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public messages can be the configured initial radio mode`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml")).apply {
|
||||
isGuiOptions_defaultFilterPublicMsgs = true
|
||||
}
|
||||
|
||||
assertEquals(SelectedMessageFilter.PUBLIC, SelectedMessageFilter.fromPreferences(prefs))
|
||||
}
|
||||
|
||||
/**
|
||||
* No filter is chosen until the operator chooses one. JavaFX has the
|
||||
* setSelected(true) on "pm to me" commented out, so the group starts with nothing on,
|
||||
|
||||
@@ -219,6 +219,40 @@ class SplitterStateTest {
|
||||
assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimums keep fixed-height controls visible without replacing stored positions`() {
|
||||
val stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)
|
||||
val state = SplitterState(paneCount = 5, stored = stored) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 800f,
|
||||
minimumPaneSizesPx = floatArrayOf(24f, 26f, 104f, 26f, 24f),
|
||||
)
|
||||
|
||||
assertTrue(sizes[0] >= 24f)
|
||||
assertTrue(sizes[1] >= 26f)
|
||||
assertTrue(sizes[2] >= 104f)
|
||||
assertTrue(sizes[3] >= 26f)
|
||||
assertTrue(sizes[4] >= 24f)
|
||||
assertEquals(800f, sizes.sum(), 0.01f)
|
||||
stored.forEachIndexed { index, position ->
|
||||
assertEquals(position.toFloat(), state.positions[index], 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pane-specific minimums scale down together in an impossibly small splitter`() {
|
||||
val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.2, 0.8)) {}
|
||||
|
||||
val sizes = state.paneSizes(
|
||||
totalPx = 100f,
|
||||
minimumPaneSizesPx = floatArrayOf(50f, 100f, 50f),
|
||||
)
|
||||
|
||||
assertEquals(listOf(25f, 50f, 25f), sizes)
|
||||
assertEquals(100f, sizes.sum(), 0.01f)
|
||||
}
|
||||
|
||||
private fun assertNull(value: Any?, message: String) {
|
||||
assertFalse(value != null, message)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.EnumSet
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The QRB part of the station filter bar, drawn (#21).
|
||||
@@ -51,12 +52,47 @@ class StationFilterBarScreenTest {
|
||||
StationFilterBar(
|
||||
filters = bar.filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = { bar.changes++ },
|
||||
)
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest {
|
||||
val filters = StationFilterState()
|
||||
val station = kst4contest.model.ChatMember()
|
||||
var calculations = 0
|
||||
val reachability = ReachabilityState(
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
resolveAutoBand = { Band.B_144 },
|
||||
selectedStation = { station },
|
||||
calculate = { member, band ->
|
||||
assertEquals(station, member)
|
||||
assertEquals(Band.B_432, band)
|
||||
calculations++
|
||||
},
|
||||
)
|
||||
|
||||
showTab {
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144, Band.B_432),
|
||||
reachability = reachability,
|
||||
onChanged = { },
|
||||
)
|
||||
}
|
||||
|
||||
onNodeWithText("Reachability").assertIsDisplayed()
|
||||
onNodeWithText("Auto").performClick()
|
||||
awaitNode(inPopup("432")).performClick()
|
||||
assertEquals(0, calculations)
|
||||
|
||||
onNodeWithText("Calc selected").performClick()
|
||||
assertEquals(1, calculations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest {
|
||||
val bar = showBar()
|
||||
@@ -133,7 +169,12 @@ class StationFilterBarScreenTest {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(width)) {
|
||||
StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {})
|
||||
StationFilterBar(
|
||||
filters = filters,
|
||||
activeBands = EnumSet.of(Band.B_144),
|
||||
reachability = ReachabilityState(EnumSet.of(Band.B_144)),
|
||||
onChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +186,10 @@ class StationFilterBarScreenTest {
|
||||
onNodeWithText(maxLabel),
|
||||
onNodeWithText(minLabel),
|
||||
onNodeWithText("Show only QTF:"),
|
||||
onNodeWithText("Inactive stations"),
|
||||
onNodeWithText("Only new grids"),
|
||||
onNodeWithText("AS next 5m"),
|
||||
onNodeWithText("Calc selected"),
|
||||
fieldBeside(maxLabel),
|
||||
fieldBeside(minLabel),
|
||||
)
|
||||
@@ -154,4 +199,32 @@ class StationFilterBarScreenTest {
|
||||
assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterControlsShareOneHeightAndBearingsShareOneCompactWidth() = runComposeUiTest {
|
||||
showBar()
|
||||
|
||||
val fieldHeight = fieldBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||
val checkboxHeight = checkBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height
|
||||
val findFieldHeight = onNodeWithText("Find...").fetchSemanticsNode().boundsInRoot.height
|
||||
val controlHeights = listOf("Reset filters", "N", "Auto", "Calc selected")
|
||||
.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } +
|
||||
listOf(fieldHeight, checkboxHeight, findFieldHeight)
|
||||
assertTrue(
|
||||
controlHeights.max() - controlHeights.min() <= 1f,
|
||||
"filter control heights differ: $controlHeights",
|
||||
)
|
||||
|
||||
val bearingWidths = Bearing.entries
|
||||
.map { onNodeWithText(it.name).fetchSemanticsNode().boundsInRoot.width }
|
||||
assertTrue(
|
||||
bearingWidths.max() - bearingWidths.min() <= 1f,
|
||||
"bearing widths differ: $bearingWidths",
|
||||
)
|
||||
val expectedWidth = with(density) { Density.DIRECTION_BUTTON_WIDTH.toPx() }
|
||||
assertTrue(
|
||||
bearingWidths.all { abs(it - expectedWidth) <= 1f },
|
||||
"bearing buttons are not $expectedWidth px wide: $bearingWidths",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,14 @@ import java.util.EnumSet
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StationListScreenTest {
|
||||
|
||||
@Test
|
||||
fun anEmptyTableKeepsItsStructureAndExplainsTheEmptyBody() = runComposeUiTest {
|
||||
show(stationList())
|
||||
|
||||
onNodeWithText("No entries").assertExists()
|
||||
onNodeWithText("QRB").assertExists()
|
||||
}
|
||||
|
||||
private fun stationList(): DataTableState<ChatMember> = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
@@ -140,6 +148,7 @@ class StationListScreenTest {
|
||||
onNodeWithText("QRB").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals("qrb", state.sortedColumnId, "clicking the header did not select its sort")
|
||||
onNodeWithText("DL2BBB").assertDoesNotExist()
|
||||
assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA"))
|
||||
}
|
||||
@@ -162,6 +171,7 @@ class StationListScreenTest {
|
||||
onNodeWithText("AP [minutes / pot%]").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals("airscout", state.sortedColumnId, "clicking the header did not select its sort")
|
||||
assertEquals(
|
||||
listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"),
|
||||
drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"),
|
||||
@@ -196,6 +206,34 @@ class StationListScreenTest {
|
||||
assertEquals(null, state.selectedRow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest {
|
||||
val selection = StationSelectionState()
|
||||
val state = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
priorityScoreOf = { 0.0 },
|
||||
formatBand = { _, _, _ -> "" },
|
||||
activeBands = EnumSet.noneOf(Band::class.java),
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
initialSortColumnId = "callsign",
|
||||
selectionModel = selection,
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val stations = (1..60).map { number -> station("DL${number.toString().padStart(2, '0')}TEST") }
|
||||
state.replaceRows(stations.reversed())
|
||||
|
||||
show(state)
|
||||
onNodeWithText("DL60TEST").assertDoesNotExist()
|
||||
|
||||
runOnIdle { selection.select(stations.last(), StationSelectionSource.PUBLIC_MESSAGE) }
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("DL60TEST").assertExists()
|
||||
assertSame(stations.last(), state.selectedRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* A station's fields change in place and the application calls forceRedraw: the list
|
||||
* filters again, so a station the filter now excludes leaves the screen (#21).
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class StationSelectionStateTest {
|
||||
|
||||
@Test
|
||||
fun `identity includes the full callsign and category`() {
|
||||
val main = station("DN9APW-2", 2)
|
||||
val second = station("DN9APW-2", 3)
|
||||
|
||||
assertNotEquals(StationIdentity.of(main), StationIdentity.of(second))
|
||||
assertEquals(2, StationIdentity.of(main)?.categoryNumber)
|
||||
assertEquals("DN9APW-2", StationIdentity.of(main)?.callSign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting the same station again creates another scroll request`() {
|
||||
val state = StationSelectionState()
|
||||
val station = station("DL1ABC", 2)
|
||||
|
||||
state.select(station, StationSelectionSource.PUBLIC_MESSAGE)
|
||||
val first = state.scrollRequestRevision
|
||||
state.select(station, StationSelectionSource.MAP)
|
||||
|
||||
assertSame(station, state.selected)
|
||||
assertTrue(state.scrollRequestRevision > first)
|
||||
assertEquals(StationSelectionSource.MAP, state.lastSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() {
|
||||
val state = StationSelectionState()
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
|
||||
val revision = state.scrollRequestRevision
|
||||
val refreshed = station("DL1ABC", 2)
|
||||
|
||||
state.reconcile(refreshed)
|
||||
|
||||
assertSame(refreshed, state.selected)
|
||||
assertEquals(revision, state.scrollRequestRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming directed message targets its sender`() {
|
||||
val message = directedMessage(sender = "DL1ABC", receiver = "OWN", category = 2)
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL1ABC", 2),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `own directed message targets its receiver`() {
|
||||
val message = directedMessage(sender = "own", receiver = "DL2XYZ", category = 3)
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL2XYZ", 3),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `historical own message can obtain its receiver from the payload`() {
|
||||
val message = directedMessage(sender = "OWN", receiver = null, category = 2).apply {
|
||||
messageText = "(DL3OLD) test"
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
StationIdentity("DL3OLD", 2),
|
||||
DirectedMessageStationTarget.forMessage(message, "OWN"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun station(callSign: String, category: Int): ChatMember = ChatMember().apply {
|
||||
this.callSign = callSign
|
||||
chatCategory = ChatCategory(category)
|
||||
}
|
||||
|
||||
private fun directedMessage(sender: String, receiver: String?, category: Int) = ChatMessage().apply {
|
||||
this.sender = station(sender, category)
|
||||
this.receiver = receiver?.let { station(it, category) }
|
||||
chatCategory = ChatCategory(category)
|
||||
messageText = "test"
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,9 @@ class TimelineStateTest {
|
||||
@Test
|
||||
fun `the lanes stack downwards in even steps`() {
|
||||
assertEquals(2f, TimelineGeometry.laneY(0))
|
||||
assertEquals(22f, TimelineGeometry.laneY(1))
|
||||
assertEquals(18f, TimelineGeometry.laneY(1))
|
||||
assertEquals(34f, TimelineGeometry.laneY(2))
|
||||
assertEquals(50f, TimelineGeometry.laneY(3))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,14 +153,45 @@ class TimelineStateTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** A third lane would land on the axis, so it shares the last one instead. */
|
||||
/** Unknown producer values stay visible in the nearest defined lane. */
|
||||
@Test
|
||||
fun `a lane beyond the available ones folds onto the last`() {
|
||||
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2))
|
||||
assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7))
|
||||
assertEquals(TimelineGeometry.laneY(3), TimelineGeometry.laneY(7))
|
||||
assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lane definitions expose the producer semantics in display order`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
TimelineLane.IN_BEAM_1,
|
||||
TimelineLane.IN_BEAM_2,
|
||||
TimelineLane.PRIORITY_1,
|
||||
TimelineLane.PRIORITY_2,
|
||||
),
|
||||
TimelineLane.entries,
|
||||
)
|
||||
assertEquals(TimelineLane.PRIORITY_1, TimelineLane.forIndex(2))
|
||||
assertEquals(TimelineLane.PRIORITY_2, TimelineLane.forIndex(99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wide timeline gets five minute ticks over the full preview`() {
|
||||
val ticks = TimelineGeometry.minuteTicks(axisWidth = 600f, minimumLabelSpacing = 30f)
|
||||
|
||||
assertEquals(listOf(0, 5, 10, 15, 20, 25, 30), ticks.map { it.minutes })
|
||||
assertEquals(0f, ticks.first().x, 0.01f)
|
||||
assertEquals(600f, ticks.last().x, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tick interval grows when labels would overlap`() {
|
||||
val ticks = TimelineGeometry.minuteTicks(axisWidth = 100f, minimumLabelSpacing = 30f)
|
||||
|
||||
assertEquals(listOf(0, 10, 20, 30), ticks.map { it.minutes })
|
||||
assertTrue(ticks.zipWithNext().all { (left, right) -> right.x - left.x >= 30f })
|
||||
}
|
||||
|
||||
// ---- the labels --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TimelineViewScreenTest {
|
||||
|
||||
@Test
|
||||
fun `timeline shows all producer lanes and relative minute ticks`() = runComposeUiTest {
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
TimelineView(
|
||||
state = TimelineState(),
|
||||
onCandidateClicked = {},
|
||||
modifier = Modifier.width(700.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf("In beam 1", "In beam 2", "Priority 1", "Priority 2", "Skeds").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
listOf("0 min", "+5 min", "+10 min", "+15 min", "+20 min", "+25 min", "+30 min").forEach {
|
||||
onNodeWithText(it).assertExists()
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
-11
@@ -1,4 +1,4 @@
|
||||
package kst4contest.view.compose.map
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
@@ -32,6 +32,8 @@ import kst4contest.view.compose.SelectedStationState
|
||||
import kst4contest.view.compose.SplitterState
|
||||
import kst4contest.view.compose.StationColumns
|
||||
import kst4contest.view.compose.StationFilterState
|
||||
import kst4contest.view.compose.StationSelectionSource
|
||||
import kst4contest.view.compose.StationSelectionState
|
||||
import kst4contest.view.compose.ThreadStatusButtons
|
||||
import kst4contest.view.compose.TimelineState
|
||||
import kst4contest.view.compose.TopPriorityState
|
||||
@@ -41,6 +43,7 @@ import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.TileFetcher
|
||||
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.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -136,6 +139,7 @@ class StationMapWindowScreenTest {
|
||||
}
|
||||
|
||||
private fun windowState(prefs: ChatPreferences): MainWindowState {
|
||||
val stationSelection = StationSelectionState()
|
||||
val stations = DataTableState(
|
||||
StationColumns.all(
|
||||
tropoOf = { "" },
|
||||
@@ -143,6 +147,8 @@ class StationMapWindowScreenTest {
|
||||
formatBand = { _, _, _ -> "" },
|
||||
),
|
||||
RowKeys.byValue(StationColumns::rowKey),
|
||||
selectionModel = stationSelection,
|
||||
keepSelectionVisible = true,
|
||||
)
|
||||
val menu = MainMenuState()
|
||||
return MainWindowState(
|
||||
@@ -158,6 +164,7 @@ class StationMapWindowScreenTest {
|
||||
selectedStation = SelectedStationState(
|
||||
propagateNotQrv = {},
|
||||
createSked = { _, _, _, _ -> false },
|
||||
stationSelection = stationSelection,
|
||||
),
|
||||
topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}),
|
||||
timeline = TimelineState(),
|
||||
@@ -185,6 +192,7 @@ class StationMapWindowScreenTest {
|
||||
mapState: MapState,
|
||||
pathAnalysis: MapPathAnalysis? = null,
|
||||
onPathAnalysisAsked: () -> Unit = {},
|
||||
onAlwaysOnTopChanged: (Boolean) -> Unit = {},
|
||||
) {
|
||||
state.stations.replaceRows(members)
|
||||
setContent {
|
||||
@@ -195,6 +203,7 @@ class StationMapWindowScreenTest {
|
||||
chatMembers = { members },
|
||||
mapState = mapState,
|
||||
pathAnalysis = { onPathAnalysisAsked(); pathAnalysis },
|
||||
onAlwaysOnTopChanged = onAlwaysOnTopChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +226,7 @@ class StationMapWindowScreenTest {
|
||||
val dl1abc = station("DL1ABC", 2)
|
||||
state.selectedStation.select(dl1abc)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 15f)
|
||||
|
||||
show(state, listOf(dl1abc), mapState)
|
||||
@@ -266,7 +275,7 @@ class StationMapWindowScreenTest {
|
||||
state.selectedStation.select(inSecond)
|
||||
state.chatInput.value = TextFieldValue("half typed")
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 12f)
|
||||
|
||||
show(state, listOf(inMain, inSecond), mapState)
|
||||
@@ -281,19 +290,20 @@ class StationMapWindowScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest {
|
||||
fun aMarkerClickWithNothingSelectedTakesTheMainCategory() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val inMain = station("DL1ABC", 2)
|
||||
val inSecond = station("DL1ABC", 3)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
val scrollRevision = state.stations.scrollRequestRevision
|
||||
|
||||
show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f))
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertSame(inMain, selectorCalls.single())
|
||||
assertSame(inMain, state.stations.selectedRow)
|
||||
assertEquals(StationSelectionSource.MAP, state.stationSelection.lastSource)
|
||||
assertTrue(state.stations.scrollRequestRevision > scrollRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,13 +387,55 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible)
|
||||
|
||||
// The grouping checkbox sits beside its label; the label itself is not clickable.
|
||||
onNodeWithText("Group nearby stations").assertExists()
|
||||
onNode(isToggleable()).assertIsOff().performClick()
|
||||
onNodeWithText("Grouping").assertExists()
|
||||
onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOff().performClick()
|
||||
waitForIdle()
|
||||
onNode(isToggleable()).assertIsOn()
|
||||
onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOn()
|
||||
assertEquals(true, prefs.isGUIstationMapClusteringEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alwaysOnTopWritesThePreferenceAndAppliesBothStatesImmediately() = runComposeUiTest {
|
||||
val prefs = prefs()
|
||||
val state = windowState(prefs)
|
||||
val applied = mutableListOf<Boolean>()
|
||||
|
||||
show(state, emptyList(), MapState(), onAlwaysOnTopChanged = applied::add)
|
||||
|
||||
onNodeWithText("Always on top").assertExists()
|
||||
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOff().performClick()
|
||||
waitForIdle()
|
||||
assertEquals(true, prefs.isGUIstationMapAlwaysOnTop)
|
||||
assertEquals(listOf(true), applied)
|
||||
|
||||
onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOn().performClick()
|
||||
waitForIdle()
|
||||
assertEquals(false, prefs.isGUIstationMapAlwaysOnTop)
|
||||
assertEquals(listOf(true, false), applied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
var resets = 0
|
||||
state.stationFiltersChanged = { resets++ }
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 14f)
|
||||
val beforeLon = mapState.centerLon
|
||||
val beforeLat = mapState.centerLat
|
||||
|
||||
show(state, emptyList(), mapState)
|
||||
onNodeWithText("Reset filters").performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(false, state.stationFilter.minQrbEnabled)
|
||||
assertEquals(1, resets)
|
||||
assertEquals(beforeLon, mapState.centerLon)
|
||||
assertEquals(beforeLat, mapState.centerLat)
|
||||
assertEquals(14f, mapState.zoom)
|
||||
}
|
||||
|
||||
/**
|
||||
* The map shows the stations the list shows (#21). The Compose map drew every station
|
||||
* whatever the filter bar said, although its status line reported a filtered view; the
|
||||
@@ -396,7 +448,7 @@ class StationMapWindowScreenTest {
|
||||
val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 }
|
||||
val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 }
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { selectorCalls += it }
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
|
||||
show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f))
|
||||
onNodeWithText("Showing 2 visible stations").assertExists()
|
||||
|
||||
Reference in new issue
Block a user