Compare commits

...
Author SHA1 Message Date
atsunatsu 1fd1459ddf build: release version 4.4.8-ba7opf.3 (555) 2026-10-02 13:31:18 +08:00
atsunatsu 56a788138b feat(lotw): 漫游核验改用「当前定位 vs 证书台址」,移除记录网格核对
- 新增 LoTWPositionWarning + positionWarning(currentGrid, stationGrids):当前定位网格不在
  证书台址网格集内 → 提示(4 位归一;任一侧缺失不报)
- ISettingsRepo.getCurrentGrid():只读取「≤24h 系统最后已知定位」与「存储台址」中较新者,
  不写任何设置(避开 setStationPosition 的写副作用)
- 上传前核对弹窗(雷达 / 日志本两入口)改由 VM 在 prepare 后计算;雷达记录页新增提示行
  (stationMismatch,点击=跳转改台址并预填当前格)
- 移除旧「记录 vs 台址」核对:uploadGridWarning / LoTWGridWarning / preview.gridWarning 及
  相关 UI / 字符串全删;LoTWPositionWarningDialog 重命名替代 LoTWGridWarningDialog
- 测试:LoTWPositionWarningTest ×5 替代 LoTWGridWarningTest;domain 171 / data 116 / mutual 28 全绿
2026-10-02 13:31:13 +08:00
atsunatsu 2e704ee1d3 build: release version 4.4.8-ba7opf.3 (554) 2026-10-02 12:02:03 +08:00
atsunatsu 5b7c7d6df5 feat(logbook): 窗口外录入 + 网格显示链 + 双倍修复 + 上传前核对 + 重传选择 + 报错拆分
.2.5–.3 调试系列收口:

- 雷达窗口外录入:不在过境窗口内按 Log 时按当前过境中点落点(弹窗确认,可事后编辑改时间)
- 网格显示链:上传盖章(markUploaded 记录台址网格集)+ 日志本行尾 / 雷达页「自己的格子」+
  QSL 槽对方网格(绿 #4CD964)+ 缩写规则统一(gridsLabel 上移 core:presentation);
  QsoDatabase v2→v3 增列 theirVuccGrids
- 全量同步「双倍」三层修复:键无关身份±60s 镜像折叠、matchableMode 归一(MFSK→FT4)、
  双确认对按 contentScore 折叠
- 上传前核对:批记录网格与台址完全不交集 → 弹窗(修改台址带预填跳转 / 忽略),预览被闸住
- 重传选中:日志本长按多选(含已上传/已确认)→ prepare(resubmit=true) → 成功后网格盖章更新;
  退出日志本重置选择(需重新长按)
- 报错拆分:QSO_FUTURE(记录时间在未来)与 QSO_DATE(超出证书范围)分开提示
- 全 App 弹窗样式统一(深色圆角 sheet)

测试:domain 172 / data 116 全绿。
2026-10-02 12:01:57 +08:00
43 changed files with 1211 additions and 101 deletions

No files matched your search

@@ -147,7 +147,7 @@ fun NavRoot(deeplink: String? = null) {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
}
}
entry<GridFinderDestination> {
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
* upload receipts). Kept apart from [Look4SatDb] so existing installations
* never need a migration of the satellite database.
*/
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
abstract class QsoDatabase : RoomDatabase() {
abstract fun qsoDao(): QsoDao
}
@@ -31,3 +31,10 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
}
}
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
}
}
@@ -47,6 +47,7 @@ data class QsoEntity(
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
val dxcc: Int? = null,
@ColumnInfo(defaultValue = "''") val country: String = "",
val cqZone: Int? = null,
@@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
import com.rtbishop.look4sat.core.data.database.QsoDatabase
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -103,7 +104,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
override val qsoRepository: IQsoRepository by lazy {
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
.addMigrations(MIGRATION_QSO_1_2)
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3)
.build()
QsoRepository(database.qsoDao(), Dispatchers.IO)
}
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
// A record dated after "now" gets its own message: it happens when the operator
// logged against a pass that had not started yet (out-of-window flow), and "fix the
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
// operator hunting the certificate while the culprit was the record's future time.
if (record.startUtcMillis > now) {
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
}
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
fail(LoTWProblem.QSO_DATE, call)
}
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
@@ -279,6 +279,13 @@ class LoTWUploadRepository internal constructor(
else -> contact
}
}
// The station-location grids this batch is signed with: the primary grid plus the
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
val locationGrids = buildList {
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
signing.location["MY_VUCC_GRIDS"]?.split(',')
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
}.distinct()
val preview = LoTWUploadPreview(
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
contacts.size, skipped,
@@ -289,7 +296,9 @@ class LoTWUploadRepository internal constructor(
unavailable,
reasons.toMap(),
duplicates,
contacts.map { it.record.id }
contacts.map { it.record.id },
locationGrids,
resubmit
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
myGrid = myGrids.firstOrNull(),
myGrids = myGrids,
myCallsign = myCallsign,
stationKey = stationKey
stationKey = stationKey,
// Copy, never the live buffer — resetRecord() clears it.
theirGrids = gridsInRecord.distinct().toList()
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -16,8 +16,11 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
import com.rtbishop.look4sat.core.domain.logbook.bandsMatchOrMirror
import com.rtbishop.look4sat.core.domain.logbook.contentScore
import com.rtbishop.look4sat.core.domain.logbook.displayMode
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
@@ -54,14 +57,14 @@ class QsoRepository(
dao.save(record.copy(
id = 0L,
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
lotwQslDate = "", vuccGrids = emptyList(),
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
).toEntity())
} else {
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
record.withConfirmation(previous)
} else if (previous?.lotwConfirmed == true) record.copy(
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
dxcc = null, country = "", cqZone = null, region = ""
) else record
val received = if (previous != null && (
@@ -76,9 +79,18 @@ class QsoRepository(
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
if (ids.isEmpty()) return@withContext
dao.markUploaded(ids)
// Stamp the station grid set this batch went out under (a line/corner prefill from
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (normalized.isEmpty()) return@withContext
val stamped = ids.mapNotNull { id ->
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
}
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
}
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
@@ -132,9 +144,7 @@ class QsoRepository(
val renames = rewriteOfficialNames(working)
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
.mapValues { it.value.toMutableList() }.toMutableMap()
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
.mapValues { it.value.toMutableList() }.toMutableMap()
val knownKeys = keyIndex.keys.toMutableSet()
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
val changes = linkedMapOf<Int, QsoRecord>().apply {
putAll(consolidation.merged)
putAll(renames)
@@ -149,27 +159,38 @@ class QsoRepository(
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
val match = exact.singleOrNull() ?: candidates.singleOrNull()
if (match == null) {
// A confirmation downloaded by the pre-fix parser sits as its own row with the
// band direction read mirrored (the downlink was stored as the uplink). It has no
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
// so it would otherwise be skipped forever, keeping the wrong band on display.
// When the bands are exactly mirrored, adopt the report's direction instead.
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
?.takeIf { index -> working[index].lotwConfirmed &&
working[index].band.equals(remote.rxBand, true) &&
working[index].rxBand.equals(remote.band, true) } else null
if (stale != null) {
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
working[stale] = fixed
changes[stale] = fixed
updated++
// A confirmation downloaded by an early release sits as its own row with the
// band direction read mirrored (the downlink was stored as the uplink). The
// strict lookup above cannot see it: its bands fail the comparison, and its
// dedupe key can differ too (fields written by older releases — timestamp
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
// as known either). Fold the report into the richest mirrored row instead of
// inserting a second row next to it; left as two rows, neither consolidation
// nor a later sync could reliably heal them.
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
!working[index].band.equals(remote.band, true) &&
sameContactIdentity(working[index], remote) &&
bandsMatchOrMirror(working[index], remote)
} else emptyList()
if (mirroredRows.isNotEmpty()) {
val target = mirroredRows.maxBy { contentScore(working[it]) }
mirroredRows.forEach { index ->
val healed = working[index].copy(
band = remote.band.ifBlank { working[index].band },
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
)
val folded = if (index == target) healed.withConfirmation(remote) else healed
working[index] = folded
changes[index] = folded
}
updated += mirroredRows.size
return@forEach
}
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
changes[working.size] = added
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
knownKeys += stableQsoKey(added)
working += added
imported++
@@ -268,6 +289,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
dxcc = dxcc,
country = country,
cqZone = cqZone,
@@ -304,6 +326,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
@@ -468,6 +468,34 @@ class SettingsRepo(
return true
}
override fun getCurrentGrid(): String? {
val station = _stationPosition.value
val now = System.currentTimeMillis()
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
// station, so a stale fix can never override a position the operator just set.
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
return chosen.takeIf(String::isNotBlank)
}
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
private fun lastKnownGridFix(): Pair<String, Long>? {
return try {
val provider = when {
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
else -> return null
}
val location = locationManager.getLastKnownLocation(provider) ?: return null
val locator = positionToQth(location.latitude, location.longitude) ?: return null
locator to location.time
} catch (_: SecurityException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun getStationPosition(): GeoPos {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
@@ -271,6 +271,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
result.values.forEach { assertEquals(1, it.size) }
}
@Test
fun parseQsosCapturesOppositeGridSet() {
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertEquals(
listOf("NL47"),
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
)
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("EN52", "EN53"),
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
)
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
assertEquals(
listOf("OM60", "EN52", "EN53"),
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
)
}
@Test
fun parseQsosRejectsBodyWithoutEoh() {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
// The row is also renamed to the ARRL spelling while it is merged.
assertEquals("SO-50", rows.first().satelliteName)
assertEquals(145_850_000L, rows.first().txFrequencyHz)
// The opposite station's grid set from the report lands on the row.
assertEquals("EN52", rows.first().theirGrid)
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
}
@Test
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
}
@Test
fun consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
// The mirrored import the old parser created (no frequencies, no report-side grids —
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
// carries the lower id.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
.toEntity()
)
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
val localId = dao.save(
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
)
assertEquals(1, repository.consolidateConfirmations())
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(localId, rows.first().id)
// The surviving row keeps the correct uplink-first bands and gains the report fills.
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertEquals("OL62", rows.first().myGrid)
assertEquals(318, rows.first().dxcc)
assertEquals("China", rows.first().country)
assertTrue(rows.first().lotwUploaded)
}
@Test
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
// into one row with the report's direction.
val stale = reportConfirmation(satName = "SO-50")
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
dao.save(stale.toEntity())
dao.save(stale.copy(id = 0L).toEntity())
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
// The report's fills land on the healed row.
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
// The doubled contacts of the reported bug: a mirrored row written by an older release
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
// the exact time, differ from the report's) used to gain a second row next to it
// instead of being healed, because every net relied on the key or the strict bands.
val staleId = dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
startUtcMillis = qsoStart + 456L
)
.toEntity()
)
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals(staleId, rows.first().id)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
// carries MFSK + FT4. The spelling difference must not block the heal either.
dao.save(
reportConfirmation(satName = "SO-50")
.copy(
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
theirGrid = "", theirVuccGrids = emptyList()
)
.toEntity()
)
repository.mergeLoTW(
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
)
val rows = dao.getAll()
assertEquals(1, rows.size)
assertEquals("2M", rows.first().band)
assertEquals("70CM", rows.first().rxBand)
assertTrue(rows.first().lotwConfirmed)
assertEquals("EN52", rows.first().theirGrid)
}
@Test
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
@@ -221,7 +324,8 @@ class QsoRepositoryTest {
cqz = 24,
state = "GD",
myGrid = "OL62",
myGrids = setOf("OL62", "OL63")
myGrids = setOf("OL62", "OL63"),
theirGrids = listOf("EN52", "EN53")
).toConfirmedRecord("BA7OPF")
private fun QsoRecord.toEntity() = QsoEntity(
@@ -254,6 +358,7 @@ class QsoRepositoryTest {
lotwReceived = lotwReceived,
lotwQslDate = lotwQslDate,
vuccGrids = vuccGrids.joinToString(","),
theirVuccGrids = theirVuccGrids.joinToString(","),
dxcc = dxcc,
country = country,
cqZone = cqZone,
@@ -330,6 +330,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
@@ -17,7 +17,12 @@ interface IQsoRepository {
suspend fun find(id: Long): QsoRecord?
suspend fun save(record: QsoRecord): Long
suspend fun delete(id: Long)
suspend fun markUploaded(ids: List<Long>)
/**
* Marks the batch as uploaded and stamps the station-location grids the batch was
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
* logbook row can show which grids the QSO went out under.
*/
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
suspend fun importAdi(content: String): AdifImportResult
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
startUtcMillis = epochMs,
theirCallsign = call,
myCallsign = accountCallsign.trim().uppercase(),
theirGrid = theirGrids.firstOrNull().orEmpty(),
theirVuccGrids = theirGrids,
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
vuccGrids = myGrids.toList(),
band = bandUp,
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
LoTWProblem.MODE -> "mode not accepted by LoTW"
LoTWProblem.QSO_DATE -> "date outside the certificate"
LoTWProblem.QSO_FUTURE -> "record time is in the future"
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
LoTWProblem.STATION_GRID -> "station grid missing"
@@ -0,0 +1,20 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
/**
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
*
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
* the point is to send them again under a corrected station location, and LoTW treats an
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
*/
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
@@ -80,7 +80,7 @@ fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
local.isSatellite == remote.isSatellite &&
local.displayMode == remote.displayMode &&
local.matchableMode == remote.matchableMode &&
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
}
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
fun QsoRecord.confirmationLookupKey(): String = listOf(
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
).joinToString("|")
/** A local row and the confirmation row that belongs to it, as indices into one list. */
/** A row to keep and the duplicate row to fold into it (then delete), as indices into one list. */
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
/**
* Rows that a confirmation should have been folded into but was not, so they can be
* consolidated back into one.
* Rows that should have been one row but were not, so they can be consolidated back into one.
*
* Confirmations merged before the identity fix landed as their own rows: the tracker
* name vs ARRL name comparison failed, and the band direction of the report was read
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
* were stored twice. The pair is only accepted when the contact identity matches and
* the bands are either identical or exactly mirrored — the signature of that second
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
* Two known holes leave a contact stored twice:
*
* 1. Confirmations merged before the identity fix landed as their own rows: the tracker name
* vs ARRL name comparison failed, and the band direction of the report was read mirrored
* (BAND_RX taken as the uplink) — so an unconfirmed local row and its confirmation stayed
* apart. Pass 1 folds the confirmation into the local row.
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
* created plus the row that was confirmed on its own later, or a duplicate import added
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
* are confirmed — so pass 2 pairs confirmed rows with each other.
*
* Both passes accept a pair only when the contact identity matches and the bands are either
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
* other one is folded into it, so no data is lost.
*/
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
val taken = mutableSetOf<Int>()
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
val pairs = mutableListOf<SplitConfirmationPair>()
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
val confirmation = records[confirmationIndex]
val localIndex = pendingIndices.firstOrNull { index ->
index !in taken && sameContactIdentity(records[index], confirmation) &&
bandsMatchOrMirror(records[index], confirmation)
} ?: return@mapNotNull null
} ?: return@forEach
taken += localIndex
SplitConfirmationPair(localIndex, confirmationIndex)
taken += confirmationIndex
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
}
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
confirmedLeft.forEach { index ->
if (index in taken) return@forEach
val cluster = mutableListOf(index)
confirmedLeft.forEach { candidate ->
if (candidate != index && candidate !in taken &&
cluster.all {
sameContactIdentity(records[it], records[candidate]) &&
bandsMatchOrMirror(records[it], records[candidate])
}
) {
cluster += candidate
}
}
if (cluster.size < 2) return@forEach
cluster.forEach { taken += it }
val keep = cluster.maxBy { contentScore(records[it]) }
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
}
return pairs
}
/** More operator/report detail first — decides which row survives a pass-2 fold. */
fun contentScore(record: QsoRecord): Int {
var score = 0
// Frequencies are the strongest operator-entered signal; a band label consistent with its
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
// contradict the side they describe.
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
.count { it.isNotBlank() }
score += record.vuccGrids.size
score += record.theirVuccGrids.size
if (record.lotwUploaded) score += 1
if (record.lotwReceived) score += 1
if (record.dxcc != null) score += 1
if (record.country.isNotBlank()) score += 1
if (record.lotwQslDate.isNotBlank()) score += 1
return score
}
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
myGrid = myGrid.ifBlank { confirmed.myGrid },
sentReport = sentReport.ifBlank { confirmed.sentReport },
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
return label.trim().uppercase(Locale.US)
}
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
* differently, so both spellings must match. */
val QsoRecord.matchableMode: String
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
val QsoRecord.isSatellite: Boolean
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
@@ -18,6 +18,10 @@ data class QsoRecord(
val theirCallsign: String,
val myCallsign: String,
val theirGrid: String = "",
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
* the logbook's QSL slot shows the abbreviated set. */
val theirVuccGrids: List<String> = emptyList(),
val myGrid: String = "",
val sentReport: String = "",
val receivedReport: String = "",
@@ -72,7 +72,13 @@ data class GridQso(
* 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
val stationKey: String? = null,
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
* the contact spans several squares; up to four in one QSO). The logbook
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
* same thing for the user's own side. */
val theirGrids: List<String> = emptyList()
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -19,6 +19,7 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
import java.util.Locale
interface ILoTWUploadRepository {
suspend fun certificate(): LoTWCertificate?
@@ -96,7 +97,23 @@ data class LoTWUploadPreview(
val duplicateSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList()
val submittedIds: List<Long> = emptyList(),
/** The full station-location grid set this batch is signed with (1 inside a grid,
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
* can show which gridsquares the QSO went out under. */
val grids: List<String> = emptyList(),
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
* through so the corrected station location reaches LoTW as an update of the contact. */
val resubmit: Boolean = false
)
/** Pre-upload location check: the freshest known position grid is not covered by the
* station grids an upload would be signed with. */
data class LoTWPositionWarning(
/** Normalized 4-character grid of the freshest known position. */
val currentGrid: String,
/** Distinct station-location grids the upload would be signed with. */
val stationGrids: List<String>
)
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
@@ -128,7 +145,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
enum class LoTWProblem {
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, QSO_FUTURE, STATION_GRID,
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
LOCATION_MISMATCH, TOO_MANY_CONTACTS
}
/**
* Roaming check run before upload: warns when the operator's freshest known position
* (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the
* batch would be signed with — the signature of operating while roaming with a station
* location that was never updated. An unknown position, missing station grids, or a covered
* position (boundary operations included) produce no warning.
*/
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
val current = currentGrid?.grid4() ?: return null
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
if (station.isEmpty()) return null
if (current in station) return null
return LoTWPositionWarning(current, station.sorted())
}
/** Normalized 4-character grid, null when the value is not a usable grid. */
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
@@ -48,6 +48,12 @@ interface ISettingsRepo {
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
/**
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
* (≤ 24 h) system location fix and the stored station position. Null only when neither
* yields a usable locator. Never writes the station position.
*/
fun getCurrentGrid(): String?
//endregion
//region # Database update settings
@@ -40,7 +40,8 @@ class LogbookMergeTest {
propagationMode = "SAT",
status = QsoStatus.COMPLETE,
lotwConfirmed = true,
vuccGrids = listOf("OM60", "OM50")
vuccGrids = listOf("OM60", "OM50"),
theirVuccGrids = listOf("EN52", "EN53")
)
@Test
@@ -73,12 +74,24 @@ class LogbookMergeTest {
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
}
@Test
fun confirmationLookupKey_groupsMfskAndFt4Spellings() {
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
// the same mode and must resolve to the same contact.
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
assertTrue(sameContactIdentity(plainMfsk, ft4))
}
@Test
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
val merged = local.withConfirmation(confirmed)
assertTrue(merged.lotwConfirmed)
assertTrue(merged.lotwReceived)
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
// The opposite station's grid set arrives with the confirmation too.
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
// Local myCallsign is kept when present; only blanks are backfilled.
assertEquals("ba7opf", merged.myCallsign)
}
@@ -97,13 +110,16 @@ class LogbookMergeTest {
cqz = 24,
state = "GD",
myGrid = "OM60",
myGrids = setOf("OM60", "OM50")
myGrids = setOf("OM60", "OM50"),
theirGrids = listOf("EN52", "EN53")
)
val record = qso.toConfirmedRecord("ba7opf")
assertEquals("BH6RJD", record.theirCallsign)
assertEquals("BA7OPF", record.myCallsign)
assertEquals("OM60", record.myGrid)
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
assertEquals("EN52", record.theirGrid)
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
assertTrue(record.lotwConfirmed)
assertTrue(record.isSatellite)
assertEquals("70CM", record.band)
@@ -198,6 +214,30 @@ class LogbookMergeTest {
assertEquals(1, pairs.first().confirmationIndex)
}
@Test
fun splitConfirmationPairs_foldsTwoConfirmedRowsAndKeepsTheRicher() {
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
// created (no frequencies, report-only fills) plus the app row that was confirmed on
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
// they stay doubled in the logbook forever.
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
.copy(id = 2L, lotwConfirmed = true)
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
assertEquals(1, pairs.size)
// The richer row (frequencies + consistent bands + upload state) is the survivor.
assertEquals(1, pairs.first().localIndex)
assertEquals(0, pairs.first().confirmationIndex)
}
@Test
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
}
@Test
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
@@ -0,0 +1,61 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*/
package com.rtbishop.look4sat.core.domain.logbook
import org.junit.Assert.assertEquals
import org.junit.Test
class LogbookResubmitTest {
private fun record(
id: Long,
status: QsoStatus = QsoStatus.COMPLETE,
uploaded: Boolean = false,
confirmed: Boolean = false
) = QsoRecord(
id = id,
startUtcMillis = 1_760_000_000_000L + id,
theirCallsign = "BG5JSB",
myCallsign = "BA7OPF",
mode = "FM",
satelliteName = "SO-50",
status = status,
lotwUploaded = uploaded,
lotwConfirmed = confirmed
)
@Test
fun keepsUploadedAndConfirmedRecords() {
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun dropsRecordsOutsideTheSelection() {
val records = listOf(record(1), record(2), record(3))
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
}
@Test
fun dropsIncompleteRecords() {
val records = listOf(
record(1, status = QsoStatus.DRAFT),
record(2, status = QsoStatus.ABORTED),
record(3, status = QsoStatus.COMPLETE)
)
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
}
@Test
fun emptySelectionYieldsNothing() {
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
}
}
@@ -276,6 +276,7 @@ class LoTWGridSyncTest {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -0,0 +1,56 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoTWPositionWarningTest {
@Test
fun coveredPositionProducesNoWarning() {
assertNull(positionWarning("OL62aa", listOf("OL62")))
}
@Test
fun positionInsideAnyStationGridIsCovered() {
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
}
@Test
fun positionOutsideTheStationWarns() {
val warning = positionWarning("om91", listOf("OL62"))
assertEquals("OM91", warning?.currentGrid)
assertEquals(listOf("OL62"), warning?.stationGrids)
}
@Test
fun stationGridsAreNormalizedForComparison() {
assertNull(positionWarning("OL62", listOf("ol62aa")))
assertNull(positionWarning("ol62", listOf("OL62")))
}
@Test
fun unknownPositionOrStationProducesNoWarning() {
assertNull(positionWarning(null, listOf("OL62")))
assertNull(positionWarning("", listOf("OL62")))
assertNull(positionWarning("OM91", emptyList()))
assertNull(positionWarning("OM91", listOf("")))
}
}
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
controller: SwipeController,
modifier: Modifier = Modifier,
revealWidth: Dp = 64.dp,
gesturesEnabled: Boolean = true,
revealAction: () -> Unit,
revealButton: @Composable () -> Unit = {
Box(
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { translationX = offsetX }
.pointerInput(revealPx) {
.pointerInput(revealPx, gesturesEnabled) {
// Selection mode turns reveals off; rows must not shift under the checkboxes.
if (!gesturesEnabled) return@pointerInput
detectHorizontalDragGestures(
onDragStart = { settleJob?.cancel() },
onHorizontalDrag = { change, dragAmount ->
@@ -0,0 +1,42 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import java.util.Locale
/**
* Grid sets abbreviated the way operators read them: a grid is shortened to its
* last two characters while its field (first two characters) stays the same as
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
* Used for the station grids a QSO was uploaded under (logbook rows and the
* radar log page) and for the opposite station's confirmed grid set.
*/
fun gridsLabel(grids: List<String>): String {
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
.filter { it.length >= 4 }.distinct().sorted()
if (clean.isEmpty()) return ""
return buildString {
append(clean.first())
for (index in 1 until clean.size) {
val grid = clean[index]
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
else append('/').append(grid)
}
}
}
@@ -0,0 +1,62 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
/**
* Pre-upload position check: the operator's current position grid falls outside the station
* location the prepared batch would be signed with — the fingerprint of operating while
* roaming with a station location that was never updated. The operator either jumps to the
* station location to fix it, or ignores the warning and proceeds to the normal preview.
*/
@Composable
fun LoTWPositionWarningDialog(
warning: LoTWPositionWarning,
onFixStation: () -> Unit,
onIgnore: () -> Unit
) {
AlertDialog(
onDismissRequest = onIgnore,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
text = {
Text(
text = stringResource(
R.string.lotw_upload_grid_mismatch,
warning.currentGrid,
gridsLabel(warning.stationGrids)
),
fontSize = 14.sp
)
},
confirmButton = {
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
},
dismissButton = {
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
}
)
}
@@ -197,6 +197,15 @@
<string name="prefs_logbook_upload">上传</string>
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
<string name="prefs_logbook_upload_confirm">确认上传</string>
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
<string name="prefs_logbook_selected">已选 %1$d 条</string>
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
@@ -406,6 +415,9 @@
<string name="qso_edit_comment">备注</string>
<string name="qso_edit_save">保存</string>
<string name="qso_edit_cancel">取消</string>
<string name="log_out_window_title">不在过境窗口</string>
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
<string name="log_out_window_confirm">录入</string>
<string name="qso_edit_err_callsign">呼号不能为空</string>
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
@@ -226,6 +226,15 @@
<string name="prefs_logbook_upload">Upload</string>
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
<string name="prefs_logbook_upload_confirm">Upload</string>
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</string>
<string name="prefs_logbook_selected">%1$d selected</string>
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
@@ -434,6 +443,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
<string name="qso_edit_comment">Comment</string>
<string name="qso_edit_save">Save</string>
<string name="qso_edit_cancel">Cancel</string>
<string name="log_out_window_title">Outside pass window</string>
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
<string name="log_out_window_confirm">Log it</string>
<string name="qso_edit_err_callsign">Callsign is required</string>
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.ve3nea.morse_expert.MainActivity
import kotlin.math.roundToInt
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("CW Settings") },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle("CW Settings") },
text = {
Column(
modifier = Modifier
@@ -93,10 +93,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.XYTileSource
@@ -676,7 +679,9 @@ private fun WorkedGridQsoDialog(
if (showMarkInput) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showMarkInput = false },
title = { Text(stringResource(R.string.grid_mark_station)) },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
text = {
androidx.compose.material3.OutlinedTextField(
value = callInput,
@@ -115,6 +115,7 @@ class FakeSettingsRepo(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -60,14 +62,18 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
import com.rtbishop.look4sat.core.presentation.EmptyListCard
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
@Composable
fun LogPage(
uiState: RadarState,
logViewModel: LogViewModel,
onFixGrid: (List<String>) -> Unit = {},
modifier: Modifier = Modifier
) {
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
@@ -100,6 +106,12 @@ fun LogPage(
if (catnum != 0) logViewModel.selectSatellite(catnum)
}
// Refresh the roaming hint whenever the page re-enters composition (e.g. returning
// from the station-location fix jump).
LaunchedEffect(Unit) {
logViewModel.refreshPositionHint()
}
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
val repeaterTxHz = voiceRepeater?.uplinkHz()
@@ -139,14 +151,28 @@ fun LogPage(
imeAction = ImeAction.Done
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
})
)
Button(
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
) { Text("Log") }
}
logUiState.stationMismatch?.let { mismatch ->
Text(
text = "⚠ " + stringResource(
R.string.lotw_upload_pos_hint,
mismatch.currentGrid,
gridsLabel(mismatch.stationGrids)
),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.clickable { onFixGrid(listOf(mismatch.currentGrid)) }
)
}
if (isLinear) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("CW", "SSB", "FT4").forEach { candidate ->
@@ -217,15 +243,29 @@ fun LogPage(
)
}
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
logUiState.uploadPositionWarning?.let { warning ->
LoTWPositionWarningDialog(
warning = warning,
onFixStation = {
logViewModel.abandonForGridFix()
onFixGrid(listOf(warning.currentGrid))
},
onIgnore = logViewModel::ignorePositionWarning
)
}
// The preview only opens once the position check is out of the way.
if (logUiState.uploadPositionWarning == null) {
logUiState.preview?.let { preview ->
UploadPreviewDialog(
preview = preview,
busy = logUiState.busy,
onConfirm = logViewModel::confirmUpload,
onDismiss = logViewModel::dismissPreview
)
}
}
if (logUiState.message.isNotBlank()) {
AlertDialog(
onDismissRequest = logViewModel::clearMessage,
@@ -239,6 +279,34 @@ fun LogPage(
)
}
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
// would never show in the window-filtered list. Confirm first, then store it at the pass
// midpoint (there is always an edit dialog afterwards for the exact time).
logUiState.outOfWindowLog?.let { pending ->
val midpointText = remember(pending.midpointUtcMillis) {
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(pending.midpointUtcMillis))
}
AlertDialog(
onDismissRequest = logViewModel::dismissOutOfWindowLog,
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
confirmButton = {
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
Text(stringResource(R.string.log_out_window_confirm))
}
},
dismissButton = {
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
editTarget?.let { target ->
QsoEditDialog(
record = target,
@@ -264,6 +332,8 @@ private fun LogRecordRow(
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(java.util.Date(record.startUtcMillis))
}
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
// 右划露出删除按钮(短信式);整行点击不再删除。
SwipeRevealRow(
key = record.id.toString(),
@@ -289,6 +359,14 @@ private fun LogRecordRow(
maxLines = 1
)
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
if (ownGrids.isNotBlank()) {
Text(
text = " $ownGrids",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
when {
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
@@ -25,8 +25,10 @@ import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -38,6 +40,23 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* A contact waiting for the operator to confirm logging while the clock is outside the
* current pass window (after LOS, before AOS, or against a pass card that is not the one
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
* so the window-filtered log page can show them; the exact time can be re-dated from the
* edit dialog afterwards.
*/
data class OutOfWindowLog(
val callsign: String,
val satName: String,
val mode: String,
val txHz: Long?,
val rxHz: Long?,
val myGrid: String,
val midpointUtcMillis: Long
)
data class LogUiState(
val callsignInput: String = "",
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
@@ -49,10 +68,18 @@ data class LogUiState(
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
val satelliteCatalog: List<String> = emptyList(),
val preview: LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val uploadPositionWarning: LoTWPositionWarning? = null,
/** Roaming hint on the log page: current position grid outside the station grids
* (null when covered or unknown). Tapping it jumps to the station location. */
val stationMismatch: LoTWPositionWarning? = null,
/** User-facing upload / record message ("" when none). */
val message: String = "",
val postText: String? = null,
val busy: Boolean = false
val busy: Boolean = false,
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
val outOfWindowLog: OutOfWindowLog? = null
)
class LogViewModel(
@@ -85,6 +112,10 @@ class LogViewModel(
)
}
}
// The roaming hint tracks the freshest position data: refresh on init and every fix.
viewModelScope.launch {
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
}
}
fun updateCallsign(value: String) =
@@ -101,19 +132,64 @@ class LogViewModel(
settingsRepo.setSatelliteMode(catnum, mode)
}
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
fun record(
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
passWindow: ClosedRange<Long>?
) {
val call = _uiState.value.callsignInput.trim()
if (call.isEmpty()) return
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
val now = System.currentTimeMillis() / 60_000L * 60_000L
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
// or against a pass card that is not the one being worked): the log page only shows
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
// operator first; the confirmed contact is stored at the pass midpoint (always inside
// the window) and can be re-dated later from the edit dialog.
if (passWindow != null && now !in passWindow) {
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
.let { it / 60_000L * 60_000L }
.coerceIn(passWindow.start, passWindow.endInclusive)
_uiState.update {
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
}
return
}
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
}
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
fun confirmOutOfWindowLog() {
val pending = _uiState.value.outOfWindowLog ?: return
storeRecord(
pending.callsign, pending.satName, pending.mode,
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
)
_uiState.update { it.copy(outOfWindowLog = null) }
}
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
private fun storeRecord(
call: String,
satName: String,
mode: String,
txHz: Long?,
rxHz: Long?,
myGrid: String,
timeMillis: Long
) {
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
val record = QsoRecord(
startUtcMillis = now,
endUtcMillis = now,
startUtcMillis = timeMillis,
endUtcMillis = timeMillis,
theirCallsign = call,
myCallsign = _uiState.value.certificateCallsign,
myGrid = myGrid.take(6).uppercase(Locale.US),
@@ -200,7 +276,13 @@ class LogViewModel(
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) }
_uiState.update {
it.copy(
busy = false,
preview = preview,
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
@@ -215,8 +297,9 @@ class LogViewModel(
_uiState.update { it.copy(busy = true) }
val result = lotwUploadRepository.upload(preview.id)
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
// Mark the submitted QSOs as uploaded (distinct from confirmed).
qsoRepository.markUploaded(lastUploadedIds)
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
// the station grids this batch went out under.
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
lastUploadedIds = emptyList()
}
val msg = when (result) {
@@ -231,6 +314,34 @@ class LogViewModel(
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
fun abandonForGridFix() {
lotwUploadRepository.discardPreview()
lastUploadedIds = emptyList()
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
}
/** Recompute the log-page roaming hint from the freshest station/position data. */
fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() }
private suspend fun refreshPositionHintNow() {
val station = runCatching { lotwUploadRepository.station() }.getOrNull()
if (station == null) {
_uiState.update { it.copy(stationMismatch = null) }
return
}
val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank)
_uiState.update {
it.copy(
stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids),
stationGrid = station.grid
)
}
}
fun clearMessage() = _uiState.update { it.copy(message = "") }
companion object {
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
}
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
viewModel.onAction(RadarAction.CwPermissionResult(granted))
}
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
onOpenLoTWStation()
}
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
})
}, onFixGrid = openLoTWStationForGridFix)
}
@Composable
@@ -228,7 +234,8 @@ private fun RadarScreen(
navigateUp: () -> Unit,
mutualData: MutualPassData,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit
) {
val upcomingPass = uiState.currentPass ?: getDefaultPass()
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
@@ -308,16 +315,17 @@ private fun RadarScreen(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = Modifier.weight(1f)
)
} else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
}
}
}
@@ -338,6 +346,7 @@ private fun RadarFillArea(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -351,7 +360,7 @@ private fun RadarFillArea(
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
@@ -361,6 +370,7 @@ private fun RadarFillArea(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
onFixGrid: (List<String>) -> Unit,
panelHeight: Dp,
modifier: Modifier = Modifier
) {
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
onFixGrid = onFixGrid,
modifier = modifier
.fillMaxWidth()
.height(panelHeight),
@@ -453,7 +465,8 @@ private fun PagerCard(
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier,
startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null
onCollapse: (() -> Unit)? = null,
onFixGrid: (List<String>) -> Unit = {}
) {
val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState(
@@ -515,7 +528,8 @@ private fun PagerCard(
)
RadarPage.Log -> LogPage(
uiState = uiState,
logViewModel = logViewModel
logViewModel = logViewModel,
onFixGrid = onFixGrid
)
RadarPage.Sstv -> SstvPage(
sstv = uiState.sstv,
@@ -9,7 +9,9 @@
*/
package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -26,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -33,6 +36,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
@@ -47,6 +52,8 @@ import com.rtbishop.look4sat.core.presentation.QsoEditDialog
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import com.rtbishop.look4sat.core.presentation.gridsLabel
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.SwipeController
@@ -82,24 +89,39 @@ fun LogbookDialog(
uploadBusy: Boolean,
uploadMessage: String,
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit,
onEdit: (QsoRecord) -> Unit,
onUpload: () -> Unit,
onConfirmUpload: () -> Unit,
onDismissPreview: () -> Unit,
onDismissMessage: () -> Unit
onDismissMessage: () -> Unit,
onIgnorePositionWarning: () -> Unit = {},
onFixGrid: (List<String>) -> Unit = {},
selectionMode: Boolean = false,
selectedIds: Set<Long> = emptySet(),
onStartSelection: (Long) -> Unit = {},
onToggleSelection: (Long) -> Unit = {},
onExitSelection: () -> Unit = {},
onResubmitSelected: () -> Unit = {}
) {
val swipeController = rememberSwipeController()
// Entering selection mode closes any row left swiped open — reveals are off there.
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
// Record currently open in the edit dialog (null when closed).
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
SharedDialog(
title = stringResource(R.string.prefs_logbook_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = onUpload,
acceptText = stringResource(R.string.prefs_logbook_upload),
acceptEnabled = !uploadBusy
onCancel = if (selectionMode) onExitSelection else onDismiss,
onAccept = if (selectionMode) onResubmitSelected else onUpload,
acceptText = if (selectionMode) {
stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
} else {
stringResource(R.string.prefs_logbook_upload)
},
acceptEnabled = if (selectionMode) selectedIds.isNotEmpty() && !uploadBusy else !uploadBusy
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
@@ -108,6 +130,21 @@ fun LogbookDialog(
if (records.isEmpty()) {
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
} else {
// Selection mode shows the checked count; otherwise a hint that long-press
// opens it (the path to resubmitting already-uploaded records).
if (selectionMode) {
Text(
text = stringResource(R.string.prefs_logbook_selected, selectedIds.size),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(
text = stringResource(R.string.prefs_logbook_select_hint),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -115,9 +152,16 @@ fun LogbookDialog(
items(records, key = { it.id }) { record ->
LogbookRow(
record = record,
selectionMode = selectionMode,
selected = record.id in selectedIds,
swipeController = swipeController,
onDelete = { onDelete(record.id) },
onClick = { editTarget = record }
onClick = {
if (selectionMode) onToggleSelection(record.id) else editTarget = record
},
onLongClick = {
if (selectionMode) onToggleSelection(record.id) else onStartSelection(record.id)
}
)
}
}
@@ -125,7 +169,15 @@ fun LogbookDialog(
}
}
if (preview != null) {
if (positionWarning != null) {
// Roaming guard: the current position sits outside the station location this batch
// would be signed with — confirm the station location before the preview opens.
LoTWPositionWarningDialog(
warning = positionWarning,
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
onIgnore = onIgnorePositionWarning
)
} else if (preview != null) {
LogbookUploadPreviewDialog(
preview = preview,
busy = uploadBusy,
@@ -194,6 +246,15 @@ private fun LogbookUploadPreviewDialog(
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
if (preview.resubmit) {
// LoTW treats an identical contact as an update, except grids already
// locked in by award credits — say so before the operator commits.
Text(
stringResource(R.string.prefs_logbook_resubmit_note),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -208,12 +269,19 @@ private fun LogbookUploadPreviewDialog(
)
}
/** Confirmed-grid colour: the map's worked-grid green, so "confirmed" reads the same everywhere. */
private val ConfirmedGreen = Color(0xFF4CD964)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LogbookRow(
record: QsoRecord,
selectionMode: Boolean,
selected: Boolean,
swipeController: SwipeController,
onDelete: () -> Unit,
onClick: () -> Unit
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val time = remember(record.startUtcMillis) {
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
@@ -222,11 +290,13 @@ private fun LogbookRow(
}
val satShort = record.satelliteName.substringBefore('(').trim()
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
// 右划露出删除按钮(短信式);整行点击不再删除。
val haptics = LocalHapticFeedback.current
// 右划露出删除按钮(短信式);整行点击不再删除。选择模式下右划禁用、长按进入多选。
SwipeRevealRow(
key = record.id.toString(),
controller = swipeController,
revealAction = onDelete,
gesturesEnabled = !selectionMode,
modifier = Modifier.fillMaxWidth()
) {
// Row style mirrors the worked-grid QSO details on the map: callsign in
@@ -234,10 +304,30 @@ private fun LogbookRow(
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.combinedClickable(
onClick = onClick,
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onLongClick()
}
)
.padding(horizontal = 4.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Checkbox slot: only while picking records for a resubmit.
if (selectionMode) {
Text(
text = if (selected) "✓" else "○",
fontSize = 15.sp,
fontFamily = FontFamily.Monospace,
color = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
modifier = Modifier.padding(end = 6.dp)
)
}
Text(
text = record.theirCallsign,
style = MaterialTheme.typography.titleMedium,
@@ -247,14 +337,30 @@ private fun LogbookRow(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// Confirmed contacts show the opposite station's grid set from the LoTW
// report (single or multi-grid, abbreviated) in the worked-grid green
// instead of the word QSL; without any grid the QSL text stays.
if (record.lotwConfirmed) {
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
val oppositeGrids = gridsLabel(
record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) }
)
if (oppositeGrids.isNotEmpty()) {
Text(text = oppositeGrids, fontSize = 13.sp, color = ConfirmedGreen, fontFamily = FontFamily.Monospace)
} else {
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
}
} else if (record.lotwUploaded) {
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
}
}
Text(
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
// Own station grid: the set the upload was stamped with, or — for rows from
// before the stamp existed — the grid the QSO was logged under, so every
// stored contact shows where the operator was.
text = listOf(
satShort, record.displayMode, band, time,
gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
).filter { it.isNotBlank() }.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -94,14 +94,29 @@ fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder)
// Station-grid prefill for the "fix it" jump from the logbook grid check (single-use,
// mirrors the Grid Finder's "Set as LoTW station" hand-off).
val pendingStationGrid by container.pendingLoTWStationGrid.collectAsStateWithLifecycle()
SettingsScreen(
uiState = uiState,
onAction = viewModel::onAction,
onOpenGridFinder = onOpenGridFinder,
pendingStationGrid = pendingStationGrid.orEmpty(),
onFixStationGrid = { grids ->
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
},
onClearStationGridPrefill = { container.setPendingLoTWStationGrid(null) }
)
}
@Composable
private fun SettingsScreen(
uiState: SettingsState,
onAction: (SettingsAction) -> Unit,
onOpenGridFinder: () -> Unit
onOpenGridFinder: () -> Unit,
pendingStationGrid: String = "",
onFixStationGrid: (List<String>) -> Unit = {},
onClearStationGridPrefill: () -> Unit = {}
) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) }
@@ -277,13 +292,33 @@ private fun SettingsScreen(
uploadBusy = uiState.logbookUploadBusy,
uploadMessage = uiState.logbookUploadMessage,
preview = uiState.logbookPreview,
onDismiss = { dialogs.logbook = false },
positionWarning = uiState.logbookPositionWarning,
onDismiss = {
// Leaving the logbook resets the resubmit selection: re-entering starts
// clean — the operator long-presses again to pick records.
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
},
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
onFixGrid = { grids ->
onAction(SettingsAction.AbandonLogbookForGridFix)
onAction(SettingsAction.ExitLogbookSelection)
dialogs.logbook = false
onFixStationGrid(grids)
dialogs.lotwUpload = true
},
selectionMode = uiState.logbookSelectionMode,
selectedIds = uiState.logbookSelectedIds,
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
)
}
if (dialogs.lotwUpload) {
@@ -294,7 +329,11 @@ private fun SettingsScreen(
busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError,
errorDetail = uiState.lotwUploadErrorDetail,
onDismiss = { dialogs.lotwUpload = false },
initialGrid = pendingStationGrid,
onDismiss = {
dialogs.lotwUpload = false
onClearStationGridPrefill()
},
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
@@ -85,9 +85,16 @@ data class SettingsState(
val lotwUploadErrorDetail: String = "",
/** One-click logbook upload: prepared preview awaiting confirmation. */
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
/** Roaming guard: the current position grid is outside the station grids the prepared
* batch would be signed with — shown as a dialog before the preview opens. */
val logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
val logbookUploadBusy: Boolean = false,
/** User-facing upload message shown inside the logbook dialog ("" = none). */
val logbookUploadMessage: String = "",
/** Logbook multi-select mode (entered by long-pressing a row); checked records can be
* re-uploaded so a corrected station location updates them on LoTW (resubmit). */
val logbookSelectionMode: Boolean = false,
val logbookSelectedIds: Set<Long> = emptySet(),
/** 指南针校准精度等级 (校准对话框进度条). */
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
@@ -160,6 +167,16 @@ sealed interface SettingsAction {
data object PrepareLogbookUpload : SettingsAction
data object ConfirmLogbookUpload : SettingsAction
data object DismissLogbookPreview : SettingsAction
/** The operator acknowledged the position mismatch and wants to upload anyway. */
data object IgnoreLogbookPositionWarning : SettingsAction
/** The operator chose to fix the station location first; the preview is discarded. */
data object AbandonLogbookForGridFix : SettingsAction
/** Long-press on a row: enter selection mode with that record checked. */
data class StartLogbookSelection(val id: Long) : SettingsAction
data class ToggleLogbookSelection(val id: Long) : SettingsAction
data object ExitLogbookSelection : SettingsAction
/** Re-upload the checked records — uploaded/confirmed rows included (resubmit). */
data object ResubmitSelectedLogbook : SettingsAction
data object ClearLogbookMessage : SettingsAction
// LoTW upload configuration (certificate + station)
@@ -34,10 +34,13 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
import com.rtbishop.look4sat.core.domain.repository.positionWarning
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
import com.rtbishop.look4sat.core.presentation.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -233,6 +236,12 @@ class SettingsViewModel(
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id)
SettingsAction.ExitLogbookSelection -> exitLogbookSelection()
SettingsAction.ResubmitSelectedLogbook -> resubmitSelectedLogbook()
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
// LoTW upload configuration
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
@@ -396,7 +405,13 @@ class SettingsViewModel(
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastLogbookUploadIds = preview.submittedIds
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
@@ -410,8 +425,9 @@ class SettingsViewModel(
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true) }
val result = lotwUploadRepository.upload(preview.id)
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds)
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
lastLogbookUploadIds = emptyList()
}
val msg = when (result) {
@@ -420,12 +436,87 @@ class SettingsViewModel(
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = null,
logbookUploadMessage = msg,
// An accepted resubmit is done — drop the checks; a failed one keeps
// them so retrying stays one tap away.
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
logbookSelectedIds = if (accepted) emptySet() else it.logbookSelectedIds
)
}
}
}
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
/** Operator chose "ignore" on the position check: keep the prepared preview. */
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
private fun abandonLogbookForGridFix() {
lotwUploadRepository.discardPreview()
lastLogbookUploadIds = emptyList()
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
}
/** Long-press entry: selection mode with the pressed record checked. */
private fun startLogbookSelection(id: Long) = _uiState.update {
it.copy(logbookSelectionMode = true, logbookSelectedIds = setOf(id))
}
private fun toggleLogbookSelection(id: Long) = _uiState.update {
val selection = it.logbookSelectedIds.toMutableSet().apply { if (!add(id)) remove(id) }
it.copy(logbookSelectedIds = selection)
}
private fun exitLogbookSelection() = _uiState.update {
it.copy(logbookSelectionMode = false, logbookSelectedIds = emptySet())
}
/**
* Re-upload the checked records — already-uploaded and confirmed rows included — so a
* corrected station location reaches LoTW and the server updates the existing contacts.
* The rest is the normal prepare → preview → upload cycle, resubmit flag included.
*/
private fun resubmitSelectedLogbook() {
if (_uiState.value.logbookUploadBusy) return
viewModelScope.launch {
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
try {
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
if (selected.isEmpty()) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
return@launch
}
val preview = lotwUploadRepository.prepare(selected, true)
if (preview.count == 0) {
// Every checked record proved un-signable — say why instead of opening
// a dead preview whose POST could only expire.
val message = unavailableUploadSummary(
preview.unavailableSkipped, preview.unavailableReasons, duplicates = preview.duplicateSkipped
).ifBlank { "No QSOs to resubmit" }
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = message) }
return@launch
}
lastLogbookUploadIds = preview.submittedIds
_uiState.update {
it.copy(
logbookUploadBusy = false,
logbookPreview = preview,
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
)
}
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
} catch (_: Exception) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
}
}
}
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
private fun loadLoTWUploadStatus() {
@@ -81,7 +81,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
import com.rtbishop.look4sat.core.presentation.layoutPadding
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
@@ -591,7 +594,9 @@ private fun AmSatUploadConfirmDialog(
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
shape = sheetDialogShape(),
containerColor = sheetDialogContainerColor(),
title = { SheetDialogTitle(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "549"
appVersionCode = "555"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.8-ba7opf.2.4"
appVersionName = "4.4.8-ba7opf.3"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry