From c7ca77a94317e06af0ce96f619a442e2c55af910 Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 7 Sep 2026 07:22:41 +0800 Subject: [PATCH] fix: sync dialogs use typed values directly (save+sync in one step); LoTW card wording; center map on station grid when entering grid mode; center grid labels in cells; update-check falls back to GitHub accelerator mirrors --- .../core/data/repository/UpdateRepository.kt | 50 ++++++++++++++----- .../src/main/res/values-zh/strings.xml | 5 +- .../src/main/res/values/strings.xml | 5 +- .../feature/map/MaidenheadGridOverlay.kt | 21 +++++--- .../look4sat/feature/map/MapScreen.kt | 30 ++++++++++- .../look4sat/feature/map/MapViewModel.kt | 1 + .../feature/settings/SettingsDialog.kt | 19 +++++-- .../feature/settings/SettingsScreen.kt | 6 +-- .../feature/settings/SettingsState.kt | 4 +- .../feature/settings/SettingsViewModel.kt | 15 +++--- 10 files changed, 113 insertions(+), 43 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt index 2408a83f..18e6dfb1 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/UpdateRepository.kt @@ -16,15 +16,21 @@ class UpdateRepository( // the API endpoint is rate-limited to 60 requests/hour per IP, which is // quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403 // failures. The web endpoint redirects to the latest tag with no such limit. - val result = remoteSource.getNetworkStream(LATEST_RELEASE_URL) - val stream = result.stream ?: return@withContext null - try { - val html = stream.bufferedReader().use { it.readText() } - parseRelease(html) - } catch (e: Exception) { - println("UpdateRepository parse failure: $e") - null + // When GitHub is unreachable (common on mainland-China networks without a + // proxy), fall back to community GitHub accelerator mirrors that proxy the + // same page; each mirror resolves the identical tag and asset URLs. + for (baseUrl in LATEST_RELEASE_URLS) { + val result = remoteSource.getNetworkStream(baseUrl) + val stream = result.stream ?: continue + val parsed = try { + parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl) + } catch (e: Exception) { + println("UpdateRepository parse failure: $e") + null + } + if (parsed != null) return@withContext parsed } + null } override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) { @@ -40,7 +46,7 @@ class UpdateRepository( } } - private fun parseRelease(html: String): LatestRelease? { + private fun parseRelease(html: String, sourceUrl: String): LatestRelease? { // The redirect target is the latest release's tag page. Extract the tag // from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and // unambiguous. The also holds the release name, but that is free @@ -53,8 +59,16 @@ class UpdateRepository( .find(html)?.groupValues?.get(1)?.trim() ?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag // The release APK asset follows the fixed naming scheme used by the build: - // Look4Sat-<tag without leading v>-release.apk - val apkUrl = "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk" + // Look4Sat-<tag without leading v>-release.apk. When the page was fetched + // through an accelerator mirror (https://<mirror>/https://github.com/...), + // download through the same mirror — raw github.com is unreachable on the + // networks that needed the mirror in the first place. + val mirrorPrefix = sourceUrl.substringBefore("https://github.com") + val apkUrl = if (mirrorPrefix.isEmpty()) { + "$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk" + } else { + "${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk" + } return LatestRelease( versionTag = tag, title = title, @@ -65,8 +79,18 @@ class UpdateRepository( private companion object { // Web pages are used instead of api.github.com to avoid the 60 req/hour - // anonymous rate limit (see getLatestRelease above). - const val LATEST_RELEASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/latest" + // anonymous rate limit (see getLatestRelease above). The GitHub URL is + // tried first; if it is unreachable (no proxy on mainland networks) the + // accelerator mirrors are tried in order. Download URLs always point at + // the mirrors too — raw github.com release downloads are equally blocked + // without a proxy, so a mirror-resolved tag must be downloaded via the + // same mirror. + val LATEST_RELEASE_URLS = listOf( + "https://github.com/atsunatsu/Look4Sat/releases/latest", + "https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest", + "https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest", + "https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest" + ) const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download" } } diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 0018d5c4..63554612 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -145,15 +145,16 @@ <string name="prefs_wavelog_not_configured">未配置 — 连接你的 Wavelog 日志以在地图上高亮已通联网格</string> <string name="prefs_wavelog_url">Wavelog 地址</string> <string name="prefs_wavelog_token">API key :日志 ID</string> - <string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。请先保存,再点同步。</string> + <string name="prefs_wavelog_hint">已同步网格数:%1$d。格式:API key:日志ID。填好直接点同步,会自动保存并拉取。</string> <string name="prefs_wavelog_sync">立即同步</string> <string name="prefs_wavelog_syncing">同步中…</string> <string name="prefs_lotw_title">LoTW 已确认网格</string> <string name="prefs_lotw_callsign">LoTW 呼号</string> <string name="prefs_lotw_password">LoTW 密码</string> - <string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。请先保存,再点同步。</string> + <string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取已确认 QSO 的网格并与 Wavelog 网格合并(当前 %1$d 个)。密码仅保存在手机本地。填好直接点同步,会自动保存并拉取。</string> <string name="prefs_lotw_sync">立即同步</string> <string name="prefs_lotw_syncing">同步中…</string> + <string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string> <string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string> <string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string> diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index b3e56cbf..2f08f8f8 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -180,15 +180,16 @@ <string name="prefs_wavelog_not_configured">Not configured — connect your Wavelog logbook to highlight worked grids on the map</string> <string name="prefs_wavelog_url">Wavelog URL</string> <string name="prefs_wavelog_token">API key : logbook ID</string> - <string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Save first, then tap Sync.</string> + <string name="prefs_wavelog_hint">Worked grids synced: %1$d. Format: API key, colon, logbook ID. Just tap Sync — credentials are saved automatically.</string> <string name="prefs_wavelog_sync">Sync now</string> <string name="prefs_wavelog_syncing">Syncing…</string> <string name="prefs_lotw_title">LoTW confirmed grids</string> <string name="prefs_lotw_callsign">LoTW callsign</string> <string name="prefs_lotw_password">LoTW password</string> - <string name="prefs_lotw_hint">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.</string> + <string name="prefs_lotw_hint">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. Just tap Sync — credentials are saved automatically.</string> <string name="prefs_lotw_sync">Sync now</string> <string name="prefs_lotw_syncing">Syncing…</string> + <string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string> <string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string> <string name="prefs_locator_text">Set station\'s position using locator</string> diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt index 89f05070..d0616d61 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MaidenheadGridOverlay.kt @@ -124,8 +124,8 @@ class MaidenheadGridOverlay : Overlay() { canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint) } - // Labels: place at the top-left corner of each cell, only when the cell - // is large enough on screen to hold a label (avoid clutter at low zoom) + // Labels: centered in each cell, only when the cell is large enough on + // screen to hold a label (avoid clutter at low zoom) val showLabels = when { zoom >= GRID_ZOOM_SUB -> true zoom >= FIELD_ZOOM_LABELS -> true @@ -140,20 +140,25 @@ class MaidenheadGridOverlay : Overlay() { val pixelsPerDegree = Math.abs(y2 - y1) if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return - val labelOffset = 8f + labelPaint.textAlign = Paint.Align.CENTER + val fontMetrics = labelPaint.fontMetrics + val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f for (row in firstRow..lastRow) { val lat = row * cellLat if (lat < -90.0 || lat >= 90.0) continue val topLatCell = lat + cellLat if (topLatCell > 90.0) continue - val y = projectionToY(projection, topLatCell) ?: continue - if (y < -labelPaint.textSize || y > canvas.height) continue + val yTop = projectionToY(projection, topLatCell) ?: continue + val yBottom = projectionToY(projection, lat) ?: continue + val yCenter = (yTop + yBottom) / 2f - textHalfHeight + if (yBottom < 0f || yTop > canvas.height) continue for (col in firstCol..lastCol) { val lon = col * cellLon - val x = projectionToX(projection, lon, centerLon) ?: continue - if (x < -200f || x > canvas.width) continue + val xLeft = projectionToX(projection, lon, centerLon) ?: continue + val xRight = projectionToX(projection, lon + cellLon, centerLon) ?: continue + if (xRight < 0f || xLeft > canvas.width) continue val label = cellLabel(lat, lon, zoom) - canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint) + canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint) } } } diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 0fb51824..18e0328f 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -42,7 +42,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -159,8 +161,15 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: } ElevatedCard(modifier = Modifier.weight(1f)) { Box(contentAlignment = Alignment.BottomCenter) { + // Track grid-mode transitions so the map only re-centers when the + // mode is switched ON, not on every recomposition. + var prevGridMode by remember { mutableStateOf(uiState.isGridMode) } AndroidView({ mapView }) { view -> - setGridMode(uiState.isGridMode, uiState.workedGrids, view) + setGridMode( + uiState.isGridMode, uiState.workedGrids, view, + if (uiState.isGridMode && !prevGridMode) uiState.stationPosition else null + ) + prevGridMode = uiState.isGridMode if (!uiState.isGridMode) { uiState.stationPosition?.let { setStationPosition(it, view) } uiState.track?.let { setSatelliteTrack(it, view) } @@ -269,7 +278,12 @@ private fun MapDataCards(data: MapData) { // region Map overlay helpers /** Toggle the grid-mode layer visibility on/off without recreating any overlay. */ -private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: MapView) { +private fun setGridMode( + gridMode: Boolean, + workedGrids: Set<String>, + mapView: MapView, + stationPosition: GeoPos? +) { try { val gridOverlay = mapView.overlays[OVERLAY_GRID] if (gridOverlay is MaidenheadGridOverlay) { @@ -286,6 +300,18 @@ private fun setGridMode(gridMode: Boolean, workedGrids: Set<String>, mapView: Ma for (index in OVERLAY_STATION..OVERLAY_MOON) { mapView.overlays.getOrNull(index)?.isEnabled = !gridMode } + // Entering grid mode: center on the station's current grid square, + // keeping the current zoom level unchanged. + if (gridMode) { + val pos = stationPosition ?: return + val lat = pos.latitude + val lon = pos.longitude + val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17) + val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17) + val centerLat = fieldLat * 10.0 + 5.0 + val centerLon = fieldLon * 20.0 + 10.0 + mapView.controller.setCenter(GeoPoint(centerLat, centerLon)) + } } catch (e: Exception) { println(e) } diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt index dba1dd67..6532b36e 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt @@ -84,6 +84,7 @@ class MapViewModel( } val (selectedCatNum, _) = satelliteRepo.selectedPass.value selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1) + getStationPosition() } fun onAction(action: MapAction) { 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 9282e763..abc1abba 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 @@ -164,7 +164,7 @@ fun WavelogDialog( message: String?, dismiss: () -> Unit, onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit, - onSync: () -> Unit + onSync: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit ) { val url = rememberSaveable { mutableStateOf(initialSettings.url) } val token = rememberSaveable { mutableStateOf(initialSettings.token) } @@ -205,12 +205,16 @@ fun WavelogDialog( modifier = Modifier.padding(horizontal = LocalSpacing.current.large) ) } - // Sync button row (inside dialog content so token must be saved first) + // Sync uses the values typed in the fields directly — saving and syncing + // happen in one step, no need to close and reopen the dialog. Row( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large) ) { - TextButton(onClick = onSync, enabled = !isSyncing) { + TextButton( + onClick = { onSync(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value)) }, + enabled = !isSyncing + ) { Text(text = if (isSyncing) stringResource(R.string.prefs_wavelog_syncing) else stringResource(R.string.prefs_wavelog_sync)) } @@ -227,7 +231,7 @@ fun LoTWDialog( message: String?, dismiss: () -> Unit, onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit, - onSync: () -> Unit + onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit ) { val call = rememberSaveable { mutableStateOf(initialSettings.callsign) } val pass = rememberSaveable { mutableStateOf(initialSettings.password) } @@ -269,11 +273,16 @@ fun LoTWDialog( modifier = Modifier.padding(horizontal = LocalSpacing.current.large) ) } + // Sync uses the values typed in the fields directly — saving and syncing + // happen in one step, no need to close and reopen the dialog. Row( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large) ) { - TextButton(onClick = onSync, enabled = !isSyncing) { + TextButton( + onClick = { onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) }, + enabled = !isSyncing + ) { Text(text = if (isSyncing) stringResource(R.string.prefs_lotw_syncing) else stringResource(R.string.prefs_lotw_sync)) } 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 e7d44fc4..7f3be694 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 @@ -211,7 +211,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> message = uiState.wavelogMessage, dismiss = { dialogs.wavelog = false }, onSave = { onAction(SettingsAction.UpdateWavelog(it)) }, - onSync = { onAction(SettingsAction.SyncWorkedGrids) } + onSync = { onAction(SettingsAction.SyncWorkedGrids(it)) } ) } @@ -223,7 +223,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> message = uiState.lotwMessage, dismiss = { dialogs.lotw = false }, onSave = { onAction(SettingsAction.UpdateLoTW(it)) }, - onSync = { onAction(SettingsAction.SyncLoTWGrids) } + onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) } ) } @@ -634,7 +634,7 @@ private fun LoTWCard( Spacer(modifier = Modifier.height(2.dp)) Text( text = if (settings.isConfigured) { - stringResource(R.string.prefs_wavelog_configured, workedGridsCount) + stringResource(R.string.prefs_lotw_configured, workedGridsCount) } else { stringResource(R.string.prefs_lotw_not_configured) }, 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 bd9627ca..7f680274 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 @@ -96,11 +96,11 @@ sealed interface SettingsAction { // Wavelog worked grids data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction - data object SyncWorkedGrids : SettingsAction + data class SyncWorkedGrids(val settings: WavelogSettings) : SettingsAction // LoTW confirmed grids data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction - data object SyncLoTWGrids : SettingsAction + data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction // Update checker data object CheckForUpdate : 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 23c856e7..619172c7 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 @@ -23,6 +23,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo +import com.rtbishop.look4sat.core.domain.model.WavelogSettings import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository @@ -155,10 +156,10 @@ class SettingsViewModel( is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings) // Wavelog worked grids is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings) - SettingsAction.SyncWorkedGrids -> syncWorkedGrids() + is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings) // LoTW confirmed grids is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings) - SettingsAction.SyncLoTWGrids -> syncLoTWGrids() + is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings) // Update checker SettingsAction.CheckForUpdate -> checkForUpdate() SettingsAction.DownloadUpdate -> downloadUpdate() @@ -170,12 +171,13 @@ class SettingsViewModel( // region Wavelog worked grids - private fun syncWorkedGrids() { - val settings = _uiState.value.wavelogSettings + private fun syncWorkedGrids(settings: WavelogSettings) { if (!settings.isConfigured) { _uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") } return } + // Persist the credentials first, then sync with the freshly-typed values. + settingsRepo.updateWavelogSettings(settings) _uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) } viewModelScope.launch { val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token) @@ -190,12 +192,13 @@ class SettingsViewModel( } } - private fun syncLoTWGrids() { - val settings = _uiState.value.lotwSettings + private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) { if (!settings.isConfigured) { _uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") } return } + // Persist the credentials first, then sync with the freshly-typed values. + settingsRepo.updateLoTWSettings(settings) _uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) } viewModelScope.launch { val lotwGrids = lotwRepo.fetchConfirmedGrids(settings.callsign, settings.password)