Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff408747cd | ||
|
|
6636dd8e98 | ||
|
|
15ab82f1c8 | ||
|
|
0ea9c0ede1 | ||
|
|
c496d90d5a | ||
|
|
c101870b71 | ||
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead |
No files matched your search
@@ -39,19 +39,22 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||
VERSION=${TAG_NAME#v}
|
||||
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||
${BUILD_TOOLS}apksigner sign \
|
||||
--ks keystore.jks \
|
||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||
--out app/build/outputs/apk/release/look4sat.apk \
|
||||
--out "$SIGNED_APK" \
|
||||
"$APK"
|
||||
rm keystore.jks
|
||||
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||
+1
-2
@@ -39,8 +39,7 @@ captures/
|
||||
.idea/
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following line if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
*.jks
|
||||
/*.properties
|
||||
/keystore.properties
|
||||
/app/keystore.jks
|
||||
|
||||
@@ -86,7 +86,6 @@ import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||
import com.rtbishop.look4sat.feature.roaming.RoamingViewModel
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
|
||||
@@ -132,12 +131,17 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
|
||||
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// UI 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
|
||||
.sortedBy { screen ->
|
||||
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||
if (idx == -1) Int.MAX_VALUE else idx
|
||||
}
|
||||
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||
val mutualViewModel: MutualViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
@@ -227,11 +231,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
||||
)
|
||||
}
|
||||
entry<Screen.Roaming> {
|
||||
val roamingViewModel: RoamingViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = RoamingViewModel.factory(container)
|
||||
)
|
||||
RoamingScreen(viewModel = roamingViewModel)
|
||||
RoamingScreen()
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
|
||||
@@ -69,11 +69,14 @@ class DatabaseRepo(
|
||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||
val tleUrls = buildMap {
|
||||
putAll(Sources.satelliteDataUrls)
|
||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
||||
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
val radioUrls = buildMap {
|
||||
putAll(Sources.transceiversDataUrls)
|
||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||
}.filterValues { it.isNotBlank() }
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
@@ -76,6 +77,8 @@ class SettingsRepo(
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyHiddenScreens = "hiddenScreens"
|
||||
private val keyScreenOrder = "screenOrder"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -356,6 +359,8 @@ class SettingsRepo(
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -371,6 +376,8 @@ class SettingsRepo(
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
||||
)
|
||||
@@ -390,12 +397,20 @@ class SettingsRepo(
|
||||
_dataSourcesSettings.value = settings
|
||||
}
|
||||
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
||||
)
|
||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
||||
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||
return DataSourcesSettings(
|
||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||
tleUrl = tleUrl,
|
||||
transceiversUrl = txUrl
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region # Radio control settings
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@ class DatabaseRepoTest {
|
||||
repository.updateFromRemote()
|
||||
|
||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
|
||||
@@ -62,7 +62,11 @@ data class OtherSettings(
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0
|
||||
val highElevation: Double = 45.0,
|
||||
// UI 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
||||
val hiddenScreens: List<String> = emptyList(),
|
||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/匹配/漫游/地图/设置)
|
||||
val screenOrder: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
||||
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
|
||||
val satelliteDataUrls = mapOf(
|
||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||
|
||||
+11
-8
@@ -21,30 +21,33 @@ import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
||||
|
||||
@Serializable
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||
|
||||
@Serializable
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
|
||||
|
||||
@Serializable
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||
|
||||
@Serializable
|
||||
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
|
||||
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
|
||||
|
||||
@Serializable
|
||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming)
|
||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||
}
|
||||
|
||||
// UI 设置: 默认页面顺序(与 MainScreen navItems 一致)
|
||||
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Mutual", "Roaming", "Map", "Settings")
|
||||
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
|
||||
</vector>
|
||||
@@ -51,12 +51,8 @@
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
||||
\n* Added multiple performance tweaks, improved passes calculation
|
||||
\n* Fixed UTC pass time problem mentioned in issue #201
|
||||
</string>
|
||||
* Ayarlar sayfasında kaydırırken oluşan çökme düzeltildi
|
||||
* Sayfa sırası: tek hareketle birden fazla konum atlanabilir; tutamaç tek nokta oldu</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Geri</string>
|
||||
@@ -103,7 +99,7 @@
|
||||
<string name="map_visible">Görünür</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Bağış yap</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
@@ -137,7 +133,7 @@
|
||||
<string name="prefs_data_entries">Uydular: %s</string>
|
||||
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||
<string name="prefs_data_update">Güncelle</string>
|
||||
<string name="prefs_data_import">İçe aktar</string>
|
||||
<string name="prefs_data_import">Özel URL</string>
|
||||
<string name="prefs_data_clear">Temizle</string>
|
||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||
@@ -146,6 +142,7 @@
|
||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
|
||||
|
||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||
<string name="prefs_net_output">Ağ</string>
|
||||
@@ -181,6 +178,9 @@
|
||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||
|
||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||
<string name="prefs_ui_title">UI Ayarları</string>
|
||||
<string name="prefs_ui_order_title">Sayfa sırası</string>
|
||||
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
|
||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<string name="nav_pass">过境</string>
|
||||
<string name="nav_radar">雷达</string>
|
||||
<string name="nav_mutual">匹配</string>
|
||||
<string name="nav_roaming">漫游</string>
|
||||
<string name="nav_map">地图</string>
|
||||
<string name="nav_prefs">设置</string>
|
||||
|
||||
@@ -38,6 +39,10 @@
|
||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* 修复设置页滑动时闪退的问题
|
||||
* 页面顺序:可一次拖动越过多个位置;拖拽手柄改为小圆点</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">后退</string>
|
||||
@@ -110,7 +115,7 @@
|
||||
<string name="prefs_data_entries">卫星:%s</string>
|
||||
<string name="prefs_data_radios">收发器:%s</string>
|
||||
<string name="prefs_data_update">在线更新</string>
|
||||
<string name="prefs_data_import">导入文件</string>
|
||||
<string name="prefs_data_import">自定义URL</string>
|
||||
<string name="prefs_data_clear">清空</string>
|
||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||
<string name="prefs_data_update_success">更新成功</string>
|
||||
@@ -156,6 +161,9 @@
|
||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_ui_title">UI设置</string>
|
||||
<string name="prefs_ui_order_title">页面顺序</string>
|
||||
<string name="prefs_ui_order_reset">重置顺序</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
|
||||
@@ -53,9 +53,8 @@
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
</string>
|
||||
* Fixed a crash when scrolling the Settings page
|
||||
* Page order: drag a page past multiple positions in one gesture; grip is now a single dot</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
<string name="radar_back">Back</string>
|
||||
@@ -112,7 +111,7 @@
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donate</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
@@ -146,7 +145,7 @@
|
||||
<string name="prefs_data_entries">Satellites: %s</string>
|
||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||
<string name="prefs_data_update">Update</string>
|
||||
<string name="prefs_data_import">Import</string>
|
||||
<string name="prefs_data_import">Custom URL</string>
|
||||
<string name="prefs_data_clear">Clear</string>
|
||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||
@@ -157,6 +156,7 @@
|
||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||
<string name="prefs_data_reset_url">Reset to default URL</string>
|
||||
|
||||
<string name="prefs_data_output_title">Data output</string>
|
||||
<string name="prefs_net_output">Network</string>
|
||||
@@ -192,6 +192,9 @@
|
||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||
|
||||
<string name="prefs_other_title">Other settings</string>
|
||||
<string name="prefs_ui_title">UI Settings</string>
|
||||
<string name="prefs_ui_order_title">Page order</string>
|
||||
<string name="prefs_ui_order_reset">Reset order</string>
|
||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||
|
||||
+726
-151
@@ -17,200 +17,775 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.roaming
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
|
||||
private val InfoCyan = Color(0xFF0CCFE2)
|
||||
private val GridBlue = Color(0xFF12ACE0)
|
||||
private val LabelBlue = Color(0xFF135678)
|
||||
private val PositionRed = Color(0xFFFF0000)
|
||||
private val DividerWhite = Color(0xFFF7F7F7)
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 漫游页 —— 原样照搬自 QTH定位器 2.0 (com.us1pm.gridsquarelocator)。
|
||||
* UI: res/layout/main.xml (结构/尺寸/颜色逐项照搬, 不做任何更改)
|
||||
* 逻辑: MainActivity.showLocation() / checkEnabled() / GPS 实时监听
|
||||
* 红线: UI 与逻辑均不更改, 与成品截图逐像素对齐。
|
||||
*/
|
||||
@Composable
|
||||
fun RoamingScreen(viewModel: RoamingViewModel) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
InfoHeader(state = state, onGpsClick = viewModel::onGpsClick)
|
||||
GridPanel(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoHeader(state: RoamingState, onGpsClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(InfoCyan)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
) {
|
||||
// Top row: GPS status + big locator
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "GPS",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (state.qthLocator != "----") Color(0xFF3BAF52) else Color.Gray)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = state.qthLocator,
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Black
|
||||
fun RoamingScreen() {
|
||||
val context = LocalContext.current
|
||||
var state by remember { mutableStateOf(RoamingState()) }
|
||||
// 照搬 onCreate: 日期固定文本 + 小时前缀(定位时拼接分钟)
|
||||
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
|
||||
var hourPrefix by remember { mutableStateOf("") }
|
||||
// 照搬 onCreate: 初始 checkEnabled() -> 红点+设置按钮, 日期隐藏
|
||||
var hasPermission by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { }
|
||||
LaunchedEffect(Unit) {
|
||||
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
|
||||
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = fine || coarse
|
||||
if (!hasPermission) {
|
||||
permissionLauncher.launch(
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
)
|
||||
} else {
|
||||
// 照搬 onResume: 绿点+进度条+提示+日期显示, 红点/按钮隐藏
|
||||
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
// Lat / Lon rows with DMS + decimal
|
||||
CoordinateRow(label = "Lat", value = state.latitude, isLat = true)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CoordinateRow(label = "Lon", value = state.longitude, isLat = false)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
CardButton(
|
||||
onClick = onGpsClick,
|
||||
text = "GPS 定位",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
// 照搬 onResume/onPause: GPS + network 监听, 10 秒 / 10 米
|
||||
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
|
||||
DisposableEffect(locationManager, hasPermission) {
|
||||
if (hasPermission) {
|
||||
@SuppressLint("MissingPermission")
|
||||
val listener = object : LocationListener {
|
||||
override fun onLocationChanged(location: Location) {
|
||||
// 照搬 showLocation 开头: 只接受 gps / network provider
|
||||
if (location.provider != "gps" && location.provider != "network") return
|
||||
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
|
||||
.copy(date = dateText)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoordinateRow(label: String, value: Double, isLat: Boolean) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.Black,
|
||||
modifier = Modifier.width(44.dp)
|
||||
)
|
||||
Text(
|
||||
text = formatDms(value, isLat),
|
||||
fontSize = 15.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "${formatDecimal(value)}°",
|
||||
fontSize = 14.sp,
|
||||
color = LabelBlue
|
||||
)
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
// 照搬 checkEnabled: 红点+按钮, 日期隐藏
|
||||
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
|
||||
}
|
||||
|
||||
override fun onProviderEnabled(provider: String) {
|
||||
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
|
||||
}
|
||||
try {
|
||||
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
|
||||
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
|
||||
} catch (_: SecurityException) {
|
||||
}
|
||||
onDispose { locationManager.removeUpdates(listener) }
|
||||
} else {
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridPanel(state: RoamingState) {
|
||||
// 照搬 res/layout/main.xml: 浅色页面, 蓝色信息区, 43sp 定位码, 3x3 九宫格, 底部版权
|
||||
// 按用户要求: 上下往内缩进系统栏安全区, 避免内容被状态栏/导航栏遮挡
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(GridBlue)
|
||||
.padding(4.dp)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
) {
|
||||
// 3x3 grid: row 0 = north
|
||||
for (row in 0..2) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
for (col in 0..2) {
|
||||
val index = row * 3 + col
|
||||
val isCenter = index == 4
|
||||
GridCell(
|
||||
label = state.gridSquares.getOrElse(index) { "----" },
|
||||
isCenter = isCenter,
|
||||
showMarker = isCenter && state.qthLocator != "----",
|
||||
modifier = Modifier.weight(1f)
|
||||
// relativeLayout7: 顶部 GPS 状态条, 25dp 高, holo_blue_bright
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp)
|
||||
.height(25.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// tvTitleGPS: "GPS" 14sp 黑, 左 8dp
|
||||
Text(
|
||||
text = "GPS",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (成品 main.xml)
|
||||
Spacer(modifier = Modifier.width(15.dp))
|
||||
// imGps: 绿点 (15dp, 主题色 primary -> 夜间滤镜下可见)
|
||||
if (state.gpsOn) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(15.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
)
|
||||
}
|
||||
// imGpsOff: 红点叠层 (15dp, 主题色 error)
|
||||
if (state.gpsOff) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(15.dp)
|
||||
.background(MaterialTheme.colorScheme.error, CircleShape)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// tvDate: 14sp 黑, 居中
|
||||
if (state.showDate) {
|
||||
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// tvTime: 14sp 黑, 右对齐
|
||||
Text(
|
||||
text = state.time,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
if (row < 2) Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridCell(
|
||||
label: String,
|
||||
isCenter: Boolean,
|
||||
showMarker: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.background(if (isCenter) InfoCyan else GridBlue)
|
||||
.border(1.dp, DividerWhite)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = if (isCenter) 26.sp else 16.sp,
|
||||
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal,
|
||||
color = LabelBlue,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (showMarker) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// btnLocationSettings: 黄色按钮, 居中
|
||||
// btnLocationSettings: 成品 #faf8f54a 半透明黄, 14sp 黑字, 居中
|
||||
if (state.showSettings) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(PositionRed)
|
||||
.border(2.dp, DividerWhite)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
|
||||
.padding(horizontal = 5.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
// linearLayout4: 经纬度区, holo_blue_bright
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
// 纬度行: tvLati 16sp 黑 左 8dp | tvLat 右对齐 3dp
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||
}
|
||||
// 经度行
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||
}
|
||||
}
|
||||
// relativeLayout6: 定位码区, 43sp bold 黑居中
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text(
|
||||
text = state.loc,
|
||||
fontSize = 43.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
// progressBar: 加载中, 居中
|
||||
if (state.showProgress) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
// tvNotice: 提示文字, 居中顶部
|
||||
if (state.showNotice) {
|
||||
Text(
|
||||
text = "请稍等,加载可能需要几分钟的时间。",
|
||||
fontSize = 16.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
// relativeLayout: 九宫格, 占满剩余, 底部 15dp
|
||||
// 用 Box 等价 RelativeLayout 布局: 左格贴左 / 中格水平居中 / 右格贴右 (成品 main.xml)
|
||||
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
|
||||
// 行1: relativeLayout5 (上排: 纬度 +1)
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||
}
|
||||
// 行2: relativeLayout4, 205dp 高 (中排: 中央)
|
||||
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
|
||||
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||
// 中央列: tv22 (100x80dp centerInParent) 叠在 linearLayout3 (200x200dp) 之上
|
||||
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
|
||||
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = 2.dp)
|
||||
.size(width = 200.dp, height = 200.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
// iv: 红点 10x10dp, top|left, leftMargin/topMargin 查表绝对定位
|
||||
Image(
|
||||
painter = painterResource(R.drawable.roam_pnt),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
|
||||
)
|
||||
}
|
||||
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight(白)
|
||||
Text(
|
||||
text = state.grids[4],
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.size(width = 100.dp, height = 80.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||
}
|
||||
// 行3: relativeLayout3 (下排: 纬度 -1)
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
// tv4: 底部版权, 10sp 黑 (往上拉, 底部预留空间避免被导航栏遮挡)
|
||||
Text(
|
||||
text = "制作:US1PM 汉化:BA7LCE",
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDms(value: Double, isLat: Boolean): String {
|
||||
val abs = kotlin.math.abs(value)
|
||||
val deg = abs.toInt()
|
||||
val minFull = (abs - deg) * 60
|
||||
val min = minFull.toInt()
|
||||
val sec = ((minFull - min) * 60).toInt()
|
||||
val hemi = if (value >= 0) (if (isLat) "N" else "E") else (if (isLat) "S" else "W")
|
||||
return "$deg° $min' $sec\" $hemi"
|
||||
// 周边格: 60/200/60dp, #0BACF1, bold 居中, 2dp margin (照搬 tv11/tv13/tv21/tv23/tv31/tv33)
|
||||
@Composable
|
||||
private fun GridCell(text: String, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(2.dp)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDecimal(value: Double): String = String.format("%.5f", value)
|
||||
/**
|
||||
* 照搬 MainActivity.showLocation() 的状态。
|
||||
* 网格/九宫格/红点查表与成品逐行一致。
|
||||
*/
|
||||
data class RoamingState(
|
||||
val date: String = "",
|
||||
val time: String = "",
|
||||
val latLabel: String = "纬度",
|
||||
val latValue: String = "",
|
||||
val lonLabel: String = "经度",
|
||||
val lonValue: String = "",
|
||||
val loc: String = "",
|
||||
val grids: List<String> = List(9) { "" },
|
||||
/** 红点 leftMargin(dp), 经度第 3 对字符查表 */
|
||||
val markerLeft: Int = 0,
|
||||
/** 红点 topMargin(dp), 纬度第 3 对字符查表(屏幕 Y 反转) */
|
||||
val markerTop: Int = 0,
|
||||
val gpsOn: Boolean = false,
|
||||
val gpsOff: Boolean = true,
|
||||
val showSettings: Boolean = true,
|
||||
val showProgress: Boolean = false,
|
||||
val showNotice: Boolean = false,
|
||||
val showDate: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* 照搬 showLocation() 的完整计算: 时间/经纬度 DMS/8 位网格/九宫格/红点查表。
|
||||
*/
|
||||
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
|
||||
// 照搬: tvTime = 缓存小时前缀 + 定位时间分钟
|
||||
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
|
||||
val time = hourPrefix + minute
|
||||
// 方向字符
|
||||
val ew = if (lon >= 0.0) "E" else "W"
|
||||
val ns = if (lat >= 0.0) "N" else "S"
|
||||
// 照搬: 纬度 DMS (String.valueOf 无补零)
|
||||
val latAbs = if (lat < 0.0) -lat else lat
|
||||
val latDeg = latAbs.toInt()
|
||||
val latMinFull = (latAbs - latDeg) * 60.0
|
||||
val latMin = latMinFull.toInt()
|
||||
val latSec = ((latMinFull - latMin) * 60.0).toInt()
|
||||
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
|
||||
val latValue = "$lat° "
|
||||
// 照搬: 经度 DMS
|
||||
val lonAbs = if (lon < 0.0) -lon else lon
|
||||
val lonDeg = lonAbs.toInt()
|
||||
val lonMinFull = (lonAbs - lonDeg) * 60.0
|
||||
val lonMin = lonMinFull.toInt()
|
||||
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
|
||||
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
|
||||
val lonValue = "$lon° "
|
||||
// 照搬: 8 位网格编码 (区间查表)
|
||||
val (str, str2, str3, str4) = encodeLon(lon)
|
||||
val (str5, str6, str38, str9) = encodeLat(lat)
|
||||
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
|
||||
// 照搬: 九宫格 parseInt3 五分支
|
||||
val grids = buildGrids(str, str5, str2, str6, str3, str38)
|
||||
// 照搬: 红点查表 (第 3 对字符)
|
||||
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
|
||||
val markerTop = latMargin[str38.firstOrNull()] ?: 0
|
||||
return RoamingState(
|
||||
time = time,
|
||||
latLabel = latLabel,
|
||||
latValue = latValue,
|
||||
lonLabel = lonLabel,
|
||||
lonValue = lonValue,
|
||||
loc = loc,
|
||||
grids = grids,
|
||||
markerLeft = markerLeft,
|
||||
markerTop = markerTop,
|
||||
gpsOn = true,
|
||||
gpsOff = false,
|
||||
showSettings = false,
|
||||
showProgress = false,
|
||||
showNotice = false,
|
||||
showDate = true
|
||||
)
|
||||
}
|
||||
|
||||
// 红点 leftMargin 查表 (MainActivity 1286-1386 行): 经度第 3 对 a..x -> -2..190 dp
|
||||
private val lonMargin = mapOf(
|
||||
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
|
||||
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
|
||||
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
|
||||
)
|
||||
|
||||
// 红点 topMargin 查表 (MainActivity 1315-1363 行): 纬度第 3 对 a..x -> 190..-2 dp (屏幕 Y 反转)
|
||||
private val latMargin = mapOf(
|
||||
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
|
||||
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
|
||||
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
|
||||
)
|
||||
|
||||
// 照搬 311-611 行: 经度 -> (20°区字母, 2°数字, 2'字母, 30"数字)
|
||||
private fun encodeLon(inputLon: Double): List<String> {
|
||||
var longitude = inputLon
|
||||
var d = 0.0
|
||||
val str = when {
|
||||
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
|
||||
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
|
||||
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
|
||||
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
|
||||
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
|
||||
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
|
||||
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
|
||||
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
|
||||
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
|
||||
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
|
||||
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
|
||||
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
|
||||
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
|
||||
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
|
||||
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
|
||||
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
|
||||
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
|
||||
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
|
||||
else -> { longitude = 0.0; " " }
|
||||
}
|
||||
val str2 = when {
|
||||
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
|
||||
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
|
||||
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
|
||||
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
|
||||
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
|
||||
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
|
||||
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
|
||||
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
|
||||
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
|
||||
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
|
||||
else -> { d = 0.0; " " }
|
||||
}
|
||||
val str3 = when {
|
||||
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
|
||||
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
|
||||
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
|
||||
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
|
||||
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
|
||||
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
|
||||
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
|
||||
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
|
||||
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
|
||||
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
|
||||
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
|
||||
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
|
||||
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
|
||||
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
|
||||
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
|
||||
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
|
||||
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
|
||||
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
|
||||
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
|
||||
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
|
||||
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
|
||||
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
|
||||
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
|
||||
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
|
||||
else -> { longitude = 0.0; " " }
|
||||
}
|
||||
val str4 = when {
|
||||
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
|
||||
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
|
||||
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
|
||||
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
|
||||
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
|
||||
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
|
||||
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
|
||||
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
|
||||
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
|
||||
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
|
||||
else -> " "
|
||||
}
|
||||
return listOf(str, str2, str3, str4)
|
||||
}
|
||||
|
||||
// 照搬 612-914 行: 纬度 -> (10°区字母, 1°数字, 1'字母, 15"数字)
|
||||
private fun encodeLat(inputLat: Double): List<String> {
|
||||
var latitude = inputLat
|
||||
var d2 = 0.0
|
||||
var d3 = 0.0
|
||||
val str5 = when {
|
||||
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
|
||||
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
|
||||
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
|
||||
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
|
||||
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
|
||||
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
|
||||
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
|
||||
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
|
||||
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
|
||||
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
|
||||
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
|
||||
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
|
||||
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
|
||||
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
|
||||
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
|
||||
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
|
||||
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
|
||||
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
|
||||
else -> { d2 = 0.0; " " }
|
||||
}
|
||||
var str6 = " "
|
||||
when {
|
||||
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
|
||||
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
|
||||
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
|
||||
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
|
||||
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
|
||||
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
|
||||
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
|
||||
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
|
||||
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
|
||||
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
|
||||
}
|
||||
val str38 = when {
|
||||
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
|
||||
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
|
||||
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
|
||||
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
|
||||
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
|
||||
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
|
||||
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
|
||||
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
|
||||
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
|
||||
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
|
||||
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
|
||||
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
|
||||
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
|
||||
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
|
||||
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
|
||||
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
|
||||
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
|
||||
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
|
||||
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
|
||||
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
|
||||
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
|
||||
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
|
||||
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
|
||||
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
|
||||
else -> { d2 = 0.0; " " }
|
||||
}
|
||||
val str9 = when {
|
||||
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
|
||||
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
|
||||
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
|
||||
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
|
||||
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
|
||||
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
|
||||
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
|
||||
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
|
||||
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
|
||||
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
|
||||
else -> " "
|
||||
}
|
||||
return listOf(str5, str6, str38, str9)
|
||||
}
|
||||
|
||||
/**
|
||||
* 照搬 917-1283 行: 九宫格 parseInt3 五分支(内部/西界/东界/北界/南界 + 四角)。
|
||||
* 返回 9 格文本: [上左, 上中, 上右, 左中, 中央, 右中, 下左, 下中, 下右]。
|
||||
*/
|
||||
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
|
||||
val parseInt = str2.toInt()
|
||||
val parseInt2 = str6.toInt()
|
||||
val str39 = str + str5
|
||||
val str40 = str2 + str6
|
||||
val parseInt3 = str40.toInt()
|
||||
val str41 = str39 + str40
|
||||
val g = MutableList(9) { "" }
|
||||
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
|
||||
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||
// 内部: 数字对 ±1
|
||||
val i7 = parseInt3 + 1
|
||||
val i8 = i7 - 10
|
||||
val valueOf9 = fmt2(i8)
|
||||
val str11 = i7.toString()
|
||||
val valueOf18 = (i7 + 10).toString()
|
||||
val i9 = parseInt3 - 10
|
||||
val valueOf10 = fmt2(i9)
|
||||
val valueOf19 = (parseInt3 + 10).toString()
|
||||
val i10 = parseInt3 - 1
|
||||
val i11 = i10 - 10
|
||||
val valueOf11 = fmt2(i11)
|
||||
val str14 = i10.toString()
|
||||
val str15 = (i10 + 10).toString()
|
||||
g[0] = str39 + valueOf9
|
||||
g[1] = str39 + str11
|
||||
g[2] = str39 + valueOf18
|
||||
g[3] = str39 + valueOf10
|
||||
g[4] = str41
|
||||
g[5] = str39 + valueOf19
|
||||
g[6] = str39 + valueOf11
|
||||
g[7] = str39 + str14
|
||||
g[8] = str39 + str15
|
||||
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||
// 西边界: 经度 0 -> 9, 经度字母 -1
|
||||
val str43 = (str[0] - 1).toString() + str5
|
||||
val str44 = "9" + (parseInt2 + 1)
|
||||
val i12 = parseInt3 + 1
|
||||
val valueOf7 = fmt2(i12)
|
||||
val str12 = (i12 + 10).toString()
|
||||
val sb4 = "9" + parseInt2
|
||||
val valueOf20 = (parseInt3 + 10).toString()
|
||||
val str46 = "9" + (parseInt2 - 1)
|
||||
val i13 = parseInt3 - 1
|
||||
val valueOf8 = fmt2(i13)
|
||||
val str15 = (i13 + 10).toString()
|
||||
g[0] = str43 + str44
|
||||
g[1] = str39 + valueOf7
|
||||
g[2] = str39 + str12
|
||||
g[3] = str43 + sb4
|
||||
g[4] = str41
|
||||
g[5] = str39 + valueOf20
|
||||
g[6] = str43 + str46
|
||||
g[7] = str39 + valueOf8
|
||||
g[8] = str39 + str15
|
||||
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||
// 东边界: 经度 9 -> 0, 经度字母 +1
|
||||
val str47 = (str[0] + 1).toString() + str5
|
||||
val i14 = parseInt3 + 1
|
||||
val valueOf21 = (i14 - 10).toString()
|
||||
val valueOf22 = i14.toString()
|
||||
val str48 = "0" + (parseInt2 + 1)
|
||||
val valueOf23 = (parseInt3 - 10).toString()
|
||||
val str49 = "0" + parseInt2
|
||||
val i15 = parseInt3 - 1
|
||||
val valueOf24 = (i15 - 10).toString()
|
||||
val valueOf25 = i15.toString()
|
||||
val str15 = "0" + (parseInt2 - 1)
|
||||
g[0] = str39 + valueOf21
|
||||
g[1] = str39 + valueOf22
|
||||
g[2] = str47 + str48
|
||||
g[3] = str39 + valueOf23
|
||||
g[4] = str41
|
||||
g[5] = str47 + str49
|
||||
g[6] = str39 + valueOf24
|
||||
g[7] = str39 + valueOf25
|
||||
g[8] = str47 + str15
|
||||
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
|
||||
// 北边界: 纬度 9 -> 0, 纬度字母 +1
|
||||
val str50 = str + (str5[0] + 1).toString()
|
||||
val i16 = parseInt3 - 9
|
||||
val i17 = i16 - 10
|
||||
val valueOf4 = fmt2(i17)
|
||||
val valueOf26 = i16.toString()
|
||||
val valueOf27 = (i16 + 10).toString()
|
||||
val i18 = parseInt3 - 10
|
||||
val valueOf5 = fmt2(i18)
|
||||
val valueOf28 = (parseInt3 + 10).toString()
|
||||
val i19 = parseInt3 - 1
|
||||
val i20 = i19 - 10
|
||||
val valueOf6 = fmt2(i20)
|
||||
val str14 = i19.toString()
|
||||
val str15 = (i19 + 10).toString()
|
||||
g[0] = str39 + valueOf4
|
||||
g[1] = str50 + valueOf26
|
||||
g[2] = str39 + valueOf27
|
||||
g[3] = str39 + valueOf5
|
||||
g[4] = str41
|
||||
g[5] = str39 + valueOf28
|
||||
g[6] = str39 + valueOf6
|
||||
g[7] = str39 + str14
|
||||
g[8] = str39 + str15
|
||||
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
|
||||
// 南边界: 纬度 0 -> 9, 纬度字母 -1
|
||||
val str52 = str + (str5[0] - 1).toString()
|
||||
val i21 = parseInt3 + 1
|
||||
val i22 = i21 - 10
|
||||
val valueOf = fmt2(i22)
|
||||
val valueOf29 = i21.toString()
|
||||
val valueOf30 = (i21 + 10).toString()
|
||||
val i23 = parseInt3 - 10
|
||||
val valueOf2 = fmt2(i23)
|
||||
val valueOf31 = (parseInt3 + 10).toString()
|
||||
val i24 = parseInt3 - 1
|
||||
val valueOf3 = fmt2(i24)
|
||||
val str14 = (i24 + 10).toString()
|
||||
val str15 = (i24 + 20).toString()
|
||||
g[0] = str39 + valueOf
|
||||
g[1] = str39 + valueOf29
|
||||
g[2] = str39 + valueOf30
|
||||
g[3] = str39 + valueOf2
|
||||
g[4] = str41
|
||||
g[5] = str39 + valueOf31
|
||||
g[6] = str52 + valueOf3
|
||||
g[7] = str52 + str14
|
||||
g[8] = str52 + str15
|
||||
} else if (parseInt3 == 0) {
|
||||
// 四角 00: 经纬度数字均 -1 进位
|
||||
val v32 = (str[0] - 1).toString()
|
||||
val v33 = (str5[0] - 1).toString()
|
||||
g[0] = v32 + str5 + "91"
|
||||
g[1] = str39 + "01"
|
||||
g[2] = str39 + "11"
|
||||
g[3] = v32 + str5 + "90"
|
||||
g[4] = str41
|
||||
g[5] = v32 + str5 + "10"
|
||||
g[6] = v32 + v33 + "99"
|
||||
g[7] = str + v33 + "09"
|
||||
g[8] = str + v33 + "19"
|
||||
} else if (parseInt3 == 9) {
|
||||
// 四角 09: 经度 -1, 纬度 +1
|
||||
val v34 = (str[0] - 1).toString()
|
||||
val v35 = (str5[0] + 1).toString()
|
||||
g[0] = v34 + v35 + "90"
|
||||
g[1] = str + v35 + "00"
|
||||
g[2] = str + v35 + "10"
|
||||
g[3] = v34 + str5 + "99"
|
||||
g[4] = str41
|
||||
g[5] = v34 + str5 + "19"
|
||||
g[6] = v34 + str5 + "98"
|
||||
g[7] = v34 + str5 + "08"
|
||||
g[8] = v34 + str5 + "18"
|
||||
} else if (parseInt3 == 99) {
|
||||
// 四角 99: 经纬度数字均 +1 进位
|
||||
val v36 = (str[0] + 1).toString()
|
||||
val v37 = (str5[0] + 1).toString()
|
||||
g[0] = str + v37 + "80"
|
||||
g[1] = str + v37 + "90"
|
||||
g[2] = v36 + v37 + "00"
|
||||
g[3] = str39 + "89"
|
||||
g[4] = str41
|
||||
g[5] = v36 + str5 + "09"
|
||||
g[6] = str39 + "88"
|
||||
g[7] = str39 + "98"
|
||||
g[8] = v36 + str5 + "08"
|
||||
} else if (parseInt3 == 90) {
|
||||
// 四角 90: 经度 +1, 纬度 -1
|
||||
val v38 = (str[0] + 1).toString()
|
||||
val v39 = (str5[0] - 1).toString()
|
||||
g[0] = str39 + "81"
|
||||
g[1] = str39 + "91"
|
||||
g[2] = v38 + str5 + "01"
|
||||
g[3] = str39 + "80"
|
||||
g[4] = str41
|
||||
g[5] = v38 + str5 + "00"
|
||||
g[6] = str + v39 + "89"
|
||||
g[7] = str + v39 + "99"
|
||||
g[8] = v38 + v39 + "09"
|
||||
}
|
||||
return g
|
||||
}
|
||||
-83
@@ -1,83 +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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.roaming
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RoamingState(
|
||||
val latitude: Double = 0.0,
|
||||
val longitude: Double = 0.0,
|
||||
val altitude: Double = 0.0,
|
||||
val qthLocator: String = "----",
|
||||
val gridSquares: List<String> = List(9) { "----" },
|
||||
val isUpdating: Boolean = false,
|
||||
val messageResId: Int = 0
|
||||
)
|
||||
|
||||
class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(RoamingState())
|
||||
val uiState: StateFlow<RoamingState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { geoPos ->
|
||||
_uiState.update { it.copy(isUpdating = false, messageResId = 0).withPosition(geoPos) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onGpsClick() {
|
||||
val success = settingsRepo.setStationPosition()
|
||||
if (success) {
|
||||
_uiState.update { it.copy(isUpdating = true) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoamingState.withPosition(pos: GeoPos): RoamingState {
|
||||
val locator = positionToQth(pos.latitude, pos.longitude) ?: return copy(qthLocator = "----")
|
||||
val square = qthToSquare(locator)
|
||||
val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" }
|
||||
return copy(
|
||||
latitude = pos.latitude,
|
||||
longitude = pos.longitude,
|
||||
altitude = pos.altitude,
|
||||
qthLocator = locator,
|
||||
gridSquares = squares
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer { RoamingViewModel(container.settingsRepo) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 176 B |
Binary file not shown.
|
After Width: | Height: | Size: 172 B |
Binary file not shown.
|
After Width: | Height: | Size: 375 B |
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.roaming
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RoamingStateTest {
|
||||
|
||||
// 用户实测坐标 (QTH定位器 2.0 成品截图): 22.314066 / 108.706575
|
||||
private fun stateOf(lat: Double, lon: Double) =
|
||||
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
|
||||
|
||||
@Test
|
||||
fun `user coordinates produce OL42ih45`() {
|
||||
val state = stateOf(22.314066, 108.706575)
|
||||
assertEquals("OL42ih45", state.loc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `red marker from ih pair matches reference lookup`() {
|
||||
val state = stateOf(22.314066, 108.706575)
|
||||
// 成品查表: 经度第3对 'i' -> leftMargin 65dp, 纬度第3对 'h' -> topMargin 137dp
|
||||
assertEquals(65, state.markerLeft)
|
||||
assertEquals(137, state.markerTop)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grid nine from OL42 matches reference`() {
|
||||
val state = stateOf(22.314066, 108.706575)
|
||||
val expected = listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
)
|
||||
assertEquals(expected, state.grids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latitude dms formatting matches reference`() {
|
||||
val state = stateOf(22.314066, 108.706575)
|
||||
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
|
||||
assertEquals("22.314066° ", state.latValue)
|
||||
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
|
||||
assertEquals("108.706575° ", state.lonValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time is hour prefix plus fix minutes`() {
|
||||
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
|
||||
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
|
||||
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
|
||||
assertEquals("21:12", state.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `west edge borrows lon letter`() {
|
||||
// 经度 0.5 纬度 22.314066: 数字对 02, 西边界 parseInt==0
|
||||
val state = stateOf(22.314066, 0.5)
|
||||
assertEquals(8, state.loc.length)
|
||||
// 左列经度 0->9, 经度字母 J-1=I: IL92
|
||||
assertEquals("IL92", state.grids[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `north edge carries lat letter`() {
|
||||
// 纬度 29.5 经度 108.706575: 数字对 49? -> 纬度数字 9 -> 北边界
|
||||
val state = stateOf(29.5, 108.706575)
|
||||
assertEquals(8, state.loc.length)
|
||||
// 上排纬度 +1 -> 0, 纬度字母 +1 (M)
|
||||
assertEquals("OM40", state.grids[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `east edge carries lon letter`() {
|
||||
// 经度 119.5 纬度 22.314066: 经度数字 9 -> 东边界
|
||||
val state = stateOf(22.314066, 119.5)
|
||||
assertEquals(8, state.loc.length)
|
||||
// 右上 经度 9->0, 经度字母 O+1=P, 纬度 2+1=3: PL03
|
||||
assertEquals("PL03", state.grids[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `south edge borrows lat letter`() {
|
||||
// 纬度 0.5 经度 108.706575: 纬度数字 0 -> 南边界
|
||||
val state = stateOf(0.5, 108.706575)
|
||||
assertEquals(8, state.loc.length)
|
||||
// 下中 纬度 0->9, 纬度字母 J-1=I: OI49
|
||||
assertEquals("OI49", state.grids[7])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `boundary values wrap like reference`() {
|
||||
// (90, 180) -> RR99xx99 (成品区间查表行为)
|
||||
val state = stateOf(90.0, 180.0)
|
||||
assertEquals("RR99xx99", state.loc)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gps state after fix matches showLocation`() {
|
||||
val state = stateOf(22.314066, 108.706575)
|
||||
assertEquals(true, state.gpsOn)
|
||||
assertEquals(false, state.gpsOff)
|
||||
assertEquals(false, state.showSettings)
|
||||
assertEquals(false, state.showProgress)
|
||||
assertEquals(false, state.showNotice)
|
||||
assertEquals(true, state.showDate)
|
||||
}
|
||||
}
|
||||
+26
@@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -41,11 +43,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
@@ -203,6 +207,17 @@ fun DataSourcesDialog(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTle.value,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { urlTle.value = Sources.defaultTleUrl },
|
||||
enabled = isEnabledCustomTle.value
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_reset),
|
||||
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(
|
||||
@@ -232,6 +247,17 @@ fun DataSourcesDialog(
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = isEnabledCustomTransceivers.value,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
|
||||
enabled = isEnabledCustomTransceivers.value
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_reset),
|
||||
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
+183
-14
@@ -22,15 +22,26 @@ import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
@@ -40,15 +51,28 @@ import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import kotlin.math.roundToInt
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -299,6 +323,15 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item {
|
||||
UiSettingsCard(
|
||||
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||
screenOrder = uiState.otherSettings.screenOrder,
|
||||
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
|
||||
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
|
||||
onResetOrder = { onAction(SettingsAction.ResetScreenOrder) }
|
||||
)
|
||||
}
|
||||
item { CardCredits() }
|
||||
}
|
||||
}
|
||||
@@ -486,11 +519,12 @@ private fun OtherCardPreview() = MainTheme {
|
||||
@Composable
|
||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(268.dp)
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_other_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -514,8 +548,146 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 设置卡片: 控制底部菜单栏各页面显示/隐藏。
|
||||
* 顺序固定(卫星/过境/雷达/匹配/漫游/地图/设置), 关闭后其余自动靠拢;
|
||||
* "设置"页固定显示(防止失去设置入口)。
|
||||
*/
|
||||
@Composable
|
||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
private fun UiSettingsCard(
|
||||
hiddenScreens: List<String>,
|
||||
screenOrder: List<String>,
|
||||
onToggle: (String) -> Unit,
|
||||
onReorder: (List<String>) -> Unit,
|
||||
onResetOrder: () -> Unit
|
||||
) {
|
||||
val screens = listOf(
|
||||
R.string.nav_sat to "Satellites",
|
||||
R.string.nav_pass to "Passes",
|
||||
R.string.nav_radar to "Radar",
|
||||
R.string.nav_mutual to "Mutual",
|
||||
R.string.nav_roaming to "Roaming",
|
||||
R.string.nav_map to "Map",
|
||||
R.string.nav_prefs to "Settings"
|
||||
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_ui_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
screens.forEach { (labelRes, name) ->
|
||||
if (name == "Settings") {
|
||||
// 设置页固定显示, 开关禁用
|
||||
SwitchRow(labelRes, true, null)
|
||||
} else {
|
||||
SwitchRow(labelRes, name !in hiddenScreens) {
|
||||
onToggle(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_ui_order_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
DragOrderList(screens = screens, screenOrder = screenOrder, onReorder = onReorder)
|
||||
TextButton(onClick = onResetOrder) {
|
||||
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面顺序拖拽列表: 每行右侧抓手, 按住上下拖动实时换位, 松手持久化 */
|
||||
@Composable
|
||||
private fun DragOrderList(
|
||||
screens: List<Pair<Int, String>>,
|
||||
screenOrder: List<String>,
|
||||
onReorder: (List<String>) -> Unit
|
||||
) {
|
||||
val itemHeight = 48.dp
|
||||
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
|
||||
// 按 screenOrder 排序(空 = 默认顺序); remember 以 screens 参数为 key, 持久化后重组
|
||||
val ordered = remember(screens, screenOrder) {
|
||||
screens.sortedBy { (_, name) ->
|
||||
screenOrder.indexOf(name).let { if (it == -1) Int.MAX_VALUE else it }
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val items = remember(ordered) { mutableStateListOf<Pair<Int, String>>().apply { addAll(ordered) } }
|
||||
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||
var dragOffset by remember { mutableStateOf(0f) }
|
||||
|
||||
// 固定高度: 嵌套在 LazyVerticalGrid 内必须给有界高度, 否则无限高度约束崩溃
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight * items.size)
|
||||
) {
|
||||
itemsIndexed(items, key = { _, s -> s.second }) { index, screen ->
|
||||
val isDragging = draggingIndex == index
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.zIndex(if (isDragging) 1f else 0f)
|
||||
.graphicsLayer { translationY = if (isDragging) dragOffset else 0f }
|
||||
.animateItem()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = screen.first),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.padding(20.dp)
|
||||
.pointerInput(screen.second) {
|
||||
detectDragGestures(
|
||||
onDragStart = { draggingIndex = items.indexOf(screen) },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount.y
|
||||
val currentIndex = draggingIndex
|
||||
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
||||
.coerceIn(0, items.size - 1)
|
||||
if (target != currentIndex) {
|
||||
items.add(target, items.removeAt(currentIndex))
|
||||
dragOffset += (currentIndex - target) * itemHeightPx
|
||||
draggingIndex = target
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
onReorder(items.map { it.second })
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
draggingIndex = -1
|
||||
dragOffset = 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// 小圆点手柄(触控区 padding 20dp 居中 -> 8dp 圆点)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -558,15 +730,11 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
|
||||
@Composable
|
||||
private fun CardCredits(modifier: Modifier = Modifier) {
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(268.dp)
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.fillMaxHeight()
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_title),
|
||||
@@ -575,6 +743,8 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_thanks)
|
||||
)
|
||||
// 感谢列表与保修声明之间保留约两行间距 (用户要求)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_license),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
@@ -590,7 +760,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable { onClick() }) {
|
||||
Spacer(Modifier)
|
||||
Icon(
|
||||
@@ -605,7 +775,6 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 6.dp)
|
||||
.infiniteMarquee()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ sealed interface SettingsAction {
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||
// UI 设置: 切换底部导航栏页面的隐藏状态(Screen simpleName)
|
||||
data class ToggleScreen(val screenName: String) : SettingsAction
|
||||
// UI 设置: 更新页面顺序
|
||||
data class ReorderScreens(val order: List<String>) : SettingsAction
|
||||
// UI 设置: 重置为默认顺序
|
||||
data object ResetScreenOrder : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
|
||||
+7
@@ -121,6 +121,13 @@ 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.ToggleScreen -> settingsRepo.updateOtherSettings { current ->
|
||||
val hidden = current.hiddenScreens.toMutableList()
|
||||
if (action.screenName in hidden) hidden.remove(action.screenName) else hidden.add(action.screenName)
|
||||
current.copy(hiddenScreens = hidden)
|
||||
}
|
||||
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
|
||||
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
|
||||
+3
-4
@@ -1,6 +1,5 @@
|
||||
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
org.gradle.caching=true
|
||||
# NOTE: lowered from -Xmx6g for the 2GB build server; heavy release builds run on GitHub Actions
|
||||
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.workers.max=1
|
||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=4
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "446"
|
||||
appVersionCode = "449"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6"
|
||||
appVersionName = "4.4.9"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
Reference in new issue
Block a user