Compare commits

...
14 changed files with 202 additions and 17 deletions

No files matched your search

@@ -66,8 +66,17 @@ class SelectionRepo(
}
}
// Selected satellites are pinned to the top (matches upstream); ordering
// within each group stays name/catnum. Sits on the reactive chain, so
// ticking an entry re-sorts the visible list immediately.
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithTypes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
@@ -284,6 +284,24 @@ class SelectionRepoSearchTest {
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
}
@Test
fun `selected satellites are shown first`() = runTest {
// Pinning ported from upstream: ticked satellites sort above the rest,
// each group keeping the name order.
val repo = createRepo(
items = listOf(
SatItem(catnum = 44444, name = "Zeta"),
SatItem(catnum = 25544, name = "Alpha"),
SatItem(catnum = 40967, name = "Beta")
)
)
val flow = repo.getEntriesFlow()
repo.setSelection(listOf(40967), true)
val results = flow.first()
assertEquals(listOf(40967, 25544, 44444), results.map { it.catnum })
assertEquals(listOf(true, false, false), results.map { it.isSelected })
}
private fun createRepo(
items: List<SatItem>,
sstvIds: List<Int> = emptyList()
@@ -14,9 +14,15 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
/**
* Small ADI codec for the logbook's actual field set (ADIF 3.1.7).
*
* Grid directions follow ADIF: the un-prefixed fields (GRIDSQUARE, VUCC_GRIDS) belong to
* the OPPOSITE station, the MY_* fields (MY_GRIDSQUARE, MY_VUCC_GRIDS) to the operator.
*/
object AdifCodec {
private val utc = TimeZone.getTimeZone("UTC")
private val gridPattern = Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")
fun encode(records: List<QsoRecord>, includeStationCallsign: Boolean = true): String = buildString {
append(field("ADIF_VER", "3.1.7"))
@@ -36,7 +42,17 @@ object AdifCodec {
appendOptional("MODE", record.mode)
appendOptional("SUBMODE", record.submode)
appendOptional("GRIDSQUARE", record.theirGrid)
// The OPPOSITE station's multi-grid set (2+ grids: a grid line or corner).
// A single grid is carried by GRIDSQUARE; one value inside the VUCC field
// makes Wavelog flag the record in its QSO view.
if (record.theirVuccGrids.size > 1) {
appendOptional("VUCC_GRIDS", record.theirVuccGrids.joinToString(","))
}
appendOptional("MY_GRIDSQUARE", record.myGrid)
// The grid set this qso went out under (own operated grids, stamped at
// upload). ADIF's MY_VUCC_GRIDS is its home — not VUCC_GRIDS, which belongs
// to the opposite station and drives Wavelog's grid/distance/QRB display.
appendOptional("MY_VUCC_GRIDS", record.vuccGrids.joinToString(","))
appendOptional("RST_SENT", record.sentReport)
appendOptional("RST_RCVD", record.receivedReport)
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
@@ -49,7 +65,6 @@ object AdifCodec {
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
record.dxcc?.let { append(field("DXCC", it.toString())) }
appendOptional("COUNTRY", record.country)
record.cqZone?.let { append(field("CQZ", it.toString())) }
@@ -80,7 +95,9 @@ object AdifCodec {
theirCallsign = call,
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
theirVuccGrids = decodeGrids(values["VUCC_GRIDS"]),
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
vuccGrids = decodeGrids(values["MY_VUCC_GRIDS"]),
sentReport = values["RST_SENT"].orEmpty(),
receivedReport = values["RST_RCVD"].orEmpty(),
txFrequencyHz = mhzToHz(values["FREQ"]),
@@ -100,8 +117,6 @@ object AdifCodec {
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
dxcc = values["DXCC"]?.toIntOrNull(),
country = values["COUNTRY"].orEmpty(),
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
@@ -140,6 +155,12 @@ object AdifCodec {
return records
}
/** Splits an ADIF grid list on commas, keeping only valid 4/6/8-char locators. */
private fun decodeGrids(value: String?): List<String> = value.orEmpty().split(',')
.map { it.trim().uppercase(Locale.US) }
.filter { it.matches(gridPattern) }
.distinct()
private fun StringBuilder.appendOptional(name: String, value: String) {
if (value.isNotBlank()) append(field(name, value))
}
@@ -48,6 +48,8 @@ data class QsoRecord(
val wavelogStation: String = "",
val lotwReceived: Boolean = false,
val lotwQslDate: String = "",
/** Own operated grid set this QSO went out under — uploaded as ADIF MY_VUCC_GRIDS
* (never VUCC_GRIDS, which belongs to the opposite station). Stamped at upload. */
val vuccGrids: List<String> = emptyList(),
val dxcc: Int? = null,
val country: String = "",
@@ -10,6 +10,7 @@
package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -22,8 +23,10 @@ class AdifCodecTest {
endUtcMillis = 1_700_000_000_100L,
theirCallsign = "BG5JVM",
myCallsign = "BA7OPF",
theirGrid = "OM60",
theirGrid = "EN52",
theirVuccGrids = listOf("EN52", "EN53"),
myGrid = "OL62TI",
vuccGrids = listOf("OL62", "OL61"),
sentReport = "59",
receivedReport = "59",
txFrequencyHz = 145_850_000L,
@@ -36,8 +39,7 @@ class AdifCodecTest {
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
lotwQslDate = "2026-09-20",
vuccGrids = listOf("OM60", "OM50")
lotwQslDate = "2026-09-20"
)
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
@@ -47,12 +49,18 @@ class AdifCodecTest {
assertEquals("2M", decoded.band)
assertEquals("70CM", decoded.rxBand)
assertTrue(decoded.lotwConfirmed)
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
// Grid directions survive a round trip: the opposite's set stays theirs,
// the operator's own set stays theirs.
assertEquals("EN52", decoded.theirGrid)
assertEquals(listOf("EN52", "EN53"), decoded.theirVuccGrids)
assertEquals("OL62TI", decoded.myGrid)
assertEquals(listOf("OL62", "OL61"), decoded.vuccGrids)
}
@Test
fun decode_lotwStyleReportFields() {
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
// LoTW report subset: lowercase eor, alphabetical fields. The un-prefixed
// grid fields belong to the OPPOSITE station, MY_* fields to the operator.
val adi = """
<EOH>
<CALL:6>BH6RJD
@@ -64,8 +72,9 @@ class AdifCodecTest {
<BAND:3>2M
<BAND_RX:4>70CM
<GRIDSQUARE:4>OM60
<MY_GRIDSQUARE:8>OM60IL70
<VUCC_GRIDS:9>OM60,OM50
<MY_GRIDSQUARE:6>OL62TI
<MY_VUCC_GRIDS:9>OL62,OL61
<STATION_CALLSIGN:6>BH6RJD
<DXCC:3>318
<COUNTRY:5>China
@@ -78,12 +87,27 @@ class AdifCodecTest {
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("SO-50", record.satelliteName)
assertEquals("FM", record.displayMode)
assertEquals("OM60IL70", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("OM60", record.theirGrid)
assertEquals(listOf("OM60", "OM50"), record.theirVuccGrids)
assertEquals("OL62TI", record.myGrid)
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
}
@Test
fun decodeWavelogPullStyleRecordKeepsGridDirections() {
// Wavelog's export: the OPPOSITE station's grids un-prefixed, the station
// profile's grid under MY_*. A pull must not flip them into each other.
val adi = "<EOH><CALL:6>BG5JVM<QSO_DATE:8>20260916<TIME_ON:4>0745" +
"<VUCC_GRIDS:9>EN52,EN53<MY_GRIDSQUARE:6>OL62TI<MY_VUCC_GRIDS:9>OL62,OL61" +
"<MODE:2>FM<PROP_MODE:3>SAT<SAT_NAME:5>SO-50<EOR>"
val record = AdifCodec.decode(adi).single()
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertEquals("OL62TI", record.myGrid)
assertEquals(listOf("OL62", "OL61"), record.vuccGrids)
}
@Test
fun encodeOmitsStationCallsignForWavelog() {
val record = QsoRecord(
@@ -99,4 +123,44 @@ class AdifCodecTest {
assertTrue(adif.contains("<CALL:6>N0CALL"))
assertTrue(adif.contains("<SAT_NAME:5>SO-50"))
}
@Test
fun encodeKeepsOwnGridsOutOfTheOppositeVuccField() {
// The reported defect: an operator's stamped own grid set must never land in
// VUCC_GRIDS — Wavelog displays that field as the OPPOSITE station's grid.
val record = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
myGrid = "OL62TI",
vuccGrids = listOf("OL62"),
satelliteName = "SO-50"
)
val adif = AdifCodec.encode(listOf(record), includeStationCallsign = false)
assertTrue(adif.contains("<MY_VUCC_GRIDS:4>OL62"))
assertFalse(adif.contains("<VUCC_GRIDS:"))
assertFalse(adif.contains("<GRIDSQUARE:"))
}
@Test
fun encodeWritesTheOppositeMultiGridSetToVuccGrids() {
val multi = QsoRecord(
startUtcMillis = 1_700_000_000_000L,
theirCallsign = "N0CALL",
myCallsign = "BA7OPF",
theirGrid = "EN52",
theirVuccGrids = listOf("EN52", "EN53"),
satelliteName = "SO-50"
)
val multiAdif = AdifCodec.encode(listOf(multi), false)
assertTrue(multiAdif.contains("<GRIDSQUARE:4>EN52"))
assertTrue(multiAdif.contains("<VUCC_GRIDS:9>EN52,EN53"))
// A single opposite grid rides in GRIDSQUARE only; one value inside the VUCC
// field would trigger Wavelog's single-grid warning in its QSO view.
val single = multi.copy(theirVuccGrids = listOf("EN52"))
val singleAdif = AdifCodec.encode(listOf(single), false)
assertTrue(singleAdif.contains("<GRIDSQUARE:4>EN52"))
assertFalse(singleAdif.contains("<VUCC_GRIDS:"))
}
}
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
@@ -19,6 +19,10 @@
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
@@ -20,6 +20,10 @@
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
@@ -21,6 +21,10 @@
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
@@ -326,9 +326,52 @@ private fun SatellitesPreview() {
@Composable
private fun SatellitesCard(items: List<SatItem>, onSelected: (Int, Boolean) -> Unit) {
val selectedItems = items.filter { it.isSelected }
val availableItems = items.filterNot { it.isSelected }
LazyVerticalGrid(columns = GridCells.Adaptive(320.dp)) {
items(items = items, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
stickyHeader(key = "sat_summary_header") {
SummaryHeader(
selectedText = stringResource(R.string.sat_group_selected_count, selectedItems.size),
availableText = stringResource(R.string.sat_group_available_count, availableItems.size)
)
}
if (selectedItems.isNotEmpty()) {
items(items = selectedItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
if (availableItems.isNotEmpty()) {
items(items = availableItems, key = { item -> item.catnum }) { entry ->
Satellite(entry, onSelected, Modifier.animateItem())
}
}
}
}
@Composable
private fun SummaryHeader(selectedText: String, availableText: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = selectedText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = availableText,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "579"
appVersionCode = "580"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.11.4"
appVersionName = "4.4.8-ba7opf.11.5"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry