diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 1970d06f..617bd39f 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -244,7 +244,16 @@ fun MainScreen( ) } entry { - 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 { MutualScreen( diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 5073c8d2..061fc662 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -130,6 +130,7 @@ %d QSOs 首通 卫星未知 + 匹配 日照中 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 7fbfe2ac..ec9505e2 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -150,6 +150,7 @@ %d QSOs First satellite unknown + Match Visible diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index be680bcb..0037805f 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -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, 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) { diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index 79e5ae45..7257fe44 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -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 diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt index 66abd1ec..8f261746 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualViewModel.kt @@ -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