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

@@ -31,10 +31,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -46,10 +48,12 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -145,7 +150,10 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@Composable
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 container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -182,7 +190,7 @@ fun MapDestination(
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel)
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid)
}
@Composable
@@ -190,7 +198,8 @@ private fun MapScreen(
uiState: MapState,
onAction: (MapAction) -> Unit,
mapView: MapView,
mapFilterViewModel: MapFilterViewModel
mapFilterViewModel: MapFilterViewModel,
onMatchGrid: (String) -> Unit
) {
val rotateMod = Modifier.rotate(180f)
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.
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) 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
return true
}
@@ -413,7 +423,8 @@ private fun MapScreen(
grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
isUtc = uiState.isUtc,
onDismiss = { selectedGrid = null }
onDismiss = { selectedGrid = null },
onMatch = { onMatchGrid(grid) }
)
}
}
@@ -425,7 +436,8 @@ private fun WorkedGridQsoDialog(
grid: String,
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
isUtc: Boolean,
onDismiss: () -> Unit
onDismiss: () -> Unit,
onMatch: () -> Unit
) {
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Centered card, ~85% width, internal scroll for long lists.
@@ -434,29 +446,43 @@ private fun WorkedGridQsoDialog(
modifier = Modifier.fillMaxWidth(0.88f)
) {
Column {
// Header: grid + counts
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
Text(
text = grid,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
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
)
// Header: grid + counts on the left, "Match" action on the right
// (jumps to the match page pre-filled with this grid).
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Column(Modifier.weight(1f)) {
Text(
text = grid,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
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()
if (qsos.isEmpty()) {
Text(
text = stringResource(R.string.grid_qso_no_sat),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp)
)
} else {
if (qsos.isNotEmpty()) {
// Group by callsign preserving first-contact order (list is
// already sorted oldest-first); expandable rows.
val grouped = remember(qsos) {
@@ -246,7 +246,7 @@ private fun MutualContent(
}
// Input form
item {
item(key = "stationInputs") {
Column(
modifier = Modifier.fillMaxWidth(),
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
itemsIndexed(state.mutualPasses) { index, pass ->
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
@@ -55,7 +55,11 @@ data class MutualUiState(
val hasSearched: Boolean = false,
val selectedPassIndex: Int = -1,
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(
@@ -183,6 +187,32 @@ class MutualViewModel(
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
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() {
val state = _uiState.value