diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index 80b025bc..3b69c512 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -252,17 +252,40 @@ class LoTWRepository : ILoTWRepository { var country: String? = null var cqz: Int? = null var state: String? = null - var myGrid: String? = null + // Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the + // full grid set of the station location; the remaining MY_* fields + // together identify the station location (one callsign can own + // several locations, and one location can span several grids). + var myGrids = mutableSetOf() + var myCallsign: String? = null + var myDxcc: String? = null + var myState: String? = null + var myCq: String? = null + var myItu: String? = null + var myIota: String? = null + var myCountry: String? = null val gridsInRecord = mutableListOf() fun emitRecord() { if (propMode != "SAT" || gridsInRecord.isEmpty()) return val epochMs = adifTimestampToEpoch(qsoDate, timeOn) + val stationKey = buildString { + append(myCallsign ?: "") + append('|').append(myDxcc ?: "") + append('|').append(myState ?: "") + append('|').append(myCq ?: "") + append('|').append(myItu ?: "") + append('|').append(myIota ?: "") + append('|').append(myCountry ?: "") + }.ifBlank { null } val qso = com.rtbishop.look4sat.core.domain.model.GridQso( call = call, epochMs = epochMs, satName = satName, mode = mode, bandUp = bandUp, bandDown = bandDown, dxcc = dxcc, country = country, cqz = cqz, state = state, - myGrid = myGrid + myGrid = myGrids.firstOrNull(), + myGrids = myGrids, + myCallsign = myCallsign, + stationKey = stationKey ) for (grid in gridsInRecord) { result.getOrPut(grid) { mutableListOf() }.add(qso) @@ -273,7 +296,9 @@ class LoTWRepository : ILoTWRepository { propMode = null; call = ""; qsoDate = ""; timeOn = "" satName = ""; mode = ""; bandUp = ""; bandDown = "" dxcc = null; country = null; cqz = null; state = null - myGrid = null + myGrids = mutableSetOf() + myCallsign = null; myDxcc = null; myState = null + myCq = null; myItu = null; myIota = null; myCountry = null gridsInRecord.clear() } @@ -311,10 +336,40 @@ class LoTWRepository : ILoTWRepository { line.startsWith(" { // Own-station grid (must not be confused with GRIDSQUARE — // the opposite station's grid). Recorded per QSO so awards - // can be counted per operated grid. + // can be counted per operated grid. A station location may + // also carry MY_VUCC_GRIDS (below) for multi-grid roaming. val value = adifValue(line) - if (value.length >= 4) myGrid = value.take(4).uppercase() + if (value.length >= 4) myGrids.add(value.take(4).uppercase()) } + line.startsWith(" { + // Own-station VUCC grids: a comma-separated list LoTW emits + // when one station location spans several grid squares + // (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without + // this field the extra grids of a multi-grid 台址 silently + // vanished from both the blue stripes and the per-台址 VUCC + // count. Same split/truncate as VUCC_GRIDS below. + adifValue(line).split(',').forEach { grid -> + val field = grid.trim().uppercase() + if (field.length >= 4) myGrids.add(field.take(4)) + } + } + // Remaining MY_* fields identify the station location (台址): + // grouped together they form the stationKey used by the map's + // operated-grid selector. One callsign can own several 台址. + line.startsWith(" + myCallsign = adifValue(line).trim().uppercase().ifBlank { null } + line.startsWith(" + myDxcc = adifValue(line).trim().ifBlank { null } + line.startsWith(" + myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) } + line.startsWith(" + myCq = adifValue(line).trim().ifBlank { null } + line.startsWith(" + myItu = adifValue(line).trim().ifBlank { null } + line.startsWith(" + myIota = adifValue(line).trim().uppercase().ifBlank { null } + line.startsWith(" + myCountry = adifValue(line).trim().ifBlank { null } line.startsWith(" { // VUCC_GRIDS holds a comma-separated list of grids // ("EN52en,EN53fa"), up to four for contacts spanning @@ -420,23 +475,30 @@ class LoTWRepository : ILoTWRepository { // would silently drop every own grid on real reports; buffer the record // and decide at instead. var propMode: String? = null - var myGrid: String? = null + var myGrids = mutableSetOf() for (raw in body.lineSequence()) { val line = raw.trim() when { line.equals("", ignoreCase = true) -> { - if (propMode == "SAT" && myGrid != null) grids.add(myGrid) + if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids) propMode = null - myGrid = null + myGrids = mutableSetOf() } line.startsWith(" { propMode = adifValue(line).uppercase() } line.startsWith(" { - // MY_GRIDSQUARE must not be mistaken for GRIDSQUARE (the - // opposite station's grid) — only own-station grids count. val value = adifValue(line) - if (value.length >= 4) myGrid = value.take(4) + if (value.length >= 4) myGrids.add(value.take(4).uppercase()) + } + line.startsWith(" { + // Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the + // station location covered; without this the extra grids + // never reached the blue stripes. + adifValue(line).split(',').forEach { grid -> + val field = grid.trim().uppercase() + if (field.length >= 4) myGrids.add(field.take(4)) + } } } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 3c8de86b..ff7bb1c7 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -210,7 +210,20 @@ class SettingsRepo( cqz = o.optInt("cq", 0).takeIf { it > 0 }, state = o.optString("st").ifBlank { null }, // Own-grid field: absent in pre-myGrid data -> null. - myGrid = o.optString("mg").ifBlank { null } + myGrid = o.optString("mg").ifBlank { null }, + // Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is + // the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS); + // pre-multi-grid data has only "mg" and falls back to a + // single-element set so every consumer sees myGrids. + myGrids = o.optJSONArray("mgs")?.let { arr -> + (0 until arr.length()).mapNotNull { i -> + arr.optString(i).takeIf { it.isNotBlank() } + }.toSet() + }?.takeIf { it.isNotEmpty() } + ?: o.optString("mg").ifBlank { null }?.let { setOf(it) } + ?: emptySet(), + myCallsign = o.optString("mc").ifBlank { null }, + stationKey = o.optString("sk").ifBlank { null } ) } if (list.isNotEmpty()) result[grid] = list @@ -239,6 +252,9 @@ class SettingsRepo( .put("cq", q.cqz ?: 0) .put("st", q.state ?: "") .put("mg", q.myGrid ?: "") + .put("mgs", org.json.JSONArray(q.myGrids.sorted())) + .put("mc", q.myCallsign ?: "") + .put("sk", q.stationKey ?: "") ) } root.put(grid, array) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt index b1c6f54f..44ee18d5 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt @@ -169,6 +169,53 @@ class LoTWRepositoryTest { assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } }) } + @Test + fun parseQsosCapturesMyVuccGridsMultiGridStation() { + // A station location (台址) can span several grids: LoTW emits + // (comma-separated) with MY_GRIDSQUARE absent. Both + // grids must land in myGrids, and myGrid (back-compat) = the first. + val qso = "A50QO\n20260820\n1130\n" + + "SAT\nRS-44\nCW\n" + + "OM60,OM50\nNL47\n\n" + val result = repo.parseConfirmedGridQsos(report(qso))!! + val parsed = result["NL47"]!!.first() + assertEquals(setOf("OM60", "OM50"), parsed.myGrids) + assertEquals("OM60", parsed.myGrid) + // 6-char fields are truncated to 4 like every other grid path. + val six = "SAT\nRS-44\n" + + "OM60IL70,OM50MA20\nOK48\n\n" + assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids) + } + + @Test + fun parseQsosBuildsStationKeyFromMyFields() { + // 台址 identity = the MY_* station snapshot (callsign + dxcc + state + + // CQ/ITU + IOTA + country). A record with the full snapshot yields the + // stable key the operated-grid selector groups by. + val qso = "A50QO\n20260820\n1130\n" + + "SAT\nRS-44\n" + + "BH6RJD\n318\nJS // Jiangsu\n" + + "24\n44\nCHINA\n" + + "NL47\n\n" + val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first() + assertEquals("BH6RJD", parsed.myCallsign) + assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey) + } + + @Test + fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() { + // One callsign can own several 台址 (e.g. BH6RJD with a Hubei location + // and a Zhejiang location); different MY_STATE -> different stationKey. + val hb = "A50QO\n20260820\n1130\n" + + "SAT\nRS-44\n" + + "BH6RJD\nHB // Hubei\nNL47\n\n" + val zj = "BG7ZFK\n20260819\n1130\n" + + "SAT\nSO-50\n" + + "BH6RJD\nZJ // Zhejiang\nOK48\n\n" + val result = repo.parseConfirmedGridQsos(report(hb, zj))!! + assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey) + } + // endregion // region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from) @@ -224,6 +271,30 @@ class LoTWRepositoryTest { assertNull(repo.parseRoamedGrids("Username/password incorrect")) } + @Test + fun parseRoamedGridsCollectsMyVuccGrids() { + // Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE + // may be absent); every field must reach the roamed set for the stripes. + val qso = "SAT\nRS-44\n" + + "OM60,OM50\nNL47\n\n" + assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso))) + // Combined with MY_GRIDSQUARE the union is kept. + val both = "SAT\nRS-44\n" + + "OL62\nOM60,OM50\nNL47\n\n" + assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both))) + } + + @Test + fun parseRoamedGridsUppercasesLowercaseMyGrid() { + // LoTW emits the tag lowercase, so field CASE is not guaranteed: + // a lowercase MY_GRIDSQUARE must still match the overlay's uppercase + // labels (regression: take(4) without .uppercase() silently dropped + // stripes for lowercase grids). + val qso = "SAT\nIO-86\n" + + "ol62\nPM95\n\n" + assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso))) + } + // endregion // region failure classification (fetchReportBody -> LoTWResult mapping) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt index 50569c5b..fcc348d9 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt @@ -49,8 +49,27 @@ data class GridQso( val country: String? = null, val cqz: Int? = null, val state: String? = null, - /** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */ - val myGrid: String? = null + /** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). + * Kept as the first entry of [myGrids] for backward compatibility with + * consumers that predate multi-grid station locations. */ + val myGrid: String? = null, + /** Every 4-char grid the station operated from for this QSO — ADIF + * plus every field of (a comma-separated + * list LoTW emits when one station location roams across several grid + * squares; MY_GRIDSQUARE may be absent on those records). A station + * location (台址) can legitimately span multiple grids, and the map's + * stripes and the operated-grid selector must show all of them. */ + val myGrids: Set = emptySet(), + /** Callsign of the station location this QSO was logged under (ADIF + * ; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity + * on its own — one callsign can have several station locations. */ + val myCallsign: String? = null, + /** Stable key of the station location (台址) this QSO was logged under, + * derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC + + * MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the + * operated-grid selector can group QSOs per 台址 and show each 台址 with + * its full grid set. Null for data synced before this field existed. */ + val stationKey: String? = null ) { /** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */ val bandLabel: String 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 9366f37e..2acc5f99 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 @@ -216,19 +216,6 @@ private fun MapScreen( // Selected award filter. Lives in an Activity-scoped ViewModel so it // survives page switches; defaults to VUCC only once per process (cold start). var selectedAward by mapFilterViewModel.selectedAward - // Six-award progress derived from the confirmed QSO store; recomputed when - // the store changes (LoTW/Wavelog sync). - // 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 - } // First callsign worked in each grid (earliest QSO by time), used by the // "首通呼号" label mode — derived from the same QSO store as the worked fills. val firstCallsByGrid: Map = remember(uiState.workedGridQsos) { @@ -236,26 +223,47 @@ private fun MapScreen( qsos.minByOrNull { it.epochMs }?.let { grid to it.call } }.toMap() } - // 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) + // 台址名单: QSOs grouped by the 台址's GRID SET. A station location (台址) + // can span several grids (MY_GRIDSQUARE + MY_VUCC_GRIDS), and QSOs whose + // location covers the same grid set belong to the same 台址; groups with an + // identical grid set merge (they are VUCC-equivalent — the MY_* snapshot + // can differ between records of the same physical 台址 when LoTW omits + // optional fields). Data synced before multi-grid support falls back to + // one group per grid, preserving the old selector exactly. + val stationGroups: List = remember(uiState.workedGridQsos) { + val byGridSet = LinkedHashMap, MutableSet>>() + for ((grid, qsos) in uiState.workedGridQsos) { + for (q in qsos) { + val gs = q.myGrids.ifEmpty { q.myGrid?.let { setOf(it) }.orEmpty() } + if (gs.isEmpty()) continue + val key = gs.sorted().joinToString(",") + byGridSet.getOrPut(key) { gs to mutableSetOf() }.second.add(grid) + } + } + byGridSet.map { (key, v) -> StationGroup(id = key, grids = v.first, worked = v.second) } + .sortedByDescending { it.workedCount } } - val awardProgress: List = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) { - val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() } + // Selected 台址 for VUCC counting; defaults to the group with the most + // worked grids. Null = "All" (every 台址 combined). Empty when the store + // carries no per-QSO myGrid data (requires a LoTW resync) — the selector + // is hidden then and VUCC falls back to the global count. + var selectedStationId by remember(stationGroups) { + mutableStateOf(stationGroups.maxByOrNull { it.workedCount }?.id) + } + val awardProgress: List = remember(uiState.workedGridQsos, stationGroups, selectedStationId) { val base = AwardCalculator.calculate(uiState.workedGridQsos) - if (vuccGrids == null) base + val worked = stationGroups.firstOrNull { it.id == selectedStationId }?.worked + if (worked == null) base else base.map { p -> - if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p + if (p.type == AwardType.VUCC) p.copy(workedKeys = worked, count = worked.size) else p } } - // Worked grids drawn on the map: filtered by the selected operated grid - // (null = all operated grids). Drives both the green fills and the tap - // listener below, so switching the selector changes which cells are green. - val workedGrids = remember(uiState.workedGrids, vuccByMyGrid, selectedMyGrid) { - if (selectedMyGrid == null) uiState.workedGrids - else vuccByMyGrid[selectedMyGrid] ?: emptySet() + // Worked grids drawn on the map: filtered by the selected 台址 (null = all). + // Drives both the green fills and the tap listener below, so switching the + // selector changes which cells are green. + val workedGrids = remember(uiState.workedGrids, stationGroups, selectedStationId) { + if (selectedStationId == null) uiState.workedGrids + else stationGroups.firstOrNull { it.id == selectedStationId }?.worked ?: emptySet() } val isGridMode = uiState.isGridMode // True when this composition restored a saved viewport. Only grid mode @@ -385,14 +393,14 @@ private fun MapScreen( if (isVertical) MapDataCard(mapData) else MapDataCards(mapData) } } - // Top-left: operated-grid selector for VUCC counting (grid mode only). + // Top-left: 台址 selector for VUCC counting (grid mode only). // Shown whenever per-grid QSO data exists; "All" (null) is available. - if (uiState.isGridMode && selectedAward == AwardType.VUCC && vuccByMyGrid.isNotEmpty()) { + if (uiState.isGridMode && selectedAward == AwardType.VUCC && stationGroups.isNotEmpty()) { VuccGridSelector( - options = vuccByMyGrid, + stations = stationGroups, allCount = uiState.workedGrids.size, - selected = selectedMyGrid, - onSelect = { selectedMyGrid = it }, + selected = selectedStationId, + onSelect = { selectedStationId = it }, modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) @@ -881,22 +889,42 @@ private fun AwardChipsRow( } /** - * Compact pill toggle for switching between satellite view and grid mode, + * One 台址 shown by the operated-grid selector. A 台址 is a station location + * and can span SEVERAL grids (MY_GRIDSQUARE + MY_VUCC_GRIDS); groups are keyed + * by the 台址's full grid set, so a multi-grid 台址 is one entry listing all + * its grids. Data synced before multi-grid support falls back to one group + * per grid. + */ +private data class StationGroup( + /** Grid-set key (sorted 4-char grids joined by ","). */ + val id: String, + /** Full grid set of this 台址 (1..n 4-char grids). */ + val grids: Set, + /** Distinct worked grids worked under this 台址 (any of its grids). */ + val worked: Set +) { + val workedCount: Int get() = worked.size + /** Selector label: the grids themselves — "OL62" for a single-grid 台址, + * "OM60,PM01" for a multi-grid one (matches how 台址 are described). */ + val label: String get() = grids.sorted().joinToString(",") +} + +/** 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. - */ + * background keeps the map readable underneath. */ @Composable private fun VuccGridSelector( - options: Map>, + stations: List, allCount: Int, selected: String?, onSelect: (String?) -> Unit, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } - // Most-worked grid first; selection is made on tap. "All" sits last. - val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } } + // Most-worked 台址 first; selection is made on tap. "All" sits last. + val sorted = remember(stations) { stations.sortedByDescending { it.workedCount } } + val selectedLabel = stations.firstOrNull { it.id == selected }?.label Surface( color = ComposeColor.Black.copy(alpha = 0.45f), shape = RoundedCornerShape(8.dp), @@ -914,8 +942,8 @@ private fun VuccGridSelector( .padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) ) { Text( - text = if (selected == null) "All ($allCount)" - else "$selected (${options[selected]?.size ?: 0})", + text = if (selectedLabel == null) "All ($allCount)" + else "$selectedLabel (${stations.firstOrNull { it.id == selected }?.workedCount ?: 0})", color = ComposeColor.White, fontSize = 12.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace @@ -929,20 +957,20 @@ private fun VuccGridSelector( } if (expanded) { androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f)) - sorted.forEach { (grid, grids) -> + sorted.forEach { station -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { - onSelect(grid) + onSelect(station.id) expanded = false } .padding(horizontal = 10.dp, vertical = 3.dp) ) { Text( - text = "$grid (${grids.size})", - color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White, + text = "${station.label} (${station.workedCount})", + color = if (station.id == selected) MaterialTheme.colorScheme.primary else ComposeColor.White, fontSize = 12.sp, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace )