From cf23d53ee874aa961a38ce367df9ddb480e0ff85 Mon Sep 17 00:00:00 2001 From: BH2VSQ Date: Fri, 31 Jul 2026 03:28:35 +0800 Subject: [PATCH] Add TianDiTu --- app/proguard-rules.pro | 3 + app/src/main/AndroidManifest.xml | 7 +- .../bh2vsq}/look4sat/MainActivity.kt | 2 +- .../bh2vsq}/look4sat/MainApplication.kt | 2 +- .../bh2vsq}/look4sat/MainScreen.kt | 2 +- .../main/res/xml/network_security_config.xml | 6 + .../look4sat/convention/PluginSetupUtils.kt | 2 +- .../core/data/repository/SettingsRepo.kt | 9 +- .../look4sat/core/domain/model/Settings.kt | 17 +- .../src/main/res/values-es/strings.xml | 2 +- .../src/main/res/values-ru/strings.xml | 2 +- .../src/main/res/values-si/strings.xml | 2 +- .../src/main/res/values-tr/strings.xml | 15 +- .../src/main/res/values-uk/strings.xml | 2 +- .../src/main/res/values-zh/strings.xml | 2 +- .../src/main/res/values/strings.xml | 22 +- .../look4sat/feature/map/MapNightOverlay.kt | 152 --------- .../look4sat/feature/map/MapScreen.kt | 293 ++++++++++++++---- .../rtbishop/look4sat/feature/map/MapState.kt | 2 + .../look4sat/feature/map/MapViewModel.kt | 38 ++- .../feature/settings/SettingsScreen.kt | 107 +++++++ .../feature/settings/SettingsState.kt | 2 + .../feature/settings/SettingsViewModel.kt | 2 + gradle.properties | 3 + gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 26 files changed, 453 insertions(+), 247 deletions(-) create mode 100644 app/proguard-rules.pro rename app/src/main/java/{com/rtbishop => cn/bh2vsq}/look4sat/MainActivity.kt (99%) rename app/src/main/java/{com/rtbishop => cn/bh2vsq}/look4sat/MainApplication.kt (98%) rename app/src/main/java/{com/rtbishop => cn/bh2vsq}/look4sat/MainScreen.kt (99%) create mode 100644 app/src/main/res/xml/network_security_config.xml delete mode 100644 feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..7b1edfd4 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep class org.osmdroid.** { *; } +-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; } +-dontwarn org.osmdroid.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd9649c9..31b92409 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,14 +15,15 @@ @@ -41,7 +42,7 @@ + android:value="cn.bh2vsq.look4sat" /> diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt similarity index 99% rename from app/src/main/java/com/rtbishop/look4sat/MainActivity.kt rename to app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt index 3a8ded66..751718cf 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt +++ b/app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.rtbishop.look4sat +package cn.bh2vsq.look4sat import android.content.Context import android.content.res.Configuration diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt similarity index 98% rename from app/src/main/java/com/rtbishop/look4sat/MainApplication.kt rename to app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt index 58fc429c..1af6ed06 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt +++ b/app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.rtbishop.look4sat +package cn.bh2vsq.look4sat import android.app.Application import com.rtbishop.look4sat.core.data.injection.MainContainer diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt similarity index 99% rename from app/src/main/java/com/rtbishop/look4sat/MainScreen.kt rename to app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt index cab6e3c9..cb745375 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.rtbishop.look4sat +package cn.bh2vsq.look4sat import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..bcbd5c57 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + tianditu.gov.cn + + diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt index 334a9308..e7eb98c0 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt @@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() { release { isMinifyEnabled = true isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } androidResources { 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 42acc318..d5dd63c5 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 @@ -82,6 +83,8 @@ class SettingsRepo( private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keySstvMode = "sstvMode" + private val keyMapSource = "mapSource" + private val keyTiandituKey = "tiandituKey" private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyTleUrl = "tleUrl" @@ -335,6 +338,8 @@ class SettingsRepo( putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putString(keySstvMode, new.sstvMode) + putString(keyMapSource, MapSource.normalize(new.mapSource)) + putString(keyTiandituKey, new.tiandituKey) } new } @@ -349,7 +354,9 @@ class SettingsRepo( stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true), - sstvMode = preferences.getString(keySstvMode, null) ?: "Auto" + sstvMode = preferences.getString(keySstvMode, null) ?: "Auto", + mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR), + tiandituKey = preferences.getString(keyTiandituKey, null) ?: "" ) //endregion 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 fcdc2667..784a3911 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 @@ -57,9 +57,24 @@ data class OtherSettings( val stateOfNightMode: Boolean = false, val shouldSeeWarning: Boolean, val shouldSeeWhatsNew: Boolean, - val sstvMode: String = "Auto" + val sstvMode: String = "Auto", + val mapSource: String = MapSource.TIANDITU_VECTOR, + val tiandituKey: String = "" ) +object MapSource { + const val OSM = "osm" + const val TIANDITU = "tianditu" + const val TIANDITU_VECTOR = "tianditu_vector" + const val TIANDITU_IMAGE = "tianditu_image" + + fun normalize(source: String): String = when (source) { + TIANDITU -> TIANDITU_VECTOR + OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source + else -> TIANDITU_VECTOR + } +} + data class DataSourcesSettings( val useCustomTLE: Boolean, val useCustomTransceivers: Boolean, diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml index b2bb7db9..1bbf9d27 100644 --- a/core/presentation/src/main/res/values-es/strings.xml +++ b/core/presentation/src/main/res/values-es/strings.xml @@ -38,7 +38,7 @@ Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites No hay pases de satélite próximos para mostrar - Bienvenido a Look4Sat\! + Bienvenido a Look4Sat-CNMap\! Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración. \n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas. diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml index bb107c73..795e1ca4 100644 --- a/core/presentation/src/main/res/values-ru/strings.xml +++ b/core/presentation/src/main/res/values-ru/strings.xml @@ -37,7 +37,7 @@ Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники Нет предстоящих пролётов спутников - Добро пожаловать в Look4Sat\! + Добро пожаловать в Look4Sat-CNMap\! Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках. \n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы. diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml index ad77e225..37263f98 100644 --- a/core/presentation/src/main/res/values-si/strings.xml +++ b/core/presentation/src/main/res/values-si/strings.xml @@ -38,7 +38,7 @@ ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්‍රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න ප්‍රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්‍රිකා පසුකර යෑම් නැත. - Look4Sat වෙත සාදරයෙන් පිළිගනිමු\! + Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\! සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න. \n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න. diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 071009d6..6c4f639e 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -1,7 +1,7 @@ - Look4Sat + Look4Sat-CNMap Kabul et İptal Gösterilecek veri bulunamadı @@ -42,11 +42,11 @@ Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun Gösterilecek yaklaşan uydu geçişi bulunmuyor - Look4Sat\'a hoş geldiniz\! + Look4Sat-CNMap\'a hoş geldiniz\! Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun. \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin. - What\'s new in Look4Sat + What\'s new in Look4Sat-CNMap * Switched to short-form commands in NetworkReporter, by theojalba \n* Added CAT radio control for full-duplex SAT operation, by jcmerg @@ -84,7 +84,8 @@ Önceki Sonraki - © OpenStreetMap contributors + © OpenStreetMap contributors + © Tianditu Azimut: %.1f° Yükseklik açısı: %.1f° İrtifa: %.0f km @@ -100,12 +101,12 @@ Görünür - Look4Sat v%s - https://play.google.com/store/apps/details?id=com.rtbishop.look4sat + Look4Sat-CNMap v%s + https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat Bağış yap https://ko-fi.com/rt_bishop F-Droid - https://f-droid.org/en/packages/com.rtbishop.look4sat/ + https://f-droid.org/en/packages/cn.bh2vsq.look4sat/ GitHub https://github.com/rt-bishop/Look4Sat/ GPLv3 diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml index 8604a1b6..ace67a46 100644 --- a/core/presentation/src/main/res/values-uk/strings.xml +++ b/core/presentation/src/main/res/values-uk/strings.xml @@ -38,7 +38,7 @@ Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники Немає майбутніх прольотів супутників - Ласкаво просимо до Look4Sat\! + Ласкаво просимо до Look4Sat-CNMap\! Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях. \n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози. diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index 8f69176a..7a6c5cb4 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -32,7 +32,7 @@ 高度:%d km 请确保您的过滤器设置正确,并且已选择卫星 没有即将过境的卫星 - 欢迎来到 Look4Sat\! + 欢迎来到 Look4Sat-CNMap\! 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index de886431..3c4bd264 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - Look4Sat + Look4Sat-CNMap Accept Cancel No data to show @@ -43,11 +43,11 @@ Make sure your filters are correct and you have selected satellites There are no upcoming satellite passes to display - Welcome to Look4Sat\! + Welcome to Look4Sat-CNMap\! Make sure to set your position via GPS, Lat/Lon or QTH in Settings. \n\nPlease update the database at least weekly to get accurate predictions. - What\'s new in Look4Sat + What\'s new in Look4Sat-CNMap * Added required tweaks to support Android 17 (API 37) \n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output @@ -82,7 +82,8 @@ Prev Next - © OpenStreetMap contributors + © OpenStreetMap contributors + © Tianditu Azimuth: %.1f° Elevation: %.1f° Altitude: %.0f km @@ -98,12 +99,12 @@ Visible - Look4Sat v%s - https://play.google.com/store/apps/details?id=com.rtbishop.look4sat + Look4Sat-CNMap v%s + https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat Donate https://ko-fi.com/rt_bishop F-Droid - https://f-droid.org/en/packages/com.rtbishop.look4sat/ + https://f-droid.org/en/packages/cn.bh2vsq.look4sat/ GitHub https://github.com/rt-bishop/Look4Sat/ GPLv3 @@ -147,6 +148,13 @@ Bluetooth CAT + Map settings + OSM + 天地图-矢量 + 天地图-影像 + 天地图密钥 + 保存密钥 + Radio Control CAT Radio Control diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt deleted file mode 100644 index db31dfc1..00000000 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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.map - -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Paint -import android.graphics.RectF -import org.osmdroid.views.MapView -import org.osmdroid.views.overlay.Overlay -import kotlin.math.cos -import kotlin.math.sin - -/** - * Custom osmdroid overlay that shades the night side of the globe. - * - * Works entirely in screen-pixel space: for each vertical strip on screen it - * asks osmdroid for the geographic coordinate, then tests whether that point is - * in the night half-sphere relative to the sub-solar point. Because the - * computation happens during draw() the result is always correct regardless - * of zoom level or map scroll position — no polygon winding issues possible. - * - * A point (latRad, lonRad) is in night when the angle to the sub-solar point - * exceeds 90°, i.e. the dot product of the two unit vectors is negative: - * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0 - * - * Performance: we sample one column per [stepPx] pixels (default 4) and draw - * filled vertical rectangles. On a 1080-wide screen this means ~270 trig - * evaluations per row, which is imperceptible. - */ -class MapNightOverlay : Overlay() { - - /** Sub-solar latitude in degrees. */ - var sunLatDeg: Double = 0.0 - - /** Sub-solar longitude in degrees. */ - var sunLonDeg: Double = 0.0 - - private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = Color.argb(75, 0, 0, 0) - } - - private val rect = RectF() - - override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { - if (shadow) return - - val proj = mapView.projection - val sunLatRad = Math.toRadians(sunLatDeg) - val sunLonRad = Math.toRadians(sunLonDeg) - val sinSunLat = sin(sunLatRad) - val cosSunLat = cos(sunLatRad) - - val w = mapView.width - val h = mapView.height - val stepPx = 4 // sample every N pixels — balance quality vs CPU - - // We scan column by column. For each column we determine the longitude, - // then find the latitude range that is in night and shade it. - // Since longitude is constant along a vertical strip and the day/night - // boundary at a given longitude is at most two latitudes, we can do a - // scan-line fill efficiently. - - var x = 0 - while (x < w) { - // Get the geographic coordinate at the top and bottom of this column. - val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue } - val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue } - - val lonRad = Math.toRadians(geoTop.longitude) - val cosLonDiff = cos(lonRad - sunLonRad) - - // Top pixel geographic lat - val latTopRad = Math.toRadians(geoTop.latitude) - // Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat) - val latBotRad = Math.toRadians(geoBot.latitude) - - // dot(sunVec, pointVec) < 0 → night - // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff - val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff - val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff - - when { - dotTop < 0 && dotBot < 0 -> { - // Entire column is night — shade from top to bottom - rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat()) - canvas.drawRect(rect, nightPaint) - } - - dotTop >= 0 && dotBot >= 0 -> { - // Entire column is day — nothing to draw - } - - else -> { - // Terminator crosses this column — find the crossing pixel by binary search - val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff) - if (dotTop < 0) { - // Night at top, day at bottom - rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat()) - canvas.drawRect(rect, nightPaint) - } else { - // Day at top, night at bottom - rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat()) - canvas.drawRect(rect, nightPaint) - } - } - } - x += stepPx - } - } - - /** - * Binary-search for the pixel row where the day/night boundary crosses column [x]. - * [yTop] is in day, [yBot] is in night (or vice versa). - */ - private fun findCrossingY( - proj: org.osmdroid.views.Projection, - x: Int, - yTop: Int, - yBot: Int, - sinSunLat: Double, - cosSunLat: Double, - cosLonDiff: Double - ): Int { - var lo = yTop - var hi = yBot - while (hi - lo > 1) { - val mid = (lo + hi) / 2 - val geo = proj.fromPixels(x, mid) ?: return mid - val latRad = Math.toRadians(geo.latitude) - val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff - if (dot < 0) hi = mid else lo = mid - } - return (lo + hi) / 2 - } -} 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 3ed234d4..10ca6770 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 @@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +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 @@ -71,37 +72,56 @@ 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.config.Configuration +import org.osmdroid.tileprovider.MapTileProviderBasic +import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase +import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.XYTileSource 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.io.File +import java.net.URLEncoder // 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_TDT_LABELS = 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_SUN = 5 private const val OVERLAY_MOON = 6 private const val OVERLAY_COUNT = 7 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 OSM_MAX_ZOOM = 19.0 +private const val TIANDITU_MAX_ZOOM = 18.0 +private const val TILE_CACHE_MAX_BYTES = 1024L * 1024L * 1024L +private const val TILE_CACHE_TRIM_BYTES = 900L * 1024L * 1024L +private const val TILE_REFRESH_DELAY_MS = 250L +private const val TILE_SOURCE_OSM = "osm" +private const val TILE_USER_AGENT = "Look4Sat-CNMap" private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - strokeWidth = 3f + strokeWidth = 4f style = Paint.Style.STROKE - color = Color.RED + color = "#1565C0".toColorInt() strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND } -private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - strokeWidth = 3f - style = Paint.Style.FILL_AND_STROKE - color = "#FFE082".toColorInt() +private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + strokeWidth = 4f + style = Paint.Style.STROKE + color = "#00897B".toColorInt() + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND } private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 36f @@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: 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 LaunchedEffect(uiState.track) { 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 (isVertical) { @@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: 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 -> - if (isVertical) MapDataCard(mapData) else MapDataCards(mapData) + if (isVertical) MapDataCard(mapData, copyrightResId) else MapDataCards(mapData, copyrightResId) } } } @@ -178,11 +203,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: // 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( @@ -220,7 +245,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) @@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) { } } Text( - text = stringResource(R.string.map_copyright), + text = stringResource(copyrightResId), fontSize = 14.sp, modifier = Modifier.align(Alignment.BottomCenter) ) @@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) { } // 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 tileSourceKey = if (layers == null || key.isBlank()) { + TILE_SOURCE_OSM + } else { + "tianditu:${layers.base}:${layers.label}:$key" + } + if (configuredTileMapView === mapView && configuredTileSource == tileSourceKey) { + requestTileRefresh(mapView) + return + } + configuredTileMapView = mapView + configuredTileSource = tileSourceKey + + if (layers == null || key.isBlank()) { + mapView.setUseDataConnection(true) + mapView.setTileProvider(MapTileProviderBasic(mapView.context, TileSourceFactory.MAPNIK)) + mapView.maxZoomLevel = OSM_MAX_ZOOM + mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults() + mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay() + requestTileRefresh(mapView) + return + } + mapView.setUseDataConnection(true) + mapView.setTileProvider(MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.base, key = key))) + mapView.maxZoomLevel = TIANDITU_MAX_ZOOM + mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults() + mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay( + MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)), + mapView.context + ).apply { + applyTileOverlayDefaults() + 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() { + setColorFilter(null) + setLoadingBackgroundColor(Color.TRANSPARENT) + setLoadingLineColor(Color.TRANSPARENT) +} + +private fun requestTileRefresh(mapView: MapView) { + mapView.post { + mapView.requestLayout() + mapView.invalidate() + mapView.postInvalidate() + } + mapView.postDelayed({ + mapView.invalidate() + mapView.postInvalidate() + }, TILE_REFRESH_DELAY_MS) +} + +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, + private val key: String +) : OnlineTileSourceBase( + "tianditu-wmts-https-$layer", + 1, + 18, + 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 { + val zoom = MapTileIndex.getZoom(pMapTileIndex) + val row = MapTileIndex.getY(pMapTileIndex) + val col = MapTileIndex.getX(pMapTileIndex) + return buildTiandituWmtsUrl( + baseUrl = getBaseUrl(), + layer = layer, + zoom = zoom, + row = row, + col = col, + encodedKey = encodedKey + ) + } +} +// endregion + // region Map overlay helpers private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { try { @@ -282,6 +415,8 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { /** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */ private val markerPool = HashMap() private var lastMapView: MapView? = null +private var lastSatelliteTrack: List>? = null +private var satelliteTrackOverlay: FolderOverlay? = null private fun setPositions( posMap: Map, @@ -294,8 +429,9 @@ private fun setPositions( lastMapView = mapView markerPool.clear() iconCache.evictAll() - footprintPolyline = null - footprintPoints = null + lastSatelliteTrack = null + satelliteTrackOverlay = null + footprintOverlay = null } // Reuse the existing FolderOverlay — creating a new one and replacing it // causes osmdroid to detach shared Marker objects, making them invisible. @@ -357,6 +493,10 @@ private fun getCachedTextIcon(name: String, mapView: MapView): Drawable { } private fun setSatelliteTrack(satTrack: List>, mapView: MapView) { + val cachedOverlay = satelliteTrackOverlay + if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) { + return + } val trackOverlay = FolderOverlay() try { satTrack.forEach { track -> @@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List>, mapView: MapView) { trackOverlay.add(this) } } + lastSatelliteTrack = satTrack + satelliteTrackOverlay = trackOverlay mapView.overlays[OVERLAY_TRACK] = trackOverlay } catch (e: Exception) { println(e) } } -/** Reusable footprint Polyline — created once, points updated in-place each frame */ -private var footprintPolyline: Polyline? = null -private var footprintPoints: ArrayList? = null +/** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */ +private var footprintOverlay: FolderOverlay? = null private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { try { val rangeCircle = orbitalPos.getRangeCircle() - var pts = footprintPoints - if (pts == null || pts.size != rangeCircle.size) { - pts = ArrayList(rangeCircle.size) - for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude)) - footprintPoints = pts - } else { - for (i in rangeCircle.indices) { - pts[i].latitude = rangeCircle[i].latitude - pts[i].longitude = rangeCircle[i].longitude + val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it } + overlay.items.clear() + splitClosedPathOnDateLine(rangeCircle).forEach { segment -> + if (segment.size > 1) { + overlay.add( + Polyline().apply { + setPoints(segment.map { GeoPoint(it.latitude, it.longitude) }) + outlinePaint.set(footprintOutlinePaint) + } + ) } } - val polyline = footprintPolyline ?: Polyline().apply { - outlinePaint.set(footprintPaint) - footprintPolyline = this - } - polyline.setPoints(pts) - mapView.overlays[OVERLAY_FOOTPRINT] = polyline + mapView.overlays[OVERLAY_FOOTPRINT] = overlay } catch (e: Exception) { println(e) } } -/** - * Update the NightOverlay with the current sub-solar position. - * The overlay is created once and kept in OVERLAY_TERMINATOR; only its - * sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it. - */ -private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) { - try { - val overlay = mapView.overlays[OVERLAY_TERMINATOR] - if (overlay is MapNightOverlay) { - overlay.sunLatDeg = sunLatDeg - overlay.sunLonDeg = sunLonDeg - } else { - mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply { - this.sunLatDeg = sunLatDeg - this.sunLonDeg = sunLonDeg +private fun splitClosedPathOnDateLine(points: List): List> { + if (points.isEmpty()) return emptyList() + val segments = mutableListOf>() + val segment = mutableListOf() + var previous = points.first() + segment.add(previous) + (points.drop(1) + points.first()).forEach { current -> + when { + previous.longitude < -170.0 && current.longitude > 170.0 -> { + val edgeLatitude = getDateLineLatitude(previous, current, -180.0) + segment.add(GeoPos(edgeLatitude, -180.0)) + segments.add(segment.toList()) + segment.clear() + segment.add(GeoPos(edgeLatitude, 180.0)) + } + previous.longitude > 170.0 && current.longitude < -170.0 -> { + val edgeLatitude = getDateLineLatitude(previous, current, 180.0) + segment.add(GeoPos(edgeLatitude, 180.0)) + segments.add(segment.toList()) + segment.clear() + segment.add(GeoPos(edgeLatitude, -180.0)) } } - } catch (e: Exception) { - println(e) + segment.add(current) + previous = current } + if (segment.size > 1) segments.add(segment) + return segments +} + +private fun getDateLineLatitude(previous: GeoPos, current: GeoPos, edgeLongitude: Double): Double { + val currentLongitude = when { + previous.longitude < -170.0 && current.longitude > 170.0 -> current.longitude - 360.0 + previous.longitude > 170.0 && current.longitude < -170.0 -> current.longitude + 360.0 + else -> current.longitude + } + val fraction = (edgeLongitude - previous.longitude) / (currentLongitude - previous.longitude) + return previous.latitude + (current.latitude - previous.latitude) * fraction } /** Place an ic_sun icon marker at the sub-solar point. */ @@ -482,40 +637,62 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map // region MapView lifecycle @Composable private fun rememberMapViewWithLifecycle(): MapView { - val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray()) val context = LocalContext.current val isVertical = isVerticalLayout() val mapView = remember { + configureOsmdroidCache(context) MapView(context).apply { setMultiTouchControls(true) setUseDataConnection(false) - setTileSource(tileSource) + setTileSource(offlineTileSource) minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical) - maxZoomLevel = 7.0 + maxZoomLevel = OFFLINE_MAX_ZOOM controller.setCenter(GeoPoint(48.8575, 6.3514)) controller.setZoom(minZoomLevel + 2) zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT overlayManager.tilesOverlay.setColorFilter(createColorFilter()) + setHorizontalMapRepetitionEnabled(true) + setVerticalMapRepetitionEnabled(false) setScrollableAreaLimitLatitude(maxLat, minLat, 0) overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() }) + addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) } } } val lifecycleObserver = rememberMapViewLifecycleObserver(mapView) val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { lifecycle.addObserver(lifecycleObserver) + if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + mapView.onResume() + requestTileRefresh(mapView) + } onDispose { lifecycle.removeObserver(lifecycleObserver) } } return mapView } +private fun configureOsmdroidCache(context: android.content.Context) { + val cacheRoot = File(context.filesDir, "osmdroid") + val tileCache = File(cacheRoot, "tiles") + Configuration.getInstance().apply { + setUserAgentValue(TILE_USER_AGENT) + setOsmdroidBasePath(cacheRoot) + setOsmdroidTileCache(tileCache) + setTileFileSystemCacheMaxBytes(TILE_CACHE_MAX_BYTES) + setTileFileSystemCacheTrimBytes(TILE_CACHE_TRIM_BYTES) + } +} + @Composable private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_RESUME -> { + mapView.onResume() + requestTileRefresh(mapView) + } Lifecycle.Event.ON_PAUSE -> mapView.onPause() else -> {} } 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 e1274618..92e826a0 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 @@ -26,6 +26,8 @@ data class MapState( val mapData: MapData? = null, val isLightUi: Boolean = false, val isUtc: Boolean = false, + val mapSource: String = "", + val tiandituKey: String = "", val stationPosition: GeoPos? = null, val orbitalPass: OrbitalPass, val track: List>? = null, 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 118a089d..1b95d5f3 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 @@ -61,6 +61,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 ) ) @@ -74,7 +76,13 @@ class MapViewModel( init { viewModelScope.launch { settingsRepo.otherSettings.collectLatest { settings -> - _uiState.update { it.copy(isUtc = settings.stateOfUtc) } + _uiState.update { + it.copy( + isUtc = settings.stateOfUtc, + mapSource = settings.mapSource, + tiandituKey = settings.tiandituKey + ) + } } } val (selectedCatNum, _) = satelliteRepo.selectedPass.value @@ -269,29 +277,45 @@ class MapViewModel( val satTracks = mutableListOf>() val currentTrack = mutableListOf() val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong()) - var oldLongitude = 0.0 + var previousPosition: GeoPos? = null satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos -> val currentPosition = satPos.toMapGeoPos() - if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) { + val previous = previousPosition + if (previous != null && previous.longitude < -170.0 && currentPosition.longitude > 170.0) { // adding left terminal position - currentTrack.add(GeoPos(currentPosition.latitude, -180.0)) + val edgeLatitude = getDateLineLatitude(previous, currentPosition) + currentTrack.add(GeoPos(edgeLatitude, -180.0)) val finishedTrack = mutableListOf().apply { addAll(currentTrack) } satTracks.add(finishedTrack) currentTrack.clear() - } else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) { + currentTrack.add(GeoPos(edgeLatitude, 180.0)) + } else if (previous != null && previous.longitude > 170.0 && currentPosition.longitude < -170.0) { // adding right terminal position - currentTrack.add(GeoPos(currentPosition.latitude, 180.0)) + val edgeLatitude = getDateLineLatitude(previous, currentPosition) + currentTrack.add(GeoPos(edgeLatitude, 180.0)) val finishedTrack = mutableListOf().apply { addAll(currentTrack) } satTracks.add(finishedTrack) currentTrack.clear() + currentTrack.add(GeoPos(edgeLatitude, -180.0)) } - oldLongitude = currentPosition.longitude + previousPosition = currentPosition currentTrack.add(currentPosition) } satTracks.add(currentTrack) _uiState.update { it.copy(track = satTracks) } } + private fun getDateLineLatitude(previous: GeoPos, current: GeoPos): Double { + val currentLon = if (previous.longitude < 0.0 && current.longitude > 0.0) { + current.longitude - 360.0 + } else { + current.longitude + 360.0 + } + val edgeLon = if (previous.longitude < 0.0) -180.0 else 180.0 + val fraction = (edgeLon - previous.longitude) / (currentLon - previous.longitude) + return previous.latitude + (current.latitude - previous.latitude) * fraction + } + companion object { /** Number of parallel chunks for satellite position computation */ private const val PARALLEL_CHUNKS = 4 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 4d96e05a..8687fdec 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 @@ -34,10 +34,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ElevatedButton import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -56,11 +60,13 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.rtbishop.look4sat.core.domain.model.MapSource import com.rtbishop.look4sat.core.domain.model.OtherSettings import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider @@ -280,6 +286,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> ) } item { OtherCard(uiState.otherSettings, onAction) } + item { MapSettingsCard(uiState.otherSettings, onAction) } item { CardCredits() } } } @@ -462,6 +469,106 @@ private fun OtherCardPreview() = MainTheme { OtherCard(settings = values) {} } +@Composable +private fun MapSettingsCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) { + var keyValue by remember(settings.tiandituKey) { mutableStateOf(settings.tiandituKey) } + LaunchedEffect(keyValue) { + kotlinx.coroutines.delay(400) + if (keyValue.trim() != settings.tiandituKey) { + onAction(SettingsAction.SetTiandituKey(keyValue)) + } + } + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(id = R.string.prefs_map_title), + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(6.dp)) + val currentMapSource = MapSource.normalize(settings.mapSource) + MapSourceRow( + currentMapSource = currentMapSource, + firstSource = MapSource.OSM, + firstTitle = stringResource(id = R.string.prefs_map_source_osm), + secondSource = MapSource.TIANDITU_VECTOR, + secondTitle = stringResource(id = R.string.prefs_map_source_tianditu_vector), + onAction = onAction + ) + Spacer(modifier = Modifier.height(6.dp)) + MapSourceRow( + currentMapSource = currentMapSource, + firstSource = MapSource.TIANDITU_IMAGE, + firstTitle = stringResource(id = R.string.prefs_map_source_tianditu_image), + onAction = onAction + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = keyValue, + onValueChange = { keyValue = it }, + label = { Text(text = stringResource(id = R.string.prefs_map_tianditu_key)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(6.dp)) + CardButton( + onClick = { onAction(SettingsAction.SetTiandituKey(keyValue)) }, + text = stringResource(id = R.string.prefs_map_save_key), + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun MapSourceRow( + currentMapSource: String, + firstSource: String, + firstTitle: String, + secondSource: String? = null, + secondTitle: String? = null, + onAction: (SettingsAction) -> Unit +) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MapSourceButton( + onClick = { onAction(SettingsAction.SetMapSource(firstSource)) }, + text = firstTitle, + selected = currentMapSource == firstSource, + modifier = Modifier.weight(1f) + ) + if (secondSource != null && secondTitle != null) { + MapSourceButton( + onClick = { onAction(SettingsAction.SetMapSource(secondSource)) }, + text = secondTitle, + selected = currentMapSource == secondSource, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun MapSourceButton(onClick: () -> Unit, text: String, selected: Boolean, modifier: Modifier = Modifier) { + ElevatedButton( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurfaceVariant + ), + shape = MaterialTheme.shapes.small, + modifier = modifier, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + ) { + Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) + } +} + @Composable private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) { ElevatedCard( 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 8943ab2d..37af26fc 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 @@ -61,6 +61,8 @@ 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 SetMapSource(val value: String) : SettingsAction + data class SetTiandituKey(val value: 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 6a685155..89d9763c 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 @@ -121,6 +121,8 @@ 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.SetMapSource -> settingsRepo.updateOtherSettings { it.copy(mapSource = action.value) } + is SettingsAction.SetTiandituKey -> settingsRepo.updateOtherSettings { it.copy(tiandituKey = action.value.trim()) } // Remote control & data sources is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) diff --git a/gradle.properties b/gradle.properties index 6308f0e2..8ecdada7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ org.gradle.caching=true org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.workers.max=4 + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b49a71a7..99fbb3d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ minSdk = "24" #noinspection UnusedVersionCatalogEntry jdkVersion = "21" #noinspection UnusedVersionCatalogEntry -packageName = "com.rtbishop.look4sat" +packageName = "cn.bh2vsq.look4sat" android-gradle-plugin = "9.2.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a8002a4a..75457f39 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Sat Jun 27 12:51:12 BST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.6.1/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME