From be6473e6923832a7d3e3560297ebb67f25c5b61f Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 7 Sep 2026 06:55:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20LoTW=20confirmed-grids=20sync=20card=20?= =?UTF-8?q?=E2=80=94=20fetch=20confirmed=20QSO=20grids=20directly=20from?= =?UTF-8?q?=20ARRL=20LoTW=20(lotwreport.adi)=20and=20merge=20with=20Wavelo?= =?UTF-8?q?g=20grids;=20credentials=20stored=20locally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/injection/MainContainer.kt | 3 + .../core/data/repository/LoTWRepository.kt | 93 +++++++++++++++++++ .../core/data/repository/SettingsRepo.kt | 25 +++++ .../look4sat/core/domain/model/Settings.kt | 7 ++ .../core/domain/repository/ILoTWRepository.kt | 28 ++++++ .../core/domain/repository/IMainContainer.kt | 1 + .../core/domain/repository/ISettingsRepo.kt | 5 + .../src/main/res/values-zh/strings.xml | 7 ++ .../src/main/res/values/strings.xml | 7 ++ .../feature/settings/SettingsDialog.kt | 63 +++++++++++++ .../feature/settings/SettingsScreen.kt | 55 ++++++++++- .../feature/settings/SettingsState.kt | 7 ++ .../feature/settings/SettingsViewModel.kt | 32 +++++++ 13 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt 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 4e929576..8bc0a811 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 @@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo import com.rtbishop.look4sat.core.data.repository.SensorsRepo import com.rtbishop.look4sat.core.data.repository.SettingsRepo import com.rtbishop.look4sat.core.data.repository.UpdateRepository +import com.rtbishop.look4sat.core.data.repository.LoTWRepository import com.rtbishop.look4sat.core.data.repository.WavelogRepository import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.RemoteSource @@ -49,6 +50,7 @@ import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IReporter +import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo @@ -84,6 +86,7 @@ class MainContainer(private val context: Context) : IMainContainer { override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) } override val updateRepo by lazy { UpdateRepository(remoteSource) } override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() } + override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() } override val radioTrackingService: IRadioTrackingService by lazy { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) 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 new file mode 100644 index 00000000..bc84e79d --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LoTWRepository.kt @@ -0,0 +1,93 @@ +/* + * 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.data.repository + +import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint: + * GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=&password= + * &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince= + * + * Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the + * report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char + * value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are + * truncated/expanded to the 4-char form used by the map overlay. + * + * ARRL rate-limits the report endpoint (roughly once per hour per account), which is + * fine for a manual sync button. + */ +class LoTWRepository : ILoTWRepository { + + override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set? = + withContext(Dispatchers.IO) { + val call = callsign.trim().uppercase() + val pwd = password.trim() + if (call.isBlank() || pwd.isBlank()) return@withContext null + val query = buildString { + append("login=").append(URLEncoder.encode(call, "UTF-8")) + append("&password=").append(URLEncoder.encode(pwd, "UTF-8")) + append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes") + } + try { + val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection + connection.connectTimeout = 15_000 + connection.readTimeout = 60_000 + connection.requestMethod = "GET" + val code = connection.responseCode + if (code !in 200..299) { + connection.disconnect() + return@withContext null + } + val body = connection.inputStream.bufferedReader().use { it.readText() } + connection.disconnect() + if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null + parseConfirmedGrids(body) + } catch (e: Exception) { + println("LoTWRepository fetch failure: $e") + null + } + } + + internal fun parseConfirmedGrids(body: String): Set? { + // LoTW answers with ADIF text; on bad credentials it returns a short error page + // containing "password=?" or an -less block. Treat anything without a header + // marker as failure so the caller can show a sensible message. + if (!body.contains("")) return null + val grids = mutableSetOf() + for (raw in body.lineSequence()) { + val line = raw.trim() + val value = when { + line.startsWith(" line.substringAfter('>') + line.startsWith(" line.substringAfter('>') + else -> continue + }.substringBefore("E<").trim().uppercase() + if (value.length >= 4) grids.add(value.take(4)) + } + return grids + } + + private companion object { + const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi" + } +} 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 fb59a705..b9ed48c7 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 @@ -178,6 +178,31 @@ class SettingsRepo( grids.sorted().forEach { array.put(it) } preferences.edit { putString(keyWorkedGrids, array.toString()) } } + + // LoTW credentials (stored locally on the device only) + private val keyLoTWCall = "lotwCallsign" + private val keyLoTWPass = "lotwPassword" + + override val lotwSettings: kotlinx.coroutines.flow.StateFlow + get() = _lotwSettings + private val _lotwSettings = MutableStateFlow(getLoTWSettings()) + + override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) { + preferences.edit { + putString(keyLoTWCall, settings.callsign.trim().uppercase()) + putString(keyLoTWPass, settings.password) + } + _lotwSettings.value = settings.copy( + callsign = settings.callsign.trim().uppercase(), + password = settings.password + ) + } + + private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings = + com.rtbishop.look4sat.core.domain.model.LoTWSettings( + callsign = preferences.getString(keyLoTWCall, null).orEmpty(), + password = preferences.getString(keyLoTWPass, null).orEmpty() + ) //endregion //region # Transceivers settings diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt index 09101905..5bf9ef08 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt @@ -84,6 +84,13 @@ data class WavelogSettings( val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank() } +data class LoTWSettings( + val callsign: String = "", + val password: String = "" +) { + val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank() +} + data class RadioControlSettings( val enabled: Boolean, val radioModel: String, diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt new file mode 100644 index 00000000..b19d3ee5 --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILoTWRepository.kt @@ -0,0 +1,28 @@ +/* + * 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 + +/** Fetches confirmed gridsquares directly from ARRL LoTW. */ +interface ILoTWRepository { + + /** + * Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account. + * Returns the 4-char grid set, or null on any failure (network / bad credentials). + */ + suspend fun fetchConfirmedGrids(callsign: String, password: String): Set? +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 84544030..4e5d0a87 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -33,6 +33,7 @@ interface IMainContainer { val amSatRepo: IAmSatRepository val updateRepo: IUpdateRepository val wavelogRepo: IWavelogRepository + val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository val radioTrackingService: IRadioTrackingService val mutualPassData: StateFlow fun setMutualPassData(data: MutualPassData) 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 fc475e22..88446eea 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 @@ -100,4 +100,9 @@ interface ISettingsRepo { fun getWorkedGrids(): Set fun setWorkedGrids(grids: Set) //endregion + + //region # LoTW confirmed-grids settings + val lotwSettings: StateFlow + fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) + //endregion } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 07ba4397..0018d5c4 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -148,6 +148,13 @@ 已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。 立即同步 同步中… + LoTW 已确认网格 + LoTW 呼号 + LoTW 密码 + 直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。请先保存,再点同步。 + 立即同步 + 同步中… + 未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器 使用梅登黑德网格设置站点位置 卫星数据更新 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index fece922e..b3e56cbf 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -183,6 +183,13 @@ Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync. Sync now Syncing… + LoTW confirmed grids + LoTW callsign + LoTW password + Fetch confirmed-QSO grids directly from ARRL LoTW and merge them with Wavelog grids (currently %1$d). The password is stored on this device only. Save first, then tap Sync. + Sync now + Syncing… + Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed Set station\'s position using locator Satellite data diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt index 4ae91dd3..9282e763 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsDialog.kt @@ -219,6 +219,69 @@ fun WavelogDialog( } } +@Composable +fun LoTWDialog( + initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, + workedGridsCount: Int, + isSyncing: Boolean, + message: String?, + dismiss: () -> Unit, + onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit, + onSync: () -> Unit +) { + val call = rememberSaveable { mutableStateOf(initialSettings.callsign) } + val pass = rememberSaveable { mutableStateOf(initialSettings.password) } + SharedDialog( + title = stringResource(R.string.prefs_lotw_title), + onCancel = dismiss, + onAccept = { + onSave(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) + dismiss() + } + ) { + OutlinedTextField( + value = call.value, + onValueChange = { call.value = it.uppercase() }, + label = { Text(text = stringResource(id = R.string.prefs_lotw_callsign)) }, + placeholder = { Text(text = "BA7OPF") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), + ) + OutlinedTextField( + value = pass.value, + onValueChange = { pass.value = it }, + label = { Text(text = stringResource(id = R.string.prefs_lotw_password)) }, + placeholder = { Text(text = "********") }, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large), + ) + Text( + text = stringResource(R.string.prefs_lotw_hint, workedGridsCount), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = LocalSpacing.current.large) + ) + if (message != null) { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = LocalSpacing.current.large) + ) + } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large) + ) { + TextButton(onClick = onSync, enabled = !isSyncing) { + Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing) + else stringResource(R.string.prefs_lotw_sync)) + } + } + Spacer(modifier = Modifier.height(0.dp)) + } +} + @Preview(showBackground = true) @Composable private fun TransceiversDialogPreview() { 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 30f2efe5..e7d44fc4 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 @@ -215,6 +215,18 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> ) } + if (dialogs.lotw) { + LoTWDialog( + initialSettings = uiState.lotwSettings, + workedGridsCount = uiState.workedGridsCount, + isSyncing = uiState.lotwSyncing, + message = uiState.lotwMessage, + dismiss = { dialogs.lotw = false }, + onSave = { onAction(SettingsAction.UpdateLoTW(it)) }, + onSync = { onAction(SettingsAction.SyncLoTWGrids) } + ) + } + // URLs for top bar val uriHandler = LocalUriHandler.current val appUrl = stringResource(R.string.prefs_app_url) @@ -325,6 +337,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> showWavelogDialog = { dialogs.wavelog = true } ) } + item { + LoTWCard( + settings = uiState.lotwSettings, + workedGridsCount = uiState.workedGridsCount, + showLoTWDialog = { dialogs.lotw = true } + ) + } item { OtherCard(uiState.otherSettings, onAction) } item { CardCredits() } item(span = { GridItemSpan(maxLineSpan) }) { @@ -600,6 +619,38 @@ private fun WavelogCard( } } +@Composable +private fun LoTWCard( + settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings, + workedGridsCount: Int, + showLoTWDialog: () -> Unit +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + Text( + text = stringResource(id = R.string.prefs_lotw_title), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (settings.isConfigured) { + stringResource(R.string.prefs_wavelog_configured, workedGridsCount) + } else { + stringResource(R.string.prefs_lotw_not_configured) + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(4.dp)) + CardButton( + onClick = showLoTWDialog, + text = stringResource(id = R.string.prefs_wavelog_configure), + modifier = Modifier.fillMaxWidth() + ) + } + } +} + @Composable private fun formatUpdateTime(updateTime: Long): String { val timePattern = stringResource(id = R.string.prefs_updated_time) @@ -720,6 +771,7 @@ private class DialogVisibility { var bluetooth by mutableStateOf(false) var radioControl by mutableStateOf(false) var wavelog by mutableStateOf(false) + var lotw by mutableStateOf(false) } @Composable @@ -727,12 +779,13 @@ private fun rememberDialogVisibility(): DialogVisibility { return rememberSaveable(saver = run { androidx.compose.runtime.saveable.Saver( save = { - listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog) + listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw) }, restore = { DialogVisibility().apply { position = it[0]; locator = it[1]; dataSources = it[2] network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6] + lotw = it.getOrElse(7) { false } } } ) 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 8a04fbd9..bd9627ca 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 @@ -59,6 +59,9 @@ data class SettingsState( val workedGridsCount: Int = 0, val wavelogSyncing: Boolean = false, val wavelogMessage: String? = null, + val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(), + val lotwSyncing: Boolean = false, + val lotwMessage: String? = null, val updateChecker: UpdateCheckerState = UpdateCheckerState() ) @@ -95,6 +98,10 @@ sealed interface SettingsAction { data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction data object SyncWorkedGrids : SettingsAction + // LoTW confirmed grids + data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction + data object SyncLoTWGrids : SettingsAction + // Update checker data object CheckForUpdate : SettingsAction data object DownloadUpdate : SettingsAction 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 ae538799..23c856e7 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 @@ -41,6 +41,7 @@ class SettingsViewModel( private val settingsRepo: ISettingsRepo, private val updateRepo: IUpdateRepository, private val wavelogRepo: IWavelogRepository, + private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository, private val apkFile: File, private val showToast: IShowToast ) : ViewModel() { @@ -116,6 +117,11 @@ class SettingsViewModel( _uiState.update { it.copy(wavelogSettings = settings) } } } + viewModelScope.launch { + settingsRepo.lotwSettings.collect { settings -> + _uiState.update { it.copy(lotwSettings = settings) } + } + } } @@ -150,6 +156,9 @@ class SettingsViewModel( // Wavelog worked grids is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings) SettingsAction.SyncWorkedGrids -> syncWorkedGrids() + // LoTW confirmed grids + is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings) + SettingsAction.SyncLoTWGrids -> syncLoTWGrids() // Update checker SettingsAction.CheckForUpdate -> checkForUpdate() SettingsAction.DownloadUpdate -> downloadUpdate() @@ -181,6 +190,28 @@ class SettingsViewModel( } } + private fun syncLoTWGrids() { + val settings = _uiState.value.lotwSettings + if (!settings.isConfigured) { + _uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") } + return + } + _uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) } + viewModelScope.launch { + val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password) + _uiState.update { state -> + if (lotwGrids == null) { + state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network") + } else { + // Merge with Wavelog grids: union of both sources, all confirmed/worked + val merged = settingsRepo.getWorkedGrids() + lotwGrids + settingsRepo.setWorkedGrids(merged) + state.copy(lotwSyncing = false, workedGridsCount = merged.size, lotwMessage = null) + } + } + } + } + // endregion // region Update checker @@ -305,6 +336,7 @@ class SettingsViewModel( settingsRepo = container.settingsRepo, updateRepo = container.updateRepo, wavelogRepo = container.wavelogRepo, + lotwRepo = container.lotwRepo, apkFile = File(context.cacheDir, "look4sat-update.apk"), showToast = container.provideShowToast() )