feat(map): grid QSO dialog Match button prefills the match page

- Grid-QSO dialog gains a Match action (radio-tower icon + label) in its
  header; tapping it navigates to the match page pre-filled with the tapped
  grid as the opposite station and a 24h time range, auto-starts the query,
  and scrolls the page straight to the time-range card.
- Every grid is now tappable (previously only worked grids opened the
  dialog); unworked grids show an empty QSO list instead of a hint.
This commit is contained in:
atsunatsu committed 2026-09-22 10:46:56 +08:00
1 parent 6beaeb3e1a
commit 3e0874e6a8
6 files changed
+120 -37

No files matched your search

@@ -244,7 +244,16 @@ fun MainScreen(
) )
} }
entry<Screen.Map> { entry<Screen.Map> {
MapDestination(mapFilterViewModel = mapFilterViewModel) MapDestination(
mapFilterViewModel = mapFilterViewModel,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill the
// match page for that grid and switch to its tab.
mutualViewModel.prefillMatchFromGrid(grid)
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack.add(Screen.Mutual)
}
)
} }
entry<Screen.Mutual> { entry<Screen.Mutual> {
MutualScreen( MutualScreen(
@@ -130,6 +130,7 @@
<string name="grid_qso_count_many">%d QSOs</string> <string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">首通</string> <string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string> <string name="grid_qso_no_sat">卫星未知</string>
<string name="grid_qso_match">匹配</string>
<string name="map_visible">日照中</string> <string name="map_visible">日照中</string>
<!-- Settings screen --> <!-- Settings screen -->
@@ -150,6 +150,7 @@
<string name="grid_qso_count_many">%d QSOs</string> <string name="grid_qso_count_many">%d QSOs</string>
<string name="grid_qso_first">First</string> <string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string> <string name="grid_qso_no_sat">satellite unknown</string>
<string name="grid_qso_match">Match</string>
<string name="map_visible">Visible</string> <string name="map_visible">Visible</string>
<!-- Settings screen --> <!-- Settings screen -->
@@ -31,10 +31,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -46,10 +48,12 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -63,6 +67,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color as ComposeColor import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -145,7 +150,10 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@Composable @Composable
fun MapDestination( fun MapDestination(
mapFilterViewModel: MapFilterViewModel mapFilterViewModel: MapFilterViewModel,
// Invoked with the tapped grid when the QSO dialog's "Match" button is
// pressed: navigate to the match page pre-filled for that grid.
onMatchGrid: (String) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -182,7 +190,7 @@ fun MapDestination(
viewModel.onAction(MapAction.SetVisible(false)) viewModel.onAction(MapAction.SetVisible(false))
} }
} }
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel) MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid)
} }
@Composable @Composable
@@ -190,7 +198,8 @@ private fun MapScreen(
uiState: MapState, uiState: MapState,
onAction: (MapAction) -> Unit, onAction: (MapAction) -> Unit,
mapView: MapView, mapView: MapView,
mapFilterViewModel: MapFilterViewModel mapFilterViewModel: MapFilterViewModel,
onMatchGrid: (String) -> Unit
) { ) {
val rotateMod = Modifier.rotate(180f) val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00" val timeString = uiState.mapData?.aosTime ?: "00:00:00"
@@ -261,7 +270,8 @@ private fun MapScreen(
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too. // appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
if (grid !in workedGrids) return false // Every grid is tappable: worked grids show their confirmed QSOs,
// unworked grids open the same dialog with an empty list.
selectedGrid = grid selectedGrid = grid
return true return true
} }
@@ -413,7 +423,8 @@ private fun MapScreen(
grid = grid, grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs }, qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
isUtc = uiState.isUtc, isUtc = uiState.isUtc,
onDismiss = { selectedGrid = null } onDismiss = { selectedGrid = null },
onMatch = { onMatchGrid(grid) }
) )
} }
} }
@@ -425,7 +436,8 @@ private fun WorkedGridQsoDialog(
grid: String, grid: String,
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>, qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
isUtc: Boolean, isUtc: Boolean,
onDismiss: () -> Unit onDismiss: () -> Unit,
onMatch: () -> Unit
) { ) {
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Centered card, ~85% width, internal scroll for long lists. // Centered card, ~85% width, internal scroll for long lists.
@@ -434,29 +446,43 @@ private fun WorkedGridQsoDialog(
modifier = Modifier.fillMaxWidth(0.88f) modifier = Modifier.fillMaxWidth(0.88f)
) { ) {
Column { Column {
// Header: grid + counts // Header: grid + counts on the left, "Match" action on the right
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { // (jumps to the match page pre-filled with this grid).
Text( Row(
text = grid, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
color = MaterialTheme.colorScheme.primary, ) {
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace Column(Modifier.weight(1f)) {
) Text(
val distinctCalls = qsos.map { it.call }.distinct().size text = grid,
Text( style = MaterialTheme.typography.titleLarge,
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size), color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
color = MaterialTheme.colorScheme.onSurfaceVariant )
) val distinctCalls = qsos.map { it.call }.distinct().size
Text(
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
TextButton(
onClick = {
onDismiss()
onMatch()
}
) {
Icon(
painter = painterResource(R.drawable.ic_radio_tower),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.grid_qso_match))
}
} }
androidx.compose.material3.HorizontalDivider() androidx.compose.material3.HorizontalDivider()
if (qsos.isEmpty()) { if (qsos.isNotEmpty()) {
Text(
text = stringResource(R.string.grid_qso_no_sat),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp)
)
} else {
// Group by callsign preserving first-contact order (list is // Group by callsign preserving first-contact order (list is
// already sorted oldest-first); expandable rows. // already sorted oldest-first); expandable rows.
val grouped = remember(qsos) { val grouped = remember(qsos) {
@@ -246,7 +246,7 @@ private fun MutualContent(
} }
// Input form // Input form
item { item(key = "stationInputs") {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
@@ -323,16 +323,21 @@ private fun MutualContent(
) )
} }
} }
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
} }
} }
// Time range + query card. Its own LazyColumn item so a prefill from
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
// to the time-range selector.
item(key = "matchSearch") {
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
// Results // Results
itemsIndexed(state.mutualPasses) { index, pass -> itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData( val mutualData = MutualPassData(
@@ -364,6 +369,17 @@ private fun MutualContent(
) )
} }
} }
// Prefill from the map's grid-QSO dialog "Match" button: jump straight to
// the time-range card. Index accounts for the optional error card at the
// top (error = 0, station inputs = 1, time range = 2; else 1).
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex) {
if (state.scrollToTimeRange) {
listState.scrollToItem(matchSearchIndex)
viewModel.consumeScrollToTimeRange()
}
}
} }
@Composable @Composable
@@ -55,7 +55,11 @@ data class MutualUiState(
val hasSearched: Boolean = false, val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1, val selectedPassIndex: Int = -1,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null,
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
// the page scrolls to the time-range card, then consumeScrollToTimeRange()
// clears it.
val scrollToTimeRange: Boolean = false
) )
class MutualViewModel( class MutualViewModel(
@@ -183,6 +187,32 @@ class MutualViewModel(
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) } fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) } fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
/**
* Pre-fill the match page for a target grid picked from the map's grid-QSO
* dialog ("Match" button): set the opposite-station grid (with its
* coordinates), reset the time range to 24h, flag the page to scroll
* straight to the time-range card, and start the query immediately so
* results are ready when the page opens.
*/
fun prefillMatchFromGrid(grid: String) {
val g = grid.trim().uppercase()
val pos = qthToPosition(g)
_uiState.update {
it.copy(
stationBGrid = g,
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
hoursAhead = 24,
scrollToTimeRange = true
)
}
queryMutualPasses()
}
fun consumeScrollToTimeRange() {
_uiState.update { it.copy(scrollToTimeRange = false) }
}
fun queryMutualPasses() { fun queryMutualPasses() {
val state = _uiState.value val state = _uiState.value