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 3c8de86b..39a7c228 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
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
+import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -98,6 +99,8 @@ class SettingsRepo(
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
+ private val keyMapSource = "mapSource"
+ private val keyTiandituKey = "tiandituKey"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -107,6 +110,7 @@ class SettingsRepo(
private val keySatelliteEnabled = "satelliteEnabled"
private val keyTransceiversEnabled = "transceiversEnabled"
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
+ private val keyAutoTleSourceMigration = "autoTleSourceMigration"
private val separatorComma = ","
private val separatorUrl = "\n"
private val legacyCelestrakSatnogsUrl =
@@ -597,7 +601,12 @@ class SettingsRepo(
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
_otherSettings.update { current ->
- val new = transform(current)
+ val new = transform(current).let {
+ it.copy(
+ mapSource = MapSource.normalize(it.mapSource),
+ tiandituKey = it.tiandituKey.trim()
+ )
+ }
preferences.edit {
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
@@ -614,6 +623,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
+ putString(keyMapSource, new.mapSource)
+ putString(keyTiandituKey, new.tiandituKey)
}
new
}
@@ -634,7 +645,9 @@ class SettingsRepo(
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
- highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
+ highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
+ mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
+ tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
)
//endregion
@@ -656,6 +669,7 @@ class SettingsRepo(
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
putBoolean(keySatnogsTleSourceMigration, true)
+ putBoolean(keyAutoTleSourceMigration, true)
}
_dataSourcesSettings.value = normalized
}
@@ -666,7 +680,7 @@ class SettingsRepo(
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
legacyEnabledKey = keyUseCustomTle,
legacyUrlKey = keyTleUrl
- ).migrateSatnogsTleSource(),
+ ).migrateSatnogsTleSource().migrateAutoTleSources(),
transceiversUrls = getDataSourceUrls(
key = keyTransceiversUrls,
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
@@ -727,6 +741,28 @@ class SettingsRepo(
return migrated
}
+ private fun List.migrateAutoTleSources(): List {
+ if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
+ val autoTleUrls = listOfNotNull(
+ Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
+ Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
+ ).filter { it.isNotBlank() }
+ val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
+ if (missingUrls.isEmpty()) {
+ preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
+ return this
+ }
+
+ val migrated = this + missingUrls
+ val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
+ preferences.edit {
+ putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
+ putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
+ putBoolean(keyAutoTleSourceMigration, true)
+ }
+ return migrated
+ }
+
private fun List.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
private fun isSameSourceUrl(first: String, second: String): Boolean =
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 d2fe269d..e98d648e 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
@@ -75,9 +75,23 @@ data class OtherSettings(
* ephemeris auto-update; only effective after at least one manual sync). */
val stateOfAutoLotwSync: Boolean = true,
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
- val compassOffsetDegrees: Float = 0f
+ val compassOffsetDegrees: Float = 0f,
+ val mapSource: String = MapSource.OSM,
+ val tiandituKey: String = ""
)
+object MapSource {
+ const val OSM = "osm"
+ const val TIANDITU_VECTOR = "tianditu_vector"
+ const val TIANDITU_IMAGE = "tianditu_image"
+ private const val LEGACY_TIANDITU = "tianditu"
+
+ fun normalize(source: String): String = when (source) {
+ OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
+ LEGACY_TIANDITU -> TIANDITU_VECTOR
+ else -> OSM
+ }
+}
data class DataSourcesSettings(
val satelliteUrls: List,
val transceiversUrls: List,
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
index d4c41022..b256bd2c 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/Sources.kt
@@ -21,6 +21,8 @@ object Sources {
val satelliteDataUrls = mapOf(
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
"R4UAB" to "https://r4uab.ru/satonline.txt",
+ "BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
+ "BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index df091a0a..f3de6b20 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -88,7 +88,7 @@
Önceki
Sonraki
- © OpenStreetMap contributors
+ © OpenStreetMap contributors
Azimut: %.1f°
Yükseklik açısı: %.1f°
İrtifa: %.0f km
@@ -209,7 +209,8 @@
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
- \n• BG7NTA BG5JSU (CW decoding feature)
+ \n• BG7NTA BG5JSU (CW decoding feature)
+ \n• BH2VSQ BG5JSU (TianDiTu rendering support)
Bu uygulama herhangi bir garanti sunmaz
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 712d2a40..7ccb8cb7 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -247,6 +247,15 @@
启用 CAT 控制
其他设置
+ 地图设置
+ 配置地图
+ 地图来源
+ 离线 OSM 地图
+ 天地图(矢量)
+ 天地图(影像)
+ 天地图访问密钥
+ 使用天地图前,需先在天地图开发者控制台申请服务端应用 Key。
+ 选择天地图需要填写访问密钥;保存密钥前将继续使用离线 OSM 地图。
以 UTC 时间显示
启用卫星数据自动更新
自动同步 LoTW 已确认网格
@@ -282,7 +291,8 @@
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
- \n• BG7NTA BG5JSU(CW解码功能)
+ \n• BG7NTA BG5JSU(CW解码功能)
+ \n• BH2VSQ BG5JSU(天地图渲染支持)
该应用程序不提供任何保修.
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index c8f77def..daeba1ab 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -128,7 +128,8 @@
Prev
Next
- © OpenStreetMap contributors
+ © OpenStreetMap contributors
+ © Tianditu
Grid mode
Satellite mode
First call
@@ -276,6 +277,15 @@
Check your network permission
Other settings
+ Map settings
+ Configure map
+ Map source
+ Offline OSM
+ TianDiTu vector
+ TianDiTu imagery
+ TianDiTu access key
+ Request a server-side application key from the TianDiTu developer console before using TianDiTu maps.
+ A TianDiTu key is required. Offline OSM remains active until it is entered.
Show pass time in UTC
Enable automatic data update
Auto-sync LoTW confirmed grids
@@ -312,6 +322,7 @@
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BG7NTA BG5JSU (CW decoding feature)
+ \n* BH2VSQ BG5JSU (TianDiTu rendering support)
The app comes with no warranty
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 9366f37e..63148708 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
@@ -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())
+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.
@@ -300,6 +312,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) {
@@ -382,7 +397,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: operated-grid selector for VUCC counting (grid mode only).
@@ -761,11 +777,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(
@@ -803,7 +819,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)
@@ -830,7 +846,7 @@ private fun MapDataCards(data: MapData) {
}
}
Text(
- text = stringResource(R.string.map_copyright),
+ text = stringResource(copyrightResId),
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -1045,6 +1061,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
/**
@@ -1463,7 +1573,6 @@ private fun rememberMapViewWithLifecycle(
mapFilterViewModel: MapFilterViewModel,
restoreViewport: Boolean
): MapView {
- val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray())
val context = LocalContext.current
val isVertical = isVerticalLayout()
val mapView = remember {
@@ -1475,9 +1584,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).
@@ -1522,6 +1631,8 @@ private fun clearMapCaches() {
lastPositions = null
lastAction = null
lastMapView = null
+ configuredTileMapView = null
+ configuredTileSource = null
}
@Composable
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
index 6510ee34..ba9915e6 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
@@ -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. */
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 dee6c078..179149d2 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
@@ -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
)
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/MapSettingsDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/MapSettingsDialog.kt
new file mode 100644
index 00000000..646e452a
--- /dev/null
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/MapSettingsDialog.kt
@@ -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 .
+ */
+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) })
+}
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 c7160fc4..b8770257 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
@@ -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,
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 924badfc..4ac9b2e3 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
@@ -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
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 4bdeccb8..1f01b303 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
@@ -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)