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 701e6e4c..1960fe52 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,7 +30,6 @@ 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 @@ -287,9 +286,6 @@ class LoTWUploadRepository internal constructor( 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, @@ -302,7 +298,6 @@ class LoTWUploadRepository internal constructor( duplicates, contacts.map { it.record.id }, locationGrids, - gridWarning, resubmit ) if (contacts.isNotEmpty()) pending = Pending( diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt index 930282db..afa8b5e3 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt @@ -468,6 +468,34 @@ class SettingsRepo( return true } + override fun getCurrentGrid(): String? { + val station = _stationPosition.value + val now = System.currentTimeMillis() + // Only trust a location fix that is both recent (≤ 24 h) and newer than the stored + // station, so a stale fix can never override a position the operator just set. + val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L } + val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator + return chosen.takeIf(String::isNotBlank) + } + + /** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */ + private fun lastKnownGridFix(): Pair? { + return try { + val provider = when { + LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps + LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet + else -> return null + } + val location = locationManager.getLastKnownLocation(provider) ?: return null + val locator = positionToQth(location.latitude, location.longitude) ?: return null + locator to location.time + } catch (_: SecurityException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + private fun getStationPosition(): GeoPos { val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble() val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble() diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index a3ae662d..e6b2833f 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -271,6 +271,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou override fun setStationPosition(locator: String): Boolean = true + override fun getCurrentGrid(): String? = null + override fun getSatelliteTypesIds(types: List): List = emptyList() override fun setSatelliteTypeIds(type: String, ids: List) { diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt index 2474c62d..65668211 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/SelectionRepoSearchTest.kt @@ -330,6 +330,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo { override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true override fun setStationPosition(): Boolean = true override fun setStationPosition(locator: String): Boolean = true + override fun getCurrentGrid(): String? = null override fun getSatelliteTypesIds(types: List): List = types.flatMap { typeIds[it].orEmpty() }.distinct() 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 5699e75a..555c23da 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 @@ -102,20 +102,17 @@ data class LoTWUploadPreview( * 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. */ +/** Pre-upload location check: the freshest known position grid is not covered by the + * station grids an upload would be signed with. */ +data class LoTWPositionWarning( + /** Normalized 4-character grid of the freshest known position. */ + val currentGrid: String, + /** Distinct station-location grids the upload would be signed with. */ val stationGrids: List ) @@ -154,30 +151,19 @@ enum class LoTWProblem { } /** - * 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. + * Roaming check run before upload: warns when the operator's freshest known position + * (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the + * batch would be signed with — the signature of operating while roaming with a station + * location that was never updated. An unknown position, missing station grids, or a covered + * position (boundary operations included) produce no warning. */ -fun uploadGridWarning(records: List, stationGrids: List): LoTWGridWarning? { +fun positionWarning(currentGrid: String?, stationGrids: List): LoTWPositionWarning? { + val current = currentGrid?.grid4() ?: return null 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() - ) + if (current in station) return null + return LoTWPositionWarning(current, 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/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt index 4d2388da..c06824eb 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISettingsRepo.kt @@ -48,6 +48,12 @@ interface ISettingsRepo { fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean fun setStationPosition(): Boolean fun setStationPosition(locator: String): Boolean + /** + * Freshest known position grid for the roaming check, read-only: the newer of a fresh + * (≤ 24 h) system location fix and the stored station position. Null only when neither + * yields a usable locator. Never writes the station position. + */ + fun getCurrentGrid(): String? //endregion //region # Database update settings diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt index 4edb0409..5eb7c672 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridSyncTest.kt @@ -276,6 +276,7 @@ class LoTWGridSyncTest { override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO() + override fun getCurrentGrid(): String? = TODO() override fun getSatelliteTypesIds(types: List): List = TODO() override fun setSatelliteTypeIds(type: String, ids: List) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO() 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 deleted file mode 100644 index 875b88d0..00000000 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWGridWarningTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWPositionWarningTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWPositionWarningTest.kt new file mode 100644 index 00000000..d74bf505 --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/repository/LoTWPositionWarningTest.kt @@ -0,0 +1,56 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.domain.repository + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class LoTWPositionWarningTest { + + @Test + fun coveredPositionProducesNoWarning() { + assertNull(positionWarning("OL62aa", listOf("OL62"))) + } + + @Test + fun positionInsideAnyStationGridIsCovered() { + assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61"))) + } + + @Test + fun positionOutsideTheStationWarns() { + val warning = positionWarning("om91", listOf("OL62")) + assertEquals("OM91", warning?.currentGrid) + assertEquals(listOf("OL62"), warning?.stationGrids) + } + + @Test + fun stationGridsAreNormalizedForComparison() { + assertNull(positionWarning("OL62", listOf("ol62aa"))) + assertNull(positionWarning("ol62", listOf("OL62"))) + } + + @Test + fun unknownPositionOrStationProducesNoWarning() { + assertNull(positionWarning(null, listOf("OL62"))) + assertNull(positionWarning("", listOf("OL62"))) + assertNull(positionWarning("OM91", emptyList())) + assertNull(positionWarning("OM91", listOf(""))) + } +} 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/LoTWPositionWarningDialog.kt similarity index 84% rename from core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWGridWarningDialog.kt rename to core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWPositionWarningDialog.kt index 67fcfbb6..557f231e 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWGridWarningDialog.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/LoTWPositionWarningDialog.kt @@ -23,17 +23,17 @@ 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 +import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning /** - * 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 + * Pre-upload position check: the operator's current position grid falls outside the station + * location the prepared batch would be signed with — the fingerprint of operating while * roaming with a station location that was never updated. The operator either jumps to the * station location to fix it, or ignores the warning and proceeds to the normal preview. */ @Composable -fun LoTWGridWarningDialog( - warning: LoTWGridWarning, +fun LoTWPositionWarningDialog( + warning: LoTWPositionWarning, onFixStation: () -> Unit, onIgnore: () -> Unit ) { @@ -46,8 +46,7 @@ fun LoTWGridWarningDialog( Text( text = stringResource( R.string.lotw_upload_grid_mismatch, - warning.count, - gridsLabel(warning.recordGrids), + warning.currentGrid, gridsLabel(warning.stationGrids) ), fontSize = 14.sp diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index f879e18c..3b688448 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -204,7 +204,8 @@ 上传前核对 修改台址 忽略 - 有 %1$d 条记录的自己的网格不在上传台址内(记录:%2$s;台址:%3$s)——请确认台址是通联时实际所在的网格。 + 当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。 + 当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新 LoTW 上传证书 证书已导入 · 台址网格 %1$s 未配置 — 导入 TrustedQSL 证书后即可上传通联 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 76818ac6..7a8c391e 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -233,7 +233,8 @@ 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. + Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading. + Current position %1$s is outside the station grids %2$s — tap to update LoTW upload certificate Certificate imported · station grid %1$s Not configured — import your TrustedQSL certificate to upload QSOs diff --git a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt index 857e5bcb..7171525b 100644 --- a/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt +++ b/feature/mutual/src/test/java/com/rtbishop/look4sat/feature/mutual/Fakes.kt @@ -115,6 +115,7 @@ class FakeSettingsRepo( override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO() override fun setStationPosition(): Boolean = TODO() override fun setStationPosition(locator: String): Boolean = TODO() + override fun getCurrentGrid(): String? = TODO() override fun getSatelliteTypesIds(types: List): List = TODO() override fun setSatelliteTypeIds(type: String, ids: List) = TODO() override fun updateDatabaseState(state: DatabaseState) = TODO() 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 8dfaf1f1..20b43933 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -66,7 +67,7 @@ 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.LoTWPositionWarningDialog @Composable fun LogPage( @@ -105,6 +106,12 @@ fun LogPage( if (catnum != 0) logViewModel.selectSatellite(catnum) } + // Refresh the roaming hint whenever the page re-enters composition (e.g. returning + // from the station-location fix jump). + LaunchedEffect(Unit) { + logViewModel.refreshPositionHint() + } + val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() } val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() } val repeaterTxHz = voiceRepeater?.uplinkHz() @@ -152,6 +159,20 @@ fun LogPage( enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank() ) { Text("Log") } } + logUiState.stationMismatch?.let { mismatch -> + Text( + text = "⚠ " + stringResource( + R.string.lotw_upload_pos_hint, + mismatch.currentGrid, + gridsLabel(mismatch.stationGrids) + ), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .clickable { onFixGrid(listOf(mismatch.currentGrid)) } + ) + } if (isLinear) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { listOf("CW", "SSB", "FT4").forEach { candidate -> @@ -222,19 +243,19 @@ fun LogPage( ) } - logUiState.uploadGridWarning?.let { warning -> - LoTWGridWarningDialog( + logUiState.uploadPositionWarning?.let { warning -> + LoTWPositionWarningDialog( warning = warning, onFixStation = { logViewModel.abandonForGridFix() - onFixGrid(warning.recordGrids) + onFixGrid(listOf(warning.currentGrid)) }, - onIgnore = logViewModel::ignoreGridWarning + onIgnore = logViewModel::ignorePositionWarning ) } - // The preview only opens once the grid check is out of the way. - if (logUiState.uploadGridWarning == null) { + // The preview only opens once the position check is out of the way. + if (logUiState.uploadPositionWarning == null) { logUiState.preview?.let { preview -> UploadPreviewDialog( preview = preview, 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 1fb2151c..5bd230a1 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 @@ -25,9 +25,10 @@ import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException +import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview -import com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult +import com.rtbishop.look4sat.core.domain.repository.positionWarning import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,9 +68,12 @@ 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, + /** Roaming guard: the current position grid is outside the station grids the prepared + * batch would be signed with — shown as a dialog before the preview opens. */ + val uploadPositionWarning: LoTWPositionWarning? = null, + /** Roaming hint on the log page: current position grid outside the station grids + * (null when covered or unknown). Tapping it jumps to the station location. */ + val stationMismatch: LoTWPositionWarning? = null, /** User-facing upload / record message ("" when none). */ val message: String = "", val postText: String? = null, @@ -108,6 +112,10 @@ class LogViewModel( ) } } + // The roaming hint tracks the freshest position data: refresh on init and every fix. + viewModelScope.launch { + settingsRepo.stationPosition.collect { refreshPositionHintNow() } + } } fun updateCallsign(value: String) = @@ -268,7 +276,13 @@ class LogViewModel( // Only the records that actually made it into the TQ8 may be // marked uploaded later — never the whole candidate list. lastUploadedIds = preview.submittedIds - _uiState.update { it.copy(busy = false, preview = preview, uploadGridWarning = preview.gridWarning) } + _uiState.update { + it.copy( + busy = false, + preview = preview, + uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) + ) + } } catch (e: LoTWOperationException) { _uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") } } catch (_: Exception) { @@ -300,14 +314,32 @@ 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 "ignore" on the position check: keep the prepared preview. */ + fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) } /** Operator chose to fix the station grid first: drop the prepared preview and leave. */ fun abandonForGridFix() { lotwUploadRepository.discardPreview() lastUploadedIds = emptyList() - _uiState.update { it.copy(uploadGridWarning = null, preview = null) } + _uiState.update { it.copy(uploadPositionWarning = null, preview = null) } + } + + /** Recompute the log-page roaming hint from the freshest station/position data. */ + fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() } + + private suspend fun refreshPositionHintNow() { + val station = runCatching { lotwUploadRepository.station() }.getOrNull() + if (station == null) { + _uiState.update { it.copy(stationMismatch = null) } + return + } + val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank) + _uiState.update { + it.copy( + stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids), + stationGrid = station.grid + ) + } } fun clearMessage() = _uiState.update { it.copy(message = "") } 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 33c5611f..de35353d 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 @@ -53,7 +53,7 @@ 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.LoTWPositionWarningDialog import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.SharedDialog import com.rtbishop.look4sat.core.presentation.SwipeController @@ -89,7 +89,7 @@ fun LogbookDialog( uploadBusy: Boolean, uploadMessage: String, preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?, - gridWarning: com.rtbishop.look4sat.core.domain.repository.LoTWGridWarning? = null, + positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null, onDismiss: () -> Unit, onDelete: (Long) -> Unit, onEdit: (QsoRecord) -> Unit, @@ -97,7 +97,7 @@ fun LogbookDialog( onConfirmUpload: () -> Unit, onDismissPreview: () -> Unit, onDismissMessage: () -> Unit, - onIgnoreGridWarning: () -> Unit = {}, + onIgnorePositionWarning: () -> Unit = {}, onFixGrid: (List) -> Unit = {}, selectionMode: Boolean = false, selectedIds: Set = emptySet(), @@ -169,13 +169,13 @@ fun LogbookDialog( } } - 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 + if (positionWarning != null) { + // Roaming guard: the current position sits outside the station location this batch + // would be signed with — confirm the station location before the preview opens. + LoTWPositionWarningDialog( + warning = positionWarning, + onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) }, + onIgnore = onIgnorePositionWarning ) } else if (preview != null) { LogbookUploadPreviewDialog( 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 0b53e4d8..5c2d2300 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 @@ -292,7 +292,7 @@ private fun SettingsScreen( uploadBusy = uiState.logbookUploadBusy, uploadMessage = uiState.logbookUploadMessage, preview = uiState.logbookPreview, - gridWarning = uiState.logbookGridWarning, + positionWarning = uiState.logbookPositionWarning, onDismiss = { // Leaving the logbook resets the resubmit selection: re-entering starts // clean — the operator long-presses again to pick records. @@ -305,7 +305,7 @@ private fun SettingsScreen( onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) }, onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) }, onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }, - onIgnoreGridWarning = { onAction(SettingsAction.IgnoreLogbookGridWarning) }, + onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) }, onFixGrid = { grids -> onAction(SettingsAction.AbandonLogbookForGridFix) onAction(SettingsAction.ExitLogbookSelection) 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 2ab11894..b206d4ba 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,9 @@ 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, + /** Roaming guard: the current position grid is outside the station grids the prepared + * batch would be signed with — shown as a dialog before the preview opens. */ + val logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null, val logbookUploadBusy: Boolean = false, /** User-facing upload message shown inside the logbook dialog ("" = none). */ val logbookUploadMessage: String = "", @@ -167,8 +167,8 @@ 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 acknowledged the position mismatch and wants to upload anyway. */ + data object IgnoreLogbookPositionWarning : SettingsAction /** The operator chose to fix the station location first; the preview is discarded. */ data object AbandonLogbookForGridFix : SettingsAction /** Long-press on a row: enter selection mode with that record checked. */ 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 c02e5483..035d3152 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 @@ -34,6 +34,7 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode +import com.rtbishop.look4sat.core.domain.repository.positionWarning import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository import com.rtbishop.look4sat.core.domain.usecase.IShowToast import com.rtbishop.look4sat.core.domain.utility.VersionComparator @@ -235,7 +236,7 @@ class SettingsViewModel( SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload() SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload() SettingsAction.DismissLogbookPreview -> dismissLogbookPreview() - SettingsAction.IgnoreLogbookGridWarning -> ignoreLogbookGridWarning() + SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning() SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix() is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id) is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id) @@ -404,7 +405,13 @@ class SettingsViewModel( // Only the records that actually made it into the TQ8 may be // marked uploaded later — never the whole candidate list. lastLogbookUploadIds = preview.submittedIds - _uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview, logbookGridWarning = preview.gridWarning) } + _uiState.update { + it.copy( + logbookUploadBusy = false, + logbookPreview = preview, + logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) + ) + } } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") } } catch (_: Exception) { @@ -445,14 +452,14 @@ class SettingsViewModel( 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 "ignore" on the position check: keep the prepared preview. */ + private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) } /** Operator chose to fix the station grid first: drop the prepared preview and leave. */ private fun abandonLogbookForGridFix() { lotwUploadRepository.discardPreview() lastLogbookUploadIds = emptyList() - _uiState.update { it.copy(logbookGridWarning = null, logbookPreview = null) } + _uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) } } /** Long-press entry: selection mode with the pressed record checked. */ @@ -496,7 +503,11 @@ class SettingsViewModel( } lastLogbookUploadIds = preview.submittedIds _uiState.update { - it.copy(logbookUploadBusy = false, logbookPreview = preview, logbookGridWarning = preview.gridWarning) + it.copy( + logbookUploadBusy = false, + logbookPreview = preview, + logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids) + ) } } catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) { _uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }