feat: Maidenhead grid mode with worked-grid highlights from Wavelog (API v2 grid=all)

This commit is contained in:
atsunatsu committed 2026-09-07 01:42:42 +08:00
1 parent 7abbeb6eb3
commit 4e3547649f
22 files changed
+647 -21

No files matched your search

@@ -0,0 +1,204 @@
/*
* 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.feature.map
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import org.osmdroid.views.MapView
import org.osmdroid.views.Projection
import org.osmdroid.views.overlay.Overlay
import kotlin.math.ceil
import kotlin.math.floor
/**
* Maidenhead locator grid overlay, drawn directly on the map canvas.
*
* Zoom levels (map maxZoom = 7):
* - zoom < GRID_ZOOM_SUB: 10° x 20° fields with 2-character labels (e.g. "PM")
* - zoom >= GRID_ZOOM_SUB: 1° x 2° squares with 4-character labels (e.g. "PM95")
*
* All geometry is computed per-frame from the projection, so the overlay
* stays correct while panning/zooming. Grid lines that cross the antimeridian
* are drawn in segments clamped to the visible bounding box.
*/
class MaidenheadGridOverlay : Overlay() {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 1.5f
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 26f
style = android.graphics.Paint.Style.FILL
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
var ownGrid: String? = null
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow || !isEnabled) return
val projection = mapView.projection
val zoom = mapView.zoomLevelDouble
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
// Visible bounding box in geographic coordinates
val north = projection.fromPixels(0, 0)
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = north.latitude.coerceIn(-90.0, 90.0)
val bottomLat = south.latitude.coerceIn(-90.0, 90.0)
val leftLon = south.longitude
val rightLon = north.longitude
val firstRow = floor(bottomLat / cellLat).toInt()
val lastRow = ceil(topLat / cellLat).toInt()
// Longitude cell indices may exceed the [-180, 180) range when the view
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
// Worked-grid highlight fills (only meaningful at sub-square zoom)
if (workedGrids.isNotEmpty() && zoom >= GRID_ZOOM_SUB) {
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 yTop = projectionToY(projection, topLatCell)
val yBottom = projectionToY(projection, lat)
if (yTop == null || yBottom == null) continue
for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon) ?: continue
val xRight = projectionToX(projection, lon + cellLon) ?: continue
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
}
}
// Vertical lines (meridians)
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
}
// Horizontal lines (parallels)
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat <= -90.0 || lat >= 90.0) continue
val y = projectionToY(projection, lat)
if (y == null) continue
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)
val showLabels = when {
zoom >= GRID_ZOOM_SUB -> true
zoom >= FIELD_ZOOM_LABELS -> true
else -> false
}
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
val y1 = projectionToY(projection, 0.0)
val y2 = projectionToY(projection, 1.0)
if (y1 == null || y2 == null) return
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
val labelOffset = 8f
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
for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon) ?: continue
if (x < -200f || x > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, x + labelOffset, y + labelPaint.textSize + labelOffset, labelPaint)
}
}
}
/** X pixel for a longitude (meridians are vertical in Web Mercator). */
private fun projectionToX(projection: Projection, lon: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(0.0, lon)
val p = projection.toPixels(geo, null)
return if (p.x in -MAX_OVERSHOOT_PX..MAX_OVERSHOOT_PX + 4096) p.x.toFloat() else null
}
/** Y pixel for a latitude, or null when outside the viewport. */
private fun projectionToY(projection: Projection, lat: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
val p = projection.toPixels(geo, null)
return p.y.toFloat()
}
private fun cellLabel(lat: Double, lon: Double, zoom: Double): String {
// Maidenhead field: longitude 20° fields A-R starting at -180,
// latitude 10° fields A-R starting at -90.
val lonNorm = normalizeLon(lon)
val latNorm = lat.coerceIn(-90.0, 89.999)
val fieldLon = ((lonNorm + 180.0) / FIELD_LON).toInt()
val fieldLat = ((latNorm + 90.0) / FIELD_LAT).toInt()
val field = "${'A' + fieldLon}${'A' + fieldLat}"
if (zoom < GRID_ZOOM_SUB) return field
// Square: 2° x 1° digits
val squareLon = (((lonNorm + 180.0) % FIELD_LON) / SUB_SQUARE_LON).toInt()
val squareLat = (((latNorm + 90.0) % FIELD_LAT) / SUB_SQUARE_LAT).toInt()
return "$field$squareLon$squareLat"
}
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
if (l < -180.0) l += 360.0
return l
}
private companion object {
const val FIELD_LAT = 10.0
const val FIELD_LON = 20.0
const val SUB_SQUARE_LAT = 1.0
const val SUB_SQUARE_LON = 2.0
const val GRID_ZOOM_SUB = 5.0
const val FIELD_ZOOM_LABELS = 3.0
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
}
}
@@ -80,14 +80,15 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
// Overlay indices
private const val OVERLAY_STATION = 0
private const val OVERLAY_TRACK = 1
private const val OVERLAY_FOOTPRINT = 2
private const val OVERLAY_POSITIONS = 3
private const val OVERLAY_TERMINATOR = 4
private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7
private const val OVERLAY_GRID = 0
private const val OVERLAY_STATION = 1
private const val OVERLAY_TRACK = 2
private const val OVERLAY_FOOTPRINT = 3
private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_TERMINATOR = 5
private const val OVERLAY_SUN = 6
private const val OVERLAY_MOON = 7
private const val OVERLAY_COUNT = 8
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
@@ -159,13 +160,16 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { view ->
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
setGridMode(uiState.isGridMode, uiState.workedGrids, view)
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
}
view.invalidate()
}
uiState.mapData?.let { mapData ->
@@ -261,6 +265,30 @@ private fun MapDataCards(data: MapData) {
// endregion
// 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) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
if (gridOverlay is MaidenheadGridOverlay) {
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
} else {
mapView.overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply {
isEnabled = gridMode
this.workedGrids = workedGrids
}
}
// Satellite-related layers are hidden in grid mode; the grid overlay
// itself is controlled by its own enabled flag.
for (index in OVERLAY_STATION..OVERLAY_MOON) {
mapView.overlays.getOrNull(index)?.isEnabled = !gridMode
}
} catch (e: Exception) {
println(e)
}
}
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try {
val overlay = mapView.overlays[OVERLAY_STATION]
@@ -500,6 +528,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
}
}
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
@@ -26,6 +26,8 @@ data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
val workedGrids: Set<String> = emptySet(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -74,7 +74,12 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -51,6 +51,7 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -155,6 +156,69 @@ fun LocatorDialog(qthLocator: String, dismiss: () -> Unit, save: (String) -> Uni
}
}
@Composable
fun WavelogDialog(
initialSettings: com.rtbishop.look4sat.core.domain.model.WavelogSettings,
workedGridsCount: Int,
isSyncing: Boolean,
message: String?,
dismiss: () -> Unit,
onSave: (com.rtbishop.look4sat.core.domain.model.WavelogSettings) -> Unit,
onSync: () -> Unit
) {
val url = rememberSaveable { mutableStateOf(initialSettings.url) }
val token = rememberSaveable { mutableStateOf(initialSettings.token) }
SharedDialog(
title = stringResource(R.string.prefs_wavelog_title),
onCancel = dismiss,
onAccept = {
onSave(com.rtbishop.look4sat.core.domain.model.WavelogSettings(url.value, token.value))
dismiss()
}
) {
OutlinedTextField(
value = url.value,
onValueChange = { url.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_url)) },
placeholder = { Text(text = "http://192.168.1.10") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
OutlinedTextField(
value = token.value,
onValueChange = { token.value = it },
label = { Text(text = stringResource(id = R.string.prefs_wavelog_token)) },
placeholder = { Text(text = "wl2_...") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
)
Text(
text = stringResource(R.string.prefs_wavelog_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)
)
}
// Sync button row (inside dialog content so token must be saved first)
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_wavelog_syncing)
else stringResource(R.string.prefs_wavelog_sync))
}
}
Spacer(modifier = Modifier.height(0.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun TransceiversDialogPreview() {
@@ -63,6 +63,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
@@ -201,6 +203,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
)
}
if (dialogs.wavelog) {
WavelogDialog(
initialSettings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
isSyncing = uiState.wavelogSyncing,
message = uiState.wavelogMessage,
dismiss = { dialogs.wavelog = false },
onSave = { onAction(SettingsAction.UpdateWavelog(it)) },
onSync = { onAction(SettingsAction.SyncWorkedGrids) }
)
}
// URLs for top bar
val uriHandler = LocalUriHandler.current
@@ -305,6 +318,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onRadioControlClick = { dialogs.radioControl = true }
)
}
item {
WavelogCard(
settings = uiState.wavelogSettings,
workedGridsCount = uiState.workedGridsCount,
showWavelogDialog = { dialogs.wavelog = true }
)
}
item { OtherCard(uiState.otherSettings, onAction) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
@@ -489,6 +509,7 @@ private fun OtherCardPreview() = MainTheme {
stateOfUtc = false,
stateOfLightTheme = false,
stateOfNightMode = false,
stateOfMapGrid = false,
shouldSeeWarning = false,
shouldSeeWhatsNew = false
)
@@ -500,7 +521,7 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(268.dp)
.height(312.dp)
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
@@ -522,6 +543,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
onAction(SettingsAction.ToggleNightMode(it))
}
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
onAction(SettingsAction.ToggleMapGrid(it))
}
}
}
}
@@ -538,6 +562,44 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
}
}
@Preview(showBackground = true)
@Composable
private fun WavelogCardPreview() = MainTheme {
WavelogCard(settings = WavelogSettings("http://192.168.1.10", "wl2_demo"), workedGridsCount = 42) {}
}
@Composable
private fun WavelogCard(
settings: WavelogSettings,
workedGridsCount: Int,
showWavelogDialog: () -> Unit
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
Text(
text = stringResource(id = R.string.prefs_wavelog_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_wavelog_not_configured)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
CardButton(
onClick = showWavelogDialog,
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)
@@ -657,6 +719,7 @@ private class DialogVisibility {
var network by mutableStateOf(false)
var bluetooth by mutableStateOf(false)
var radioControl by mutableStateOf(false)
var wavelog by mutableStateOf(false)
}
@Composable
@@ -664,12 +727,12 @@ 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)
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog)
},
restore = {
DialogVisibility().apply {
position = it[0]; locator = it[1]; dataSources = it[2]
network = it[3]; bluetooth = it[4]; radioControl = it[5]
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
}
}
)
@@ -22,6 +22,7 @@ import com.rtbishop.look4sat.core.domain.model.LatestRelease
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import java.io.File
@@ -54,6 +55,10 @@ data class SettingsState(
val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val wavelogSettings: WavelogSettings = WavelogSettings(),
val workedGridsCount: Int = 0,
val wavelogSyncing: Boolean = false,
val wavelogMessage: String? = null,
val updateChecker: UpdateCheckerState = UpdateCheckerState()
)
@@ -77,6 +82,7 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class ToggleMapGrid(val value: Boolean) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -85,6 +91,10 @@ sealed interface SettingsAction {
// Data sources
data class UpdateDataSources(val settings: DataSourcesSettings) : SettingsAction
// Wavelog worked grids
data class UpdateWavelog(val settings: WavelogSettings) : SettingsAction
data object SyncWorkedGrids : SettingsAction
// Update checker
data object CheckForUpdate : SettingsAction
data object DownloadUpdate : SettingsAction
@@ -26,6 +26,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
import com.rtbishop.look4sat.core.presentation.R
@@ -39,6 +40,7 @@ class SettingsViewModel(
private val databaseRepo: IDatabaseRepo,
private val settingsRepo: ISettingsRepo,
private val updateRepo: IUpdateRepository,
private val wavelogRepo: IWavelogRepository,
private val apkFile: File,
private val showToast: IShowToast
) : ViewModel() {
@@ -54,7 +56,9 @@ class SettingsViewModel(
rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
wavelogSettings = settingsRepo.wavelogSettings.value,
workedGridsCount = settingsRepo.getWorkedGrids().size
)
)
@@ -107,6 +111,11 @@ class SettingsViewModel(
_uiState.update { it.copy(radioControlSettings = settings) }
}
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collect { settings ->
_uiState.update { it.copy(wavelogSettings = settings) }
}
}
}
@@ -133,10 +142,14 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
// Wavelog worked grids
is SettingsAction.UpdateWavelog -> settingsRepo.updateWavelogSettings(action.settings)
SettingsAction.SyncWorkedGrids -> syncWorkedGrids()
// Update checker
SettingsAction.CheckForUpdate -> checkForUpdate()
SettingsAction.DownloadUpdate -> downloadUpdate()
@@ -146,6 +159,30 @@ class SettingsViewModel(
}
}
// region Wavelog worked grids
private fun syncWorkedGrids() {
val settings = _uiState.value.wavelogSettings
if (!settings.isConfigured) {
_uiState.update { it.copy(wavelogMessage = "Wavelog URL/token not configured") }
return
}
_uiState.update { it.copy(wavelogSyncing = true, wavelogMessage = null) }
viewModelScope.launch {
val grids = wavelogRepo.fetchWorkedGrids(settings.url, settings.token)
_uiState.update {
if (grids != null) {
settingsRepo.setWorkedGrids(grids)
it.copy(wavelogSyncing = false, workedGridsCount = grids.size, wavelogMessage = null)
} else {
it.copy(wavelogSyncing = false, wavelogMessage = "Sync failed — check URL/token/network")
}
}
}
}
// endregion
// region Update checker
private fun checkForUpdate() {
@@ -267,6 +304,7 @@ class SettingsViewModel(
databaseRepo = container.databaseRepo,
settingsRepo = container.settingsRepo,
updateRepo = container.updateRepo,
wavelogRepo = container.wavelogRepo,
apkFile = File(context.cacheDir, "look4sat-update.apk"),
showToast = container.provideShowToast()
)