Compare commits

..
Author SHA1 Message Date
atsunatsu b979524b37 chore: bump version to 4.4.7-ba7opf.1 (versionCode 471) 2026-09-14 20:45:38 +08:00
atsunatsu 9e748f5534 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.
2026-09-14 20:45:25 +08:00
atsunatsu 25b982ac59 feat(vucc): per operated-grid VUCC counting
GridQso gains myGrid (ADIF MY_GRIDSQUARE) captured per QSO during LoTW
parsing (handles alphabetical field order + EOR reset) and persisted as
JSON key 'mg' (absent in pre-myGrid data -> null). Legacy syncs keep the
global VUCC count; one resync unlocks per-grid counting.
2026-09-14 20:45:20 +08:00
atsunatsu 6a6399b606 fix(passes): recalculate passes when the UTC toggle changes
Port of upstream PR #252 (wty2019wty): combine otherSettings.stateOfUtc
into initRepository so an existing AOS time window is reapplied in the
newly selected timezone right after toggling UTC, instead of waiting for
the next selection/position change.
2026-09-14 18:54:22 +08:00
9 changed files with 180 additions and 16 deletions

No files matched your search

@@ -169,6 +169,7 @@ class LoTWRepository : ILoTWRepository {
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
@@ -177,7 +178,8 @@ class LoTWRepository : ILoTWRepository {
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
dxcc = dxcc, country = country, cqz = cqz, state = state,
myGrid = myGrid
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -188,6 +190,7 @@ class LoTWRepository : ILoTWRepository {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
gridsInRecord.clear()
}
@@ -222,6 +225,13 @@ class LoTWRepository : ILoTWRepository {
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_GRIDSQUARE:") -> {
// 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.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
@@ -66,8 +68,11 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
settingsRepo.stationPosition,
// Recalculate passes when the UTC display toggle changes, so an existing
// AOS time window is reapplied in the newly selected timezone.
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
@@ -204,7 +204,9 @@ class SettingsRepo(
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
country = o.optString("cty").ifBlank { null },
cqz = o.optInt("cq", 0).takeIf { it > 0 },
state = o.optString("st").ifBlank { null }
state = o.optString("st").ifBlank { null },
// Own-grid field: absent in pre-myGrid data -> null.
myGrid = o.optString("mg").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
@@ -232,6 +234,7 @@ class SettingsRepo(
.put("cty", q.country ?: "")
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
)
}
root.put(grid, array)
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWRepositoryTest {
@@ -150,6 +151,24 @@ class LoTWRepositoryTest {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseQsosCapturesOwnGridPerQso() {
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
// alphabetically); the per-record buffer must still attach it to the QSO.
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
assertEquals("OL62", result["NL47"]!!.first().myGrid)
assertEquals("OL72", result["OK48"]!!.first().myGrid)
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@@ -48,7 +48,9 @@ data class GridQso(
val dxcc: Int? = null,
val country: String? = null,
val cqz: Int? = null,
val state: String? = null
val state: String? = null,
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
val myGrid: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -122,6 +122,10 @@
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="map_visible">日照中</string>
@@ -147,6 +147,10 @@
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="map_visible">Visible</string>
@@ -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?>(AwardType.VUCC) }
// Six-award progress derived from the confirmed QSO store; recomputed when
// the store changes (LoTW/Wavelog sync).
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
AwardCalculator.calculate(uiState.workedGridQsos)
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
val m = mutableMapOf<String, MutableSet<String>>()
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<AwardProgress> = 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<String, Set<String>>,
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
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "470"
appVersionCode = "471"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.0"
appVersionName = "4.4.7-ba7opf.1"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry