feat(lotw): 漫游核验改用「当前定位 vs 证书台址」,移除记录网格核对

- 新增 LoTWPositionWarning + positionWarning(currentGrid, stationGrids):当前定位网格不在
  证书台址网格集内 → 提示(4 位归一;任一侧缺失不报)
- ISettingsRepo.getCurrentGrid():只读取「≤24h 系统最后已知定位」与「存储台址」中较新者,
  不写任何设置(避开 setStationPosition 的写副作用)
- 上传前核对弹窗(雷达 / 日志本两入口)改由 VM 在 prepare 后计算;雷达记录页新增提示行
  (stationMismatch,点击=跳转改台址并预填当前格)
- 移除旧「记录 vs 台址」核对:uploadGridWarning / LoTWGridWarning / preview.gridWarning 及
  相关 UI / 字符串全删;LoTWPositionWarningDialog 重命名替代 LoTWGridWarningDialog
- 测试:LoTWPositionWarningTest ×5 替代 LoTWGridWarningTest;domain 171 / data 116 / mutual 28 全绿
This commit is contained in:
atsunatsu committed 2026-10-02 13:31:13 +08:00
1 parent 2e704ee1d3
commit 56a788138b
19 files changed
+222 -158

No files matched your search

@@ -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(
@@ -468,6 +468,34 @@ class SettingsRepo(
return true
}
override fun getCurrentGrid(): String? {
val station = _stationPosition.value
val now = System.currentTimeMillis()
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
// station, so a stale fix can never override a position the operator just set.
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
return chosen.takeIf(String::isNotBlank)
}
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
private fun lastKnownGridFix(): Pair<String, Long>? {
return try {
val provider = when {
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
else -> return null
}
val location = locationManager.getLastKnownLocation(provider) ?: return null
val locator = positionToQth(location.latitude, location.longitude) ?: return null
locator to location.time
} catch (_: SecurityException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun getStationPosition(): GeoPos {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
@@ -271,6 +271,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
@@ -330,6 +330,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getCurrentGrid(): String? = null
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
types.flatMap { typeIds[it].orEmpty() }.distinct()
@@ -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<String> = 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<String>,
/** 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<String>
)
@@ -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<QsoRecord>, stationGrids: List<String>): LoTWGridWarning? {
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
val current = currentGrid?.grid4() ?: return null
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
if (station.isEmpty()) return null
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<String> =
(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 }
@@ -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
@@ -276,6 +276,7 @@ class LoTWGridSyncTest {
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -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<String> = 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)
}
}
@@ -0,0 +1,56 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class LoTWPositionWarningTest {
@Test
fun coveredPositionProducesNoWarning() {
assertNull(positionWarning("OL62aa", listOf("OL62")))
}
@Test
fun positionInsideAnyStationGridIsCovered() {
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
}
@Test
fun positionOutsideTheStationWarns() {
val warning = positionWarning("om91", listOf("OL62"))
assertEquals("OM91", warning?.currentGrid)
assertEquals(listOf("OL62"), warning?.stationGrids)
}
@Test
fun stationGridsAreNormalizedForComparison() {
assertNull(positionWarning("OL62", listOf("ol62aa")))
assertNull(positionWarning("ol62", listOf("OL62")))
}
@Test
fun unknownPositionOrStationProducesNoWarning() {
assertNull(positionWarning(null, listOf("OL62")))
assertNull(positionWarning("", listOf("OL62")))
assertNull(positionWarning("OM91", emptyList()))
assertNull(positionWarning("OM91", listOf("")))
}
}
@@ -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
@@ -204,7 +204,8 @@
<string name="lotw_upload_grid_title">上传前核对</string>
<string name="lotw_upload_grid_fix">修改台址</string>
<string name="lotw_upload_grid_ignore">忽略</string>
<string name="lotw_upload_grid_mismatch">有 %1$d 条记录的自己的网格不在上传台址内(记录:%2$s;台址:%3$s)——请确认台址是通联时实际所在的网格。</string>
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
@@ -233,7 +233,8 @@
<string name="lotw_upload_grid_title">Upload check</string>
<string name="lotw_upload_grid_fix">Fix station location</string>
<string name="lotw_upload_grid_ignore">Ignore</string>
<string name="lotw_upload_grid_mismatch">%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.</string>
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
@@ -115,6 +115,7 @@ class FakeSettingsRepo(
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
override fun setStationPosition(): Boolean = TODO()
override fun setStationPosition(locator: String): Boolean = TODO()
override fun getCurrentGrid(): String? = TODO()
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
override fun updateDatabaseState(state: DatabaseState) = TODO()
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -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,
@@ -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<String> = 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 = "") }
@@ -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<String>) -> Unit = {},
selectionMode: Boolean = false,
selectedIds: Set<Long> = 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(
@@ -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)
@@ -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. */
@@ -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}") }