mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
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:
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
|
||||||
|
|||||||
+31
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user