From 9e748f553429e43397387f6efc5eca90b659ac64 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 14 Sep 2026 20:45:25 +0800 Subject: [PATCH] feat(map): worked-grid QSO counts and operated-grid selector - QSO dialog: each callsign row shows 'n QSO'/'n QSOs' (plurals) centered between callsign and date, styled like the date. - Grid mode + VUCC filter: top-left selector picks the operated grid the VUCC count is computed from (defaults to the most-worked grid), with a collapsible list of all operated grids; arrow style matches the QSO dialog rows. --- .../src/main/res/values-zh/strings.xml | 4 + .../src/main/res/values/strings.xml | 4 + .../look4sat/feature/map/MapScreen.kt | 135 ++++++++++++++++-- 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index becca72c..a9293462 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -122,6 +122,10 @@ 能见度:%s %d 个呼号 · %d 次通联 + + %d QSO + %d QSOs + 首通 卫星未知 日照中 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 192cc876..52288200 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -147,6 +147,10 @@ Visibility: %s %d callsigns · %d QSOs + + %d QSO + %d QSOs + First satellite unknown 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 96af2a1b..af07a5fe 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 @@ -62,6 +62,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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -181,8 +182,30 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: var selectedAward by remember { mutableStateOf(AwardType.VUCC) } // Six-award progress derived from the confirmed QSO store; recomputed when // the store changes (LoTW/Wavelog sync). - val awardProgress: List = remember(uiState.workedGridQsos) { - AwardCalculator.calculate(uiState.workedGridQsos) + // Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it. + val vuccByMyGrid: Map> = remember(uiState.workedGridQsos) { + val m = mutableMapOf>() + for ((grid, qsos) in uiState.workedGridQsos) { + for (q in qsos) { + val mg = q.myGrid ?: continue + m.getOrPut(mg) { mutableSetOf() }.add(grid) + } + } + m + } + // Selected operated grid for VUCC counting; defaults to the grid with the + // most worked grids. Null when the store carries no per-QSO myGrid data + // (requires a LoTW resync) — then VUCC falls back to the global count. + var selectedMyGrid by remember(vuccByMyGrid) { + mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key) + } + val awardProgress: List = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) { + val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() } + val base = AwardCalculator.calculate(uiState.workedGridQsos) + if (vuccGrids == null) base + else base.map { p -> + if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p + } } // Attach the tap listener whenever grid mode / worked grids change. val workedGrids = uiState.workedGrids @@ -298,6 +321,18 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: if (isVertical) MapDataCard(mapData) else MapDataCards(mapData) } } + // Top-left: operated-grid selector for VUCC counting (grid mode only). + val myGrid = selectedMyGrid + if (uiState.isGridMode && selectedAward == AwardType.VUCC && myGrid != null) { + VuccGridSelector( + options = vuccByMyGrid, + selected = myGrid, + onSelect = { selectedMyGrid = it }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) + } GridModeToggle( isGridMode = uiState.isGridMode, onToggle = { onAction(MapAction.ToggleGridMode(it)) }, @@ -400,18 +435,33 @@ private fun WorkedGridCallRow( style = MaterialTheme.typography.titleMedium, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, color = MaterialTheme.colorScheme.tertiary, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) Text( - text = formatDate(first.epochMs, isUtc), + text = pluralStringResource(R.plurals.grid_qso_count, callQsos.size, callQsos.size), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = if (expanded) " ▴" else " ▾", - fontSize = 18.sp, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier.weight(1f) ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + modifier = Modifier.weight(1f) + ) { + Text( + text = formatDate(first.epochMs, isUtc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (expanded) " ▴" else " ▾", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.primary + ) + } } // First-QSO summary line, always visible. Text( @@ -579,6 +629,73 @@ private fun AwardChipsRow( } } +/** + * Compact pill toggle for switching between satellite view and grid mode, + * floated over the map's top-right corner. The label reflects the active + * mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent + * background keeps the map readable underneath. + */ +@Composable +private fun VuccGridSelector( + options: Map>, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + // Most-worked grid first; selection is made on tap. + val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } } + Surface( + color = ComposeColor.Black.copy(alpha = 0.45f), + shape = RoundedCornerShape(8.dp), + modifier = modifier + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { expanded = !expanded } + .padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) + ) { + Text( + text = "$selected (${options[selected]?.size ?: 0})", + color = ComposeColor.White, + fontSize = 12.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + Text( + text = if (expanded) " ▴" else " ▾", + fontSize = 18.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp) + ) + } + if (expanded) { + androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f)) + sorted.forEach { (grid, grids) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelect(grid) + expanded = false + } + .padding(horizontal = 10.dp, vertical = 3.dp) + ) { + Text( + text = "$grid (${grids.size})", + color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White, + fontSize = 12.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + } + } + } +} + /** * Compact pill toggle for switching between satellite view and grid mode, * floated over the map's top-right corner. The label reflects the active