diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index c9162c0a..6d005623 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -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 { diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt index 6ef15938..0cd8a961 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/QsoDatabase.kt @@ -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 ''") + } +} diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt index be6140a2..574304c7 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/database/entity/QsoEntity.kt @@ -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, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 4fa8b74c..850a037e 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -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) } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt index a6e54f08..1d108d6a 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWSigner.kt @@ -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() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt index 42c606ba..701e6e4c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/lotw/LoTWUploadRepository.kt @@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.domain.repository.LoTWStation import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview +import com.rtbishop.look4sat.core.domain.repository.uploadGridWarning import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive @@ -279,6 +280,16 @@ 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() + // Roaming guard: flag records whose own grids fall outside the station grids this + // batch would be signed with (a station location never updated while roaming). + val gridWarning = uploadGridWarning(contacts.map { it.record }, locationGrids) val preview = LoTWUploadPreview( UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"), contacts.size, skipped, @@ -289,7 +300,10 @@ class LoTWUploadRepository internal constructor( unavailable, reasons.toMap(), duplicates, - contacts.map { it.record.id } + contacts.map { it.record.id }, + locationGrids, + gridWarning, + resubmit ) if (contacts.isNotEmpty()) pending = Pending( preview, diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt index 2c5354aa..d27561a0 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -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) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt index 63f46619..fff4357f 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/QsoRepository.kt @@ -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) = withContext(dispatcher) { + override suspend fun markUploaded(ids: List, grids: List) = 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?, 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().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, diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt index 7001a956..ac3d466e 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/LoTWRepositoryTest.kt @@ -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 = "SAT\nFO-29\nNL47\n\n" + assertEquals( + listOf("NL47"), + repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids + ) + val pair = "SAT\nSO-50\nEN52en,EN53fa\n\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 = "SAT\nSO-50\n" + + "OM60IL\nEN52en,EN53fa\n\n" + assertEquals( + listOf("OM60", "EN52", "EN53"), + repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids + ) + } + @Test fun parseQsosRejectsBodyWithoutEoh() { assertNull(repo.parseConfirmedGridQsos("Username/password incorrect")) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt index 989547d6..eda9c4ce 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/QsoRepositoryTest.kt @@ -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, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt index ec81c0c1..6e6e7672 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/IQsoRepository.kt @@ -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) + /** + * 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, grids: List = emptyList()) suspend fun exportAdi(ids: Set? = null, includeIncomplete: Boolean = false): String suspend fun importAdi(content: String): AdifImportResult suspend fun mergeConfirmed(records: List): AdifImportResult diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt index 8e3af0c5..dd15a504 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWReportBridge.kt @@ -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, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt index 7ca9d509..8206553e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LoTWUploadMessages.kt @@ -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" diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmit.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmit.kt new file mode 100644 index 00000000..2e434789 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmit.kt @@ -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, selectedIds: Set): List = + records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt index ebce6de2..533fbf26 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoConfirmationMerge.kt @@ -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): List { - val pendingIndices = records.indices.filter { !records[it].lotwConfirmed } val taken = mutableSetOf() - return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex -> + val pairs = mutableListOf() + // 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()) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt index 7472229d..19c0da09 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/logbook/QsoModels.kt @@ -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 + * + ; 1–4 grids). [theirGrid] keeps the first; + * the logbook's QSL slot shows the abbreviated set. */ + val theirVuccGrids: List = emptyList(), val myGrid: String = "", val sentReport: String = "", val receivedReport: String = "", diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt index 4ccc56a0..a57d3eeb 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/GridQso.kt @@ -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 + * plus every field of (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 = emptyList() ) { /** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */ val bandLabel: String diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt index 005c3881..5699e75a 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWUploadRepository.kt @@ -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,26 @@ 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 = emptyList() + val submittedIds: List = 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 = emptyList(), + /** Records whose own grids fall outside this batch's station location — the fingerprint + * of uploading while roaming with a stale station location. Null when all covered. */ + val gridWarning: LoTWGridWarning? = null, + /** 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 grid audit result: [count] records carry own grids outside the station grids. */ +data class LoTWGridWarning( + val count: Int, + /** Distinct own grids of the affected records (normalized, 4 characters). */ + val recordGrids: List, + /** Distinct station-location grids this batch would be signed with. */ + val stationGrids: List ) /** Why the un-uploadable records of a selection cannot be signed, and what was left out. */ @@ -128,7 +148,36 @@ 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 } + +/** + * Records whose own grids (the set a previous upload stamped, else the logged grid) do not + * appear at all in the station-location grids an upload goes out under — the signature of + * uploading while roaming with a station location that was never updated. Records without + * any grid are not flagged, and a partially covered set (boundary operations) is not + * flagged either: only a fully disjoint pair, which is always a mistake, warns. + */ +fun uploadGridWarning(records: List, stationGrids: List): LoTWGridWarning? { + val station = stationGrids.mapNotNull { it.grid4() }.toSet() + if (station.isEmpty()) return null + val affected = records.filter { record -> + val own = record.ownGrids() + own.isNotEmpty() && own.none { it in station } + } + if (affected.isEmpty()) return null + return LoTWGridWarning( + affected.size, + affected.flatMap { it.ownGrids() }.distinct().sorted(), + station.sorted() + ) +} + +/** The grid set a previous upload stamped onto the record, else the grid it was logged under. */ +private fun QsoRecord.ownGrids(): Set = + (vuccGrids.ifEmpty { listOf(myGrid) }).mapNotNull { it.grid4() }.toSet() + +/** 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 } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt index bb120a45..96301092 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookMergeTest.kt @@ -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) diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmitTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmitTest.kt new file mode 100644 index 00000000..4226a67f --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/logbook/LogbookResubmitTest.kt @@ -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(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id }) + } +} diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridWarningTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridWarningTest.kt new file mode 100644 index 00000000..875b88d0 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridWarningTest.kt @@ -0,0 +1,77 @@ +/* + * 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.repository + +import com.rtbishop.look4sat.core.domain.logbook.QsoRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LoTWGridWarningTest { + + private fun record(myGrid: String, vuccGrids: List = emptyList()) = QsoRecord( + startUtcMillis = 1_700_000_000_000L, + theirCallsign = "BG5JSB", + myCallsign = "BA7OPF", + myGrid = myGrid, + vuccGrids = vuccGrids, + mode = "FM", + satelliteName = "SO-50", + propagationMode = "SAT" + ) + + @Test + fun matchingGridsProduceNoWarning() { + assertNull(uploadGridWarning(listOf(record("OM91")), listOf("OM91"))) + } + + @Test + fun recordsLoggedOutsideTheStationAreCounted() { + val warning = uploadGridWarning( + listOf(record("OM91"), record("OM91"), record("OL62")), + listOf("OL62") + ) + assertEquals(2, warning?.count) + assertEquals(listOf("OM91"), warning?.recordGrids) + assertEquals(listOf("OL62"), warning?.stationGrids) + } + + @Test + fun boundaryStationCoversItsRecords() { + val warning = uploadGridWarning( + listOf(record("OL62", listOf("OL61", "OL62"))), + listOf("OL61", "OL62", "OM60", "OM61") + ) + assertNull(warning) + } + + @Test + fun partialOverlapIsNotFlagged() { + // The record's set touches the station set — a boundary-bookkeeping difference, + // not the roaming signature. + assertNull(uploadGridWarning(listOf(record("OL62", listOf("OL62", "OL63"))), listOf("OL62"))) + } + + @Test + fun recordsWithoutGridsOrStationAreNotFlagged() { + assertNull(uploadGridWarning(listOf(record("")), listOf("OL62"))) + assertNull(uploadGridWarning(listOf(record("OM91")), emptyList())) + } + + @Test + fun stampedGridSetIsComparedInsteadOfTheLoggedGrid() { + val warning = uploadGridWarning( + listOf(record("OL62", listOf("OM91", "OM92", "PM01", "PM02"))), + listOf("OL62") + ) + assertEquals(1, warning?.count) + assertEquals(listOf("OM91", "OM92", "PM01", "PM02"), warning?.recordGrids) + } +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt index 106c71ca..3d47ed80 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Components.kt @@ -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 -> diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridLabels.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridLabels.kt new file mode 100644 index 00000000..df5ff73e --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridLabels.kt @@ -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 . + */ +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 { + 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) + } + } +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWGridWarningDialog.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWGridWarningDialog.kt new file mode 100644 index 00000000..67fcfbb6 --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWGridWarningDialog.kt @@ -0,0 +1,63 @@ +/* + * 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 . + */ +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.LoTWGridWarning + +/** + * Pre-upload grid check: the prepared batch holds records whose own grids fall outside the + * station-location grids they would be signed with — the fingerprint of uploading 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 LoTWGridWarningDialog( + warning: LoTWGridWarning, + 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.count, + gridsLabel(warning.recordGrids), + 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)) } + } + ) +} diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 8d7d8b99..f879e18c 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -197,6 +197,14 @@ 上传 上传到 LoTW 确认上传 + 重传选中 (%1$d) + 已选 %1$d 条 + 长按记录行可多选重传 + 已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。 + 上传前核对 + 修改台址 + 忽略 + 有 %1$d 条记录的自己的网格不在上传台址内(记录:%2$s;台址:%3$s)——请确认台址是通联时实际所在的网格。 LoTW 上传证书 证书已导入 · 台址网格 %1$s 未配置 — 导入 TrustedQSL 证书后即可上传通联 @@ -406,6 +414,9 @@ 备注 保存 取消 + 不在过境窗口 + 当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。 + 录入 呼号不能为空 日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC) 频率格式不对 —— 请输入 MHz,例如 145.850 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 4ccd4016..76818ac6 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -226,6 +226,14 @@ Upload Upload to LoTW Upload + Resubmit (%1$d) + %1$d selected + Long-press a row to re-upload corrected records + Existing contacts will be updated in place — a grid already used for an award may be rejected. + Upload check + Fix station location + Ignore + %1$d record(s) log a grid outside the upload station location (records: %2$s; station: %3$s). Verify the station location is where you actually operated. LoTW upload certificate Certificate imported · station grid %1$s Not configured — import your TrustedQSL certificate to upload QSOs @@ -434,6 +442,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat Comment Save Cancel + Outside pass window + 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. + Log it Callsign is required Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC) Invalid frequency — enter MHz, e.g. 145.850 diff --git a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt index ce817298..3d53e78d 100644 --- a/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt +++ b/feature/cw/src/main/java/com/rtbishop/look4sat/feature/cw/CwSettingsDialog.kt @@ -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 diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 0035c3bf..68446f0a 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -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, diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt index b64096f8..8dfaf1f1 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogPage.kt @@ -40,6 +40,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 +61,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.LoTWGridWarningDialog @Composable fun LogPage( uiState: RadarState, logViewModel: LogViewModel, + onFixGrid: (List) -> Unit = {}, modifier: Modifier = Modifier ) { val logUiState by logViewModel.uiState.collectAsStateWithLifecycle() @@ -139,11 +144,11 @@ 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") } } @@ -217,15 +222,29 @@ fun LogPage( ) } - logUiState.preview?.let { preview -> - UploadPreviewDialog( - preview = preview, - busy = logUiState.busy, - onConfirm = logViewModel::confirmUpload, - onDismiss = logViewModel::dismissPreview + logUiState.uploadGridWarning?.let { warning -> + LoTWGridWarningDialog( + warning = warning, + onFixStation = { + logViewModel.abandonForGridFix() + onFixGrid(warning.recordGrids) + }, + onIgnore = logViewModel::ignoreGridWarning ) } + // The preview only opens once the grid check is out of the way. + if (logUiState.uploadGridWarning == 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 +258,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 +311,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 +338,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) diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt index 93e6d03e..1fb2151c 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogViewModel.kt @@ -26,6 +26,7 @@ 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.LoTWUploadPreview +import com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +39,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 +67,15 @@ data class LogUiState( /** ARRL satellite names (config.tq6) — the only names a record may be signed with. */ val satelliteCatalog: List = emptyList(), val preview: LoTWUploadPreview? = null, + /** Roaming guard: the prepared batch holds records whose own grids fall outside the + * station-location grids — shown as a dialog before the preview opens. */ + val uploadGridWarning: LoTWGridWarning? = 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( @@ -101,19 +124,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? + ) { 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 +268,7 @@ 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, uploadGridWarning = preview.gridWarning) } } catch (e: LoTWOperationException) { _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } } catch (_: Exception) { @@ -215,8 +283,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 +300,16 @@ class LogViewModel( fun dismissPreview() = _uiState.update { it.copy(preview = null) } + /** Operator chose "ignore" on the grid check: keep the prepared preview. */ + fun ignoreGridWarning() = _uiState.update { it.copy(uploadGridWarning = 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(uploadGridWarning = null, preview = null) } + } + fun clearMessage() = _uiState.update { it.copy(message = "") } companion object { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 484a1fcf..5315c0ef 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -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) -> 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) -> 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) -> 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) -> 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) -> 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, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt index 24816127..33c5611f 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LogbookDialog.kt @@ -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.LoTWGridWarningDialog 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?, + gridWarning: com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning? = null, onDismiss: () -> Unit, onDelete: (Long) -> Unit, onEdit: (QsoRecord) -> Unit, onUpload: () -> Unit, onConfirmUpload: () -> Unit, onDismissPreview: () -> Unit, - onDismissMessage: () -> Unit + onDismissMessage: () -> Unit, + onIgnoreGridWarning: () -> Unit = {}, + onFixGrid: (List) -> Unit = {}, + selectionMode: Boolean = false, + selectedIds: Set = 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(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 (gridWarning != null) { + // Roaming guard: the batch would be signed under a station grid it was not operated + // from — confirm the station location before the preview opens. + LoTWGridWarningDialog( + warning = gridWarning, + onFixStation = { onFixGrid(gridWarning.recordGrids) }, + onIgnore = onIgnoreGridWarning + ) + } 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, diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index 220f7224..0b53e4d8 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -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) -> 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 }, + gridWarning = uiState.logbookGridWarning, + 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) }, + onIgnoreGridWarning = { onAction(SettingsAction.IgnoreLogbookGridWarning) }, + 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)) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt index 46086d80..2ab11894 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt @@ -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 prepared batch holds records whose own grids fall outside the + * station-location grids — shown as a dialog before the preview opens. */ + val logbookGridWarning: com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning? = 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 = 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 grid mismatch and wants to upload anyway. */ + data object IgnoreLogbookGridWarning : 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) diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt index 70904fcb..c02e5483 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt @@ -37,7 +37,9 @@ import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode 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 +235,12 @@ class SettingsViewModel( SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() + SettingsAction.IgnoreLogbookGridWarning -> ignoreLogbookGridWarning() + 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 +404,7 @@ 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, logbookGridWarning = preview.gridWarning) } } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") } } catch (_: Exception) { @@ -410,8 +418,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 +429,83 @@ 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 grid check: keep the prepared preview. */ + private fun ignoreLogbookGridWarning() = _uiState.update { it.copy(logbookGridWarning = 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(logbookGridWarning = 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, logbookGridWarning = preview.gridWarning) + } + } 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() { diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index 98bbcf15..62975ddb 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -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(