Merge remote-tracking branch 'fork/main'

This commit is contained in:
atsunatsu committed 2026-09-25 14:50:56 +08:00
commit 7081ffaa60
13 files changed
+371 -25

No files matched your search

@@ -85,6 +85,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.model.AwardCalculator
import com.rtbishop.look4sat.core.domain.model.AwardProgress
import com.rtbishop.look4sat.core.domain.model.AwardType
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -96,31 +97,40 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.TilesOverlay
import java.net.URLEncoder
// Overlay indices
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 const val OVERLAY_TDT_LABELS = 0
private const val OVERLAY_GRID = 1
private const val OVERLAY_STATION = 2
private const val OVERLAY_TRACK = 3
private const val OVERLAY_FOOTPRINT = 4
private const val OVERLAY_POSITIONS = 5
private const val OVERLAY_TERMINATOR = 6
private const val OVERLAY_SUN = 7
private const val OVERLAY_MOON = 8
private const val OVERLAY_COUNT = 9
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
private const val OFFLINE_MAX_ZOOM = 7.0
private const val TIANDITU_MAX_ZOOM = 18.0
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f
style = Paint.Style.STROKE
@@ -209,6 +219,8 @@ private fun MapScreen(
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu else R.string.map_copyright_osm
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page.
@@ -308,6 +320,9 @@ private fun MapScreen(
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout()
if (isGridMode) {
@@ -390,7 +405,8 @@ private fun MapScreen(
}
uiState.mapData?.let { mapData ->
if (!uiState.isGridMode) {
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
if (isVertical) MapDataCard(mapData, copyrightResId)
else MapDataCards(mapData, copyrightResId)
}
}
// Top-left: 台址 selector for VUCC counting (grid mode only).
@@ -769,11 +785,11 @@ private fun formatTime(epochMs: Long, isUtc: Boolean): String {
// region Map data composables
@Composable
private fun MapDataCard(data: MapData) {
private fun MapDataCard(data: MapData, copyrightResId: Int) {
val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
MapDataRow(
@@ -811,7 +827,7 @@ private fun MapDataRow(
}
@Composable
private fun MapDataCards(data: MapData) {
private fun MapDataCards(data: MapData, copyrightResId: Int) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -838,7 +854,7 @@ private fun MapDataCards(data: MapData) {
}
}
Text(
text = stringResource(R.string.map_copyright),
text = stringResource(copyrightResId),
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -1073,6 +1089,100 @@ private fun FirstCallToggle(
// endregion
// region Tile sources
private var configuredTileMapView: MapView? = null
private var configuredTileSource: String? = null
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
val key = tiandituKey.trim()
val layers = tiandituLayers(mapSource)
val sourceKey = if (layers == null || key.isBlank()) MapSource.OSM else "${layers.base}:$key"
if (configuredTileMapView === mapView && configuredTileSource == sourceKey) return
configuredTileMapView = mapView
configuredTileSource = sourceKey
(mapView.overlays.getOrNull(OVERLAY_TDT_LABELS) as? TilesOverlay)?.onDetach(mapView)
if (layers == null || key.isBlank()) {
mapView.setUseDataConnection(false)
mapView.setTileSource(offlineTileSource)
mapView.maxZoomLevel = OFFLINE_MAX_ZOOM
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(createColorFilter())
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
requestTileRefresh(mapView)
return
}
mapView.setUseDataConnection(true)
mapView.setTileSource(TiandituTileSource(layer = layers.base, key = key))
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(null)
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
mapView.context
).apply {
applyTileOverlayDefaults(null)
setUseDataConnection(true)
}
requestTileRefresh(mapView)
}
private data class TiandituLayers(val base: String, val label: String)
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
else -> null
}
private fun TilesOverlay.applyTileOverlayDefaults(colorFilter: ColorMatrixColorFilter?) {
setColorFilter(colorFilter)
setLoadingBackgroundColor(Color.TRANSPARENT)
setLoadingLineColor(Color.TRANSPARENT)
}
private fun requestTileRefresh(mapView: MapView) {
mapView.post {
mapView.requestLayout()
mapView.invalidate()
}
}
private fun buildTiandituWmtsUrl(
baseUrl: String,
layer: String,
zoom: Int,
row: Int,
col: Int,
encodedKey: String
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
private class TiandituTileSource(
private val layer: String,
key: String
) : OnlineTileSourceBase(
"tianditu-wmts-https-$layer",
1,
TIANDITU_MAX_ZOOM.toInt(),
256,
".png",
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
"Tianditu"
) {
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
override fun getTileURLString(pMapTileIndex: Long): String = buildTiandituWmtsUrl(
baseUrl = getBaseUrl(),
layer = layer,
zoom = MapTileIndex.getZoom(pMapTileIndex),
row = MapTileIndex.getY(pMapTileIndex),
col = MapTileIndex.getX(pMapTileIndex),
encodedKey = encodedKey
)
}
// endregion
// region Map overlay helpers
/**
@@ -1491,7 +1601,6 @@ private fun rememberMapViewWithLifecycle(
mapFilterViewModel: MapFilterViewModel,
restoreViewport: Boolean
): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current
val isVertical = isVerticalLayout()
val mapView = remember {
@@ -1503,9 +1612,9 @@ private fun rememberMapViewWithLifecycle(
// draws a full-height artifact line whenever its X range crosses
// the screen at low zoom. The map must never repeat vertically.
setVerticalMapRepetitionEnabled(false)
setTileSource(tileSource)
setTileSource(offlineTileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0
maxZoomLevel = OFFLINE_MAX_ZOOM
// Restore the viewport the user left on the previous visit, but only
// in grid mode: satellite mode intentionally keeps its original
// behavior (default center, then follows the selected satellite).
@@ -1550,6 +1659,8 @@ private fun clearMapCaches() {
lastPositions = null
lastAction = null
lastMapView = null
configuredTileMapView = null
configuredTileSource = null
}
@Composable
@@ -21,11 +21,14 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.model.MapSource
data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val mapSource: String = MapSource.OSM,
val tiandituKey: String = "",
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
@@ -63,6 +63,8 @@ class MapViewModel(
MapState(
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
mapSource = settingsRepo.otherSettings.value.mapSource,
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
orbitalPass = defaultPass
)
)
@@ -82,6 +84,8 @@ class MapViewModel(
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
mapSource = settings.mapSource,
tiandituKey = settings.tiandituKey,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
@@ -0,0 +1,119 @@
/*
* 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.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.SharedDialog
@Composable
fun MapSettingsDialog(
settings: OtherSettings,
onDismiss: () -> Unit,
onSave: (mapSource: String, tiandituKey: String) -> Unit
) {
var selectedSource by rememberSaveable {
mutableStateOf(MapSource.normalize(settings.mapSource))
}
var tiandituKey by rememberSaveable { mutableStateOf(settings.tiandituKey) }
SharedDialog(
title = stringResource(R.string.prefs_map_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
onAccept = { onSave(selectedSource, tiandituKey) }
) { padding ->
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = stringResource(R.string.prefs_map_source_title),
color = MaterialTheme.colorScheme.primary
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
MapSourceChip(
selected = selectedSource == MapSource.OSM,
label = stringResource(R.string.prefs_map_source_osm),
onClick = { selectedSource = MapSource.OSM }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_VECTOR,
label = stringResource(R.string.prefs_map_source_tianditu_vector),
onClick = { selectedSource = MapSource.TIANDITU_VECTOR }
)
MapSourceChip(
selected = selectedSource == MapSource.TIANDITU_IMAGE,
label = stringResource(R.string.prefs_map_source_tianditu_image),
onClick = { selectedSource = MapSource.TIANDITU_IMAGE }
)
}
Text(
text = stringResource(R.string.prefs_map_tianditu_key_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = tiandituKey,
onValueChange = { tiandituKey = it },
label = { Text(stringResource(R.string.prefs_map_tianditu_key)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (selectedSource != MapSource.OSM && tiandituKey.isBlank()) {
Text(
text = stringResource(R.string.prefs_map_tianditu_key_required),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
private fun MapSourceChip(selected: Boolean, label: String, onClick: () -> Unit) {
FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
}
@@ -99,6 +99,7 @@ fun SettingsDestination() {
@Composable
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
var showMapSettings by rememberSaveable { mutableStateOf(false) }
if (showUpdateChecker) {
UpdateCheckerScreen(
currentVersion = uiState.appVersionName,
@@ -129,6 +130,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
}
)
if (showMapSettings) {
MapSettingsDialog(
settings = uiState.otherSettings,
onDismiss = { showMapSettings = false },
onSave = { mapSource, tiandituKey ->
onAction(SettingsAction.UpdateMapSettings(mapSource, tiandituKey))
showMapSettings = false
}
)
}
// Dialogs
if (dialogs.position) {
PositionDialog(
@@ -368,6 +380,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
onAction = onAction
)
}
item { MapSettingsCard(onClick = { showMapSettings = true }) }
item { CardCredits() }
item(span = { GridItemSpan(maxLineSpan) }) {
CardButton(
@@ -558,6 +571,24 @@ private fun OtherCardPreview() = MainTheme {
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
}
@Composable
private fun MapSettingsCard(onClick: () -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
Text(
text = stringResource(R.string.prefs_map_title),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(6.dp))
CardButton(
onClick = onClick,
text = stringResource(R.string.prefs_map_configure),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun OtherCard(
settings: OtherSettings,
@@ -108,6 +108,7 @@ sealed interface SettingsAction {
data class SetCompassOffset(val degrees: Float) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
data class UpdateMapSettings(val mapSource: String, val tiandituKey: String) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -186,6 +186,9 @@ class SettingsViewModel(
}
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
}
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)