mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6388f3bc | ||
|
|
3e0874e6a8 | ||
|
|
6beaeb3e1a | ||
|
|
91f0cb1c56 | ||
|
|
3b72f8d68a | ||
|
|
3bc2983d26 | ||
|
|
0ec4c5dc6c | ||
|
|
8e8a3849df | ||
|
|
cd65e6c077 | ||
|
|
a3d370b1fa | ||
|
|
bf492e3f8b | ||
|
|
3591960d55 | ||
|
|
3f7a087938 | ||
|
|
4b82332133 | ||
|
|
dcd16a0196 | ||
|
|
9e88609efc | ||
|
|
322becdda8 | ||
|
|
7f93a10556 | ||
|
|
3cd5b49f98 | ||
|
|
4dbe896124 | ||
|
|
b47bd78ecd | ||
|
|
c89e79e28f | ||
|
|
b40a79e3c6 | ||
|
|
6c23696e22 | ||
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf | ||
|
|
f3fdf61784 | ||
|
|
fc028f852c | ||
|
|
7a865fffd7 | ||
|
|
89220a0b4b | ||
|
|
03d0055ce2 | ||
|
|
afb1e434e8 | ||
|
|
1bed7849a3 | ||
|
|
06f00e1601 | ||
|
|
462c3de155 | ||
|
|
4020381b03 | ||
|
|
b8389f63fe | ||
|
|
e64beee44f | ||
|
|
d2dffc039c | ||
|
|
d7d4a752ed | ||
|
|
6a3ab54226 | ||
|
|
3b3579696b | ||
|
|
826f44829b | ||
|
|
b979524b37 | ||
|
|
9e748f5534 | ||
|
|
25b982ac59 | ||
|
|
6a6399b606 | ||
|
|
c1cc992abd | ||
|
|
212dced3c4 | ||
|
|
7ef555e377 | ||
|
|
2773145e0c | ||
|
|
d8d3d81648 | ||
|
|
25a41def35 | ||
|
|
d9dc0a1735 | ||
|
|
692e917893 | ||
|
|
6371bb114d | ||
|
|
2d76aeda23 | ||
|
|
ecc24fd836 | ||
|
|
689f08881c | ||
|
|
d467200a45 |
No files matched your search
@@ -6,11 +6,15 @@
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
||||
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
|
||||
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
|
||||
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||
|
||||
## 上游仓库
|
||||
|
||||
@@ -24,6 +24,13 @@ import android.os.Bundle
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -41,6 +48,8 @@ class MainApplication : Application(), IContainerProvider {
|
||||
clearAmSatCacheOnAppBackground()
|
||||
// trigger automatic update every 48 hours
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||
container.appScope.launch { checkLoTWAutoSync() }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
@@ -79,4 +88,37 @@ class MainApplication : Application(), IContainerProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
|
||||
* app start, but only pulls when due — LoTW credentials configured, the
|
||||
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
* Incremental when the callsign is unchanged; a callsign change falls back
|
||||
* to a full replace so no stale grids from another account linger.
|
||||
* Failures are silent and simply retried on the next app start — the manual
|
||||
* sync button still surfaces the explicit cause to the user.
|
||||
*/
|
||||
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||
val settingsRepo = container.settingsRepo
|
||||
val lotwSettings = settingsRepo.lotwSettings.value
|
||||
if (!shouldAutoSyncLoTW(
|
||||
isConfigured = lotwSettings.isConfigured,
|
||||
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
|
||||
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
|
||||
today = lotwSyncToday(timeNow)
|
||||
)
|
||||
) return
|
||||
val callsign = lotwSettings.callsign.trim().uppercase()
|
||||
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
|
||||
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
|
||||
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
|
||||
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||
if (result is LoTWResult.Success) {
|
||||
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
||||
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||
} else {
|
||||
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ import com.rtbishop.look4sat.core.presentation.Screen
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
@@ -104,33 +105,47 @@ fun NavRoot(deeplink: String? = null) {
|
||||
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||
slideOutHorizontally(tween(300)) { it }
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
low = otherSettings.lowElevation,
|
||||
high = otherSettings.highElevation
|
||||
)
|
||||
) {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
backStack = rootBackStack,
|
||||
onBack = navigateBack,
|
||||
transitionSpec = { pushTransition },
|
||||
popTransitionSpec = { popTransition },
|
||||
predictivePopTransitionSpec = { popTransition },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -155,6 +170,12 @@ fun MainScreen(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MutualViewModel.factory(container)
|
||||
)
|
||||
// Activity-scoped so the map's award filter survives page switches;
|
||||
// resets to VUCC only on cold start (fresh process).
|
||||
val mapFilterViewModel: MapFilterViewModel = viewModel(
|
||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||
factory = MapFilterViewModel.factory()
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalElevationThresholds provides ElevationThresholds(
|
||||
@@ -223,7 +244,16 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
entry<Screen.Map> {
|
||||
MapDestination()
|
||||
MapDestination(
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
onMatchGrid = { grid ->
|
||||
// Grid-QSO dialog "Match" button: pre-fill the
|
||||
// match page for that grid and switch to its tab.
|
||||
mutualViewModel.prefillMatchFromGrid(grid)
|
||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||
backStack.add(Screen.Mutual)
|
||||
}
|
||||
)
|
||||
}
|
||||
entry<Screen.Mutual> {
|
||||
MutualScreen(
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.other.okhttp)
|
||||
// android.jar's org.json is a stub ("not mocked") in unit tests;
|
||||
// provide a real implementation so JSON parsing is testable.
|
||||
testImplementation(libs.test.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,7 @@ interface Look4SatDao {
|
||||
|
||||
@Query("DELETE FROM radios")
|
||||
suspend fun deleteRadios()
|
||||
|
||||
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
@@ -19,19 +19,21 @@ package com.rtbishop.look4sat.core.data.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
|
||||
abstract class Look4SatDb : RoomDatabase() {
|
||||
abstract fun look4SatDao(): Look4SatDao
|
||||
}
|
||||
|
||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
||||
// database.execSQL("DROP TABLE entries")
|
||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
||||
// }
|
||||
//}
|
||||
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
||||
val argper: Double,
|
||||
val meanan: Double,
|
||||
val catnum: Int,
|
||||
val bstar: Double
|
||||
val bstar: Double,
|
||||
val ndot: Double = 0.0
|
||||
)
|
||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
||||
val uplinkHigh: Long?,
|
||||
val uplinkMode: String?,
|
||||
val isInverted: Boolean,
|
||||
val catnum: Int?
|
||||
val catnum: Int?,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -24,6 +24,7 @@ import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||
@@ -167,7 +168,10 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private fun provideLocalSource(): ILocalSource {
|
||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
||||
val database = builder
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
return LocalSource(database.look4SatDao())
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -221,7 +221,22 @@ class AmSatRepository(
|
||||
}
|
||||
}
|
||||
val days = (0 until 3).map { d ->
|
||||
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
|
||||
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
|
||||
val dayStart = nowSec - (d + 1) * 86400L
|
||||
val dayEnd = nowSec - d * 86400L
|
||||
val dayReports = byName[name].orEmpty()
|
||||
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
|
||||
.sortedByDescending { it.reportedTimeUtcSec }
|
||||
val streakCount = if (dayReports.isEmpty()) 0 else {
|
||||
val newestStatus = dayReports.first().report
|
||||
dayReports.takeWhile { it.report == newestStatus }.size
|
||||
}
|
||||
SatDay(
|
||||
dateLabel = labels[d],
|
||||
slots = slots.subList(d * 12, (d + 1) * 12),
|
||||
streakCount = streakCount
|
||||
)
|
||||
}
|
||||
SatStatus(name = name, days = days)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class DatabaseRepo(
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
localSource.insertRadios(transceivers)
|
||||
localSource.insertRadios(transceivers, isCustom = true)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
@@ -105,9 +105,16 @@ class DatabaseRepo(
|
||||
val normUrl = normalizeUrl(rawUrl)
|
||||
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
localSource.insertRadios(importedRadios)
|
||||
// insert parsed data into the database.
|
||||
// Transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
|
||||
// would bring them back if they were replaced here.
|
||||
if (importedRadios.isNotEmpty()) {
|
||||
localSource.deleteManagedRadios()
|
||||
localSource.insertRadios(importedRadios)
|
||||
}
|
||||
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||
setUpdateSuccessful(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
|
||||
+123
-19
@@ -18,9 +18,13 @@
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
@@ -46,7 +50,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||
@@ -58,9 +62,11 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
override suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): LoTWResult = withContext(Dispatchers.IO) {
|
||||
fetchReportBody(callsign, password).fold(
|
||||
fetchReportBody(callsign, password, since, onProgress).fold(
|
||||
onSuccess = { body ->
|
||||
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||
LoTWResult.Success(grids, qsos, roamed)
|
||||
@@ -87,33 +93,46 @@ class LoTWRepository : ILoTWRepository {
|
||||
return Triple(grids, qsos, roamed)
|
||||
}
|
||||
|
||||
private fun fetchReportBody(callsign: String, password: String): Result<String> {
|
||||
private suspend fun fetchReportBody(
|
||||
callsign: String,
|
||||
password: String,
|
||||
since: String,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): Result<String> {
|
||||
val call = callsign.trim().uppercase()
|
||||
val pwd = password.trim()
|
||||
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
|
||||
// Without it, LoTW applies a "system supplied default" since-date and
|
||||
// only returns confirmations newer than the account's last query —
|
||||
// subsequent syncs would return an empty/incremental report.
|
||||
val since = "2000-01-01"
|
||||
// Empty since -> full report. qso_qslsince with an early date forces a
|
||||
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
|
||||
// default" since-date and only returns confirmations newer than the
|
||||
// account's last query — subsequent syncs would be incremental/empty.
|
||||
val effectiveSince = since.ifBlank { "2000-01-01" }
|
||||
val query = buildString {
|
||||
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
||||
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
|
||||
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
|
||||
}
|
||||
return try {
|
||||
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||
// ARRL can be slow to accept connections from mobile networks
|
||||
// (long TLS handshakes across the Pacific, occasional server-side
|
||||
// queueing). 30s connect + 120s read gives the request enough
|
||||
// headroom; the sync button stays disabled meanwhile so users
|
||||
// see progress rather than a hung dialog.
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
// queueing — a busy server took 36.5 s to accept a connection in
|
||||
// testing, so 30 s connect would have killed it).
|
||||
//
|
||||
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
||||
// wait for the NEXT chunk of data (a stall timeout). A full report
|
||||
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
|
||||
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
|
||||
// as long as chunks keep arriving it must never be cut off.
|
||||
// 600 s tolerates long server-side pauses while it generates the
|
||||
// report; a truly dead link (no data at all) still fails within it.
|
||||
connection.connectTimeout = 60_000
|
||||
connection.readTimeout = 600_000
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||
connection.instanceFollowRedirects = true
|
||||
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299) {
|
||||
connection.disconnect()
|
||||
@@ -123,9 +142,8 @@ class LoTWRepository : ILoTWRepository {
|
||||
else Result.failure(IOException("HTTP $code"))
|
||||
}
|
||||
val stream = connection.inputStream
|
||||
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
|
||||
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
|
||||
.bufferedReader().use { it.readText() }
|
||||
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
||||
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
||||
connection.disconnect()
|
||||
when {
|
||||
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||
@@ -135,6 +153,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||
else -> Result.success(body)
|
||||
}
|
||||
} catch (e: java.util.concurrent.CancellationException) {
|
||||
// Cancellation must propagate — it is not a network failure.
|
||||
throw e
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||
} catch (e: SSLException) {
|
||||
@@ -145,6 +166,68 @@ class LoTWRepository : ILoTWRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the report body into a string while reporting download progress.
|
||||
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
|
||||
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
||||
* meaningful progress bar and remaining-time estimate.
|
||||
*/
|
||||
private suspend fun readBodyWithProgress(
|
||||
stream: java.io.InputStream,
|
||||
isGzip: Boolean,
|
||||
onProgress: (LoTWProgress) -> Unit
|
||||
): String {
|
||||
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
|
||||
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
|
||||
val sb = StringBuilder()
|
||||
val buf = CharArray(8192)
|
||||
var bytesRead = 0L
|
||||
var expected = 0L
|
||||
var qsoCount = 0L
|
||||
var speed = 0L
|
||||
var headerSeen = false
|
||||
var lastSampleMs = System.currentTimeMillis()
|
||||
var lastSampleBytes = 0L
|
||||
var lastEmitMs = 0L
|
||||
while (true) {
|
||||
// Cooperative cancellation: lets the user abort a full sync that
|
||||
// was started by mistake. Throws CancellationException once the
|
||||
// job is cancelled; the blocking read below is fast while chunks
|
||||
// keep arriving, so the abort lands on the next chunk boundary.
|
||||
coroutineContext.ensureActive()
|
||||
val n = reader.read(buf)
|
||||
if (n <= 0) break
|
||||
sb.append(buf, 0, n)
|
||||
bytesRead += n
|
||||
// The record count sits in the report header, long before the data.
|
||||
if (!headerSeen) {
|
||||
NUMREC_REGEX.find(sb)?.let { m ->
|
||||
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
|
||||
expected = qsoCount * AVG_RECORD_BYTES
|
||||
headerSeen = true
|
||||
}
|
||||
}
|
||||
// Sample speed every 500ms, EMA-smoothed.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSampleMs >= 500) {
|
||||
val elapsedS = (now - lastSampleMs) / 1000.0
|
||||
if (elapsedS > 0) {
|
||||
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
|
||||
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
|
||||
lastSampleMs = now
|
||||
lastSampleBytes = bytesRead
|
||||
}
|
||||
}
|
||||
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
|
||||
if (now - lastEmitMs >= 250) {
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
lastEmitMs = now
|
||||
}
|
||||
}
|
||||
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
internal class CredentialsException : Exception("bad callsign/password")
|
||||
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||
internal class TimeoutException(message: String) : Exception(message)
|
||||
@@ -169,6 +252,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
var country: String? = null
|
||||
var cqz: Int? = null
|
||||
var state: String? = null
|
||||
var myGrid: String? = null
|
||||
val gridsInRecord = mutableListOf<String>()
|
||||
|
||||
fun emitRecord() {
|
||||
@@ -177,7 +261,8 @@ class LoTWRepository : ILoTWRepository {
|
||||
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||
call = call, epochMs = epochMs, satName = satName,
|
||||
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state
|
||||
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||
myGrid = myGrid
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
@@ -188,6 +273,7 @@ class LoTWRepository : ILoTWRepository {
|
||||
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||
dxcc = null; country = null; cqz = null; state = null
|
||||
myGrid = null
|
||||
gridsInRecord.clear()
|
||||
}
|
||||
|
||||
@@ -222,6 +308,13 @@ class LoTWRepository : ILoTWRepository {
|
||||
cqz = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<STATE:") ->
|
||||
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||
// Own-station grid (must not be confused with GRIDSQUARE —
|
||||
// the opposite station's grid). Recorded per QSO so awards
|
||||
// can be counted per operated grid.
|
||||
val value = adifValue(line)
|
||||
if (value.length >= 4) myGrid = value.take(4).uppercase()
|
||||
}
|
||||
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||
// VUCC_GRIDS holds a comma-separated list of grids
|
||||
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||
@@ -352,5 +445,16 @@ class LoTWRepository : ILoTWRepository {
|
||||
|
||||
private companion object {
|
||||
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
|
||||
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
|
||||
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
||||
/**
|
||||
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
||||
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
||||
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
|
||||
* before the download truly ends — an under-estimate made the bar sit
|
||||
* at 100% while data was still flowing, read as "finished but stuck".
|
||||
*/
|
||||
const val AVG_RECORD_BYTES = 900L
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.TimeZone
|
||||
@@ -66,14 +68,19 @@ class SatelliteRepo(
|
||||
override suspend fun initRepository() = withContext(dispatcher) {
|
||||
combine(
|
||||
settingsRepo.selectedIds,
|
||||
settingsRepo.stationPosition
|
||||
) { selectedIds, _ -> selectedIds }
|
||||
settingsRepo.stationPosition,
|
||||
// Recalculate passes when the UTC display toggle changes, so an existing
|
||||
// AOS time window is reapplied in the newly selected timezone.
|
||||
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||
) { selectedIds, _, _ -> selectedIds }
|
||||
.collect { selectedIds ->
|
||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
val now = System.currentTimeMillis()
|
||||
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
||||
calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
@@ -175,7 +182,16 @@ class SatelliteRepo(
|
||||
aosEndMinute: Int,
|
||||
invertAosTimeWindow: Boolean
|
||||
): Boolean {
|
||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
||||
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
||||
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
|
||||
// shows UTC times while the filter silently uses local time — the two
|
||||
// disagree by the timezone offset (e.g. 8h for China).
|
||||
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||
TimeZone.getTimeZone("UTC")
|
||||
} else {
|
||||
TimeZone.getDefault()
|
||||
}
|
||||
val offsetMillis = tz.getOffset(aosTime).toLong()
|
||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||
val aosMinute = (localMillis / 60_000L).toInt()
|
||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||
|
||||
+19
-2
@@ -111,12 +111,29 @@ class SelectionRepo(
|
||||
/**
|
||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||
* and lowercases the query once up front instead of per-item.
|
||||
*
|
||||
* Fuzzy search: the query is split into space-separated tokens and every
|
||||
* token must appear in the satellite name after both sides are normalized
|
||||
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||
* previously failed whenever the name contained a separator the query lacked.
|
||||
*/
|
||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||
if (query.isBlank()) return items
|
||||
val catnum = query.toIntOrNull()
|
||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||
val lowerQuery = query.lowercase()
|
||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
||||
val tokens = query.split(' ')
|
||||
.map { normalizeForSearch(it) }
|
||||
.filter { it.isNotEmpty() }
|
||||
if (tokens.isEmpty()) return items
|
||||
return items.filter { item ->
|
||||
val normalizedName = normalizeForSearch(item.name)
|
||||
tokens.all { normalizedName.contains(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||
private fun normalizeForSearch(text: String): String =
|
||||
text.lowercase().filter { it.isLetterOrDigit() }
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import android.view.Display
|
||||
import android.view.Surface
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -41,7 +42,12 @@ class SensorsRepo(
|
||||
) : ISensorsRepo, SensorEventListener {
|
||||
|
||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
||||
private val _compassAccuracy = MutableStateFlow(
|
||||
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
|
||||
else CompassAccuracy.UNRELIABLE
|
||||
)
|
||||
private val rotationMatrix = FloatArray(9)
|
||||
private val tempMatrix = FloatArray(9)
|
||||
private val orientationValues = FloatArray(3)
|
||||
@@ -50,6 +56,7 @@ class SensorsRepo(
|
||||
private var hasInitialReading = false
|
||||
|
||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
||||
|
||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||
return GeomagneticField(
|
||||
@@ -62,15 +69,41 @@ class SensorsRepo(
|
||||
|
||||
override fun enableSensor() {
|
||||
hasInitialReading = false
|
||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||
if (rotationSensor == null || magneticSensor == null) {
|
||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||
return
|
||||
}
|
||||
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
|
||||
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
|
||||
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
|
||||
if (!rotationRegistered || !magneticRegistered) {
|
||||
sensorManager.unregisterListener(this)
|
||||
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
|
||||
updateAccuracy(accuracy)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||
when (event.sensor.type) {
|
||||
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
|
||||
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccuracy(accuracy: Int) {
|
||||
_compassAccuracy.value = when (accuracy) {
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
|
||||
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
|
||||
else -> CompassAccuracy.UNRELIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayRotation(): Int {
|
||||
|
||||
@@ -58,6 +58,7 @@ class SettingsRepo(
|
||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||
private val keyFilterHoursBefore = "filterHoursBefore"
|
||||
private val keyFilterMinElevation = "filterMinElevation"
|
||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||
@@ -77,12 +78,15 @@ class SettingsRepo(
|
||||
private val keySelectedTypes = "selectedTypes"
|
||||
private val keySelectedModes = "selectedModes"
|
||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||
private val keyStateOfSensors = "stateOfSensors"
|
||||
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
||||
private val keyStateOfSweep = "stateOfSweep"
|
||||
private val keyStateOfUtc = "stateOfUtc"
|
||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||
private val keyStateOfNightMode = "stateOfNightMode"
|
||||
private val keyStateOfMapGrid = "stateOfMapGrid"
|
||||
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
||||
private val keyStationAltitude = "stationAltitude"
|
||||
private val keyStationLatitude = "stationLatitude"
|
||||
private val keyStationLongitude = "stationLongitude"
|
||||
@@ -204,7 +208,9 @@ class SettingsRepo(
|
||||
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||
country = o.optString("cty").ifBlank { null },
|
||||
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||
state = o.optString("st").ifBlank { null }
|
||||
state = o.optString("st").ifBlank { null },
|
||||
// Own-grid field: absent in pre-myGrid data -> null.
|
||||
myGrid = o.optString("mg").ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (list.isNotEmpty()) result[grid] = list
|
||||
@@ -232,6 +238,7 @@ class SettingsRepo(
|
||||
.put("cty", q.country ?: "")
|
||||
.put("cq", q.cqz ?: 0)
|
||||
.put("st", q.state ?: "")
|
||||
.put("mg", q.myGrid ?: "")
|
||||
)
|
||||
}
|
||||
root.put(grid, array)
|
||||
@@ -286,6 +293,24 @@ class SettingsRepo(
|
||||
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||
)
|
||||
|
||||
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
|
||||
// decide incremental (same callsign) vs full (first time / callsign change)
|
||||
// report requests and to merge increments into the stored grid data.
|
||||
private val keyLastLotwSyncDate = "lotwLastSyncDate"
|
||||
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
|
||||
|
||||
override fun getLastLotwSyncDate(): String =
|
||||
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncDate(date: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncDate, date) }
|
||||
|
||||
override fun getLastLotwSyncCallsign(): String =
|
||||
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
|
||||
|
||||
override fun setLastLotwSyncCallsign(callsign: String) =
|
||||
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||
//endregion
|
||||
|
||||
//region # Transceivers settings
|
||||
@@ -295,6 +320,7 @@ class SettingsRepo(
|
||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||
@@ -306,6 +332,7 @@ class SettingsRepo(
|
||||
private fun getPassesSettings(): PassesSettings {
|
||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
|
||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||
@@ -319,7 +346,8 @@ class SettingsRepo(
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
selectedModes
|
||||
selectedModes,
|
||||
hoursBefore
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
@@ -517,12 +545,15 @@ class SettingsRepo(
|
||||
val new = transform(current)
|
||||
preferences.edit {
|
||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
@@ -535,12 +566,15 @@ class SettingsRepo(
|
||||
|
||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
|
||||
@@ -51,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
|
||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||
)
|
||||
|
||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||
@@ -73,20 +73,22 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||
}
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||
|
||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||
|
||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
||||
)
|
||||
|
||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
|
||||
)
|
||||
|
||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||
|
||||
+66
-2
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class AmSatRepositoryTest {
|
||||
|
||||
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
|
||||
assertEquals(1, remoteSource.catalogRequests)
|
||||
assertEquals(1, remoteSource.reportRequests)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
|
||||
report("r1", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r2", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r3", "AO-7", "Not Heard", nowSec - 7000),
|
||||
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
|
||||
report("r4", "AO-7", "Heard", nowSec - 63000),
|
||||
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
|
||||
report("r5", "AO-7", "Heard", nowSec - 90000),
|
||||
report("r6", "AO-7", "Not Heard", nowSec - 100000)
|
||||
// day 2 无报告 → 0
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
val status = page?.statuses?.single()
|
||||
assertEquals("AO-7", status?.name)
|
||||
assertEquals(3, status?.days?.size)
|
||||
|
||||
assertEquals(2, status?.days?.get(0)?.streakCount)
|
||||
assertEquals(1, status?.days?.get(1)?.streakCount)
|
||||
assertEquals(0, status?.days?.get(2)?.streakCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
|
||||
val nowSec = System.currentTimeMillis() / 1000
|
||||
val remoteSource = FakeAmSatRemoteSource(
|
||||
reportsJson = amSatReportsJson(
|
||||
report("r1", "AO-7", "Heard", nowSec - 1800),
|
||||
report("r2", "AO-7", "Heard", nowSec - 3600),
|
||||
report("r3", "AO-7", "Heard", nowSec - 5400),
|
||||
report("r4", "AO-7", "Heard", nowSec - 30000)
|
||||
)
|
||||
)
|
||||
val repository = AmSatRepository(remoteSource)
|
||||
|
||||
val page = repository.fetchStatus()
|
||||
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||
cacheField.set(this, page)
|
||||
}
|
||||
|
||||
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
|
||||
private class FakeAmSatRemoteSource(
|
||||
private val responseDelayMillis: Long = 0L,
|
||||
private val reportsJson: String = """{"data":[]}"""
|
||||
) : IRemoteSource {
|
||||
var catalogRequests = 0
|
||||
var reportRequests = 0
|
||||
|
||||
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||
reportRequests += 1
|
||||
return """{"data":[]}"""
|
||||
return reportsJson
|
||||
}
|
||||
|
||||
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||
}
|
||||
|
||||
private fun amSatReportsJson(vararg reports: String): String =
|
||||
"""{"data":[${reports.joinToString(",")}]}"""
|
||||
|
||||
private fun report(id: String, name: String, status: String, epochSec: Long): String =
|
||||
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
|
||||
|
||||
private fun isoUtc(epochSec: Long): String =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}.format(Date(epochSec * 1000))
|
||||
+10
-2
@@ -185,8 +185,12 @@ private class FakeLocalSource : ILocalSource {
|
||||
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
|
||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||
insertedRadios += radios
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
|
||||
}
|
||||
|
||||
override suspend fun deleteManagedRadios() {
|
||||
insertedRadios.removeAll { !it.isCustom }
|
||||
}
|
||||
|
||||
override suspend fun deleteRadios() {
|
||||
@@ -292,6 +296,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
|
||||
|
||||
+19
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.core.data.repository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWRepositoryTest {
|
||||
@@ -150,6 +151,24 @@ class LoTWRepositoryTest {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOwnGridPerQso() {
|
||||
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
|
||||
// alphabetically); the per-record buffer must still attach it to the QSO.
|
||||
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
|
||||
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
|
||||
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
|
||||
assertEquals("OL62", result["NL47"]!!.first().myGrid)
|
||||
assertEquals("OL72", result["OK48"]!!.first().myGrid)
|
||||
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
|
||||
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
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.model.SatItem
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SelectionRepoSearchTest {
|
||||
|
||||
private val sampleItems = listOf(
|
||||
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
|
||||
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
|
||||
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
|
||||
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
|
||||
SatItem(catnum = 99999, name = "FO-29"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with dashes spaces brackets`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("ao7")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
|
||||
// key guarantee is that AO-7 — which was unreachable before because of
|
||||
// the dashes/brackets — is now found.
|
||||
assertTrue(results.map { it.catnum }.contains(7530))
|
||||
assertEquals(7530, results.first().catnum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without separators matches name with only dashes`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("fo29")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(99999), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `space-separated tokens all must match in any order`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zarya iss")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact name query still matches`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(7530), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial token query matches substring`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("funcube")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(39444), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric query matches catnum exactly`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("25544")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(listOf(25544), results.map { it.catnum })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no query returns all items`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("")
|
||||
val results = repo.getEntriesFlow().first()
|
||||
assertEquals(sampleItems.size, results.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmatched query returns nothing`() = runTest {
|
||||
val repo = createRepo(sampleItems)
|
||||
repo.setQuery("zzzznomatch")
|
||||
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||
}
|
||||
|
||||
private fun createRepo(items: List<SatItem>): ISelectionRepo {
|
||||
return SelectionRepo(
|
||||
dispatcher = Dispatchers.Unconfined,
|
||||
localSource = FakeLocalSourceForSearch(items),
|
||||
settingsRepo = FakeSettingsRepoForSearch()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeLocalSourceForSearch(private val items: List<SatItem>) : ILocalSource {
|
||||
override suspend fun getEntriesTotal(): Int = items.size
|
||||
override suspend fun getEntriesList(): List<SatItem> = items
|
||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||
override suspend fun deleteEntries() = Unit
|
||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||
override suspend fun getRadiosTotal(): Int = 0
|
||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||
override suspend fun deleteManagedRadios() = Unit
|
||||
override suspend fun deleteRadios() = Unit
|
||||
}
|
||||
|
||||
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||
)
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(
|
||||
false, false, false, false, false, false, false,
|
||||
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
|
||||
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||
override fun setSelectedTypes(types: List<String>) = Unit
|
||||
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
|
||||
override fun updateDatabaseState(state: DatabaseState) = Unit
|
||||
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = Unit
|
||||
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||
override fun setWorkedGrids(grids: Set<String>) = Unit
|
||||
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||
override fun setRoamedGrids(grids: Set<String>) = Unit
|
||||
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
@@ -48,7 +48,9 @@ data class GridQso(
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null
|
||||
val state: String? = null,
|
||||
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
|
||||
val myGrid: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
@@ -32,5 +32,7 @@ data class SatRadio(
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?
|
||||
@SerialName("norad_cat_id") val catnum: Int?,
|
||||
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||
val isCustom: Boolean = false
|
||||
)
|
||||
@@ -20,7 +20,8 @@ data class SatSlot(
|
||||
/** One satellite day (12 two-hour slots) */
|
||||
data class SatDay(
|
||||
val dateLabel: String, // "Aug 4"
|
||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
||||
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
||||
)
|
||||
|
||||
/** One satellite, 3 days of state */
|
||||
|
||||
@@ -30,7 +30,9 @@ data class PassesSettings(
|
||||
val aosStartMinute: Int = 0,
|
||||
val aosEndMinute: Int = 23 * 60 + 59,
|
||||
val invertAosTimeWindow: Boolean = false,
|
||||
val selectedModes: List<String>
|
||||
val selectedModes: List<String>,
|
||||
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
|
||||
val hoursBefore: Int = 0
|
||||
)
|
||||
|
||||
data class RCSettings(
|
||||
@@ -60,11 +62,20 @@ data class OtherSettings(
|
||||
val stateOfLightTheme: Boolean,
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val stateOfMapGrid: Boolean = false,
|
||||
/** Grid mode: label worked (green) cells with the FIRST callsign worked
|
||||
* in that grid instead of the Maidenhead code; non-worked cells get no
|
||||
* label at all. */
|
||||
val stateOfMapFirstCall: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto",
|
||||
val lowElevation: Double = 15.0,
|
||||
val highElevation: Double = 45.0
|
||||
val highElevation: Double = 45.0,
|
||||
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
||||
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||
val stateOfAutoLotwSync: Boolean = true,
|
||||
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
||||
val compassOffsetDegrees: Float = 0f
|
||||
)
|
||||
|
||||
data class DataSourcesSettings(
|
||||
|
||||
+35
-1
@@ -30,9 +30,43 @@ interface ILoTWRepository {
|
||||
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||
* gridsquare. Failure cause is kept explicit.
|
||||
*
|
||||
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
|
||||
* date for a full report, or the last sync date for an
|
||||
* incremental pull. Empty falls back to a full report.
|
||||
* @param onProgress streamed download progress (phase + bytes + estimate),
|
||||
* invoked from the IO dispatcher; may be called on every
|
||||
* read chunk.
|
||||
*/
|
||||
suspend fun fetchConfirmedGridQsos(
|
||||
callsign: String,
|
||||
password: String
|
||||
password: String,
|
||||
since: String = "",
|
||||
onProgress: (LoTWProgress) -> Unit = {}
|
||||
): LoTWResult
|
||||
}
|
||||
|
||||
/** Streamed progress of a LoTW report download. */
|
||||
data class LoTWProgress(
|
||||
val phase: LoTWPhase,
|
||||
/** Bytes read so far (uncompressed body). */
|
||||
val bytesRead: Long = 0,
|
||||
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
|
||||
val expectedBytes: Long = 0,
|
||||
/** Smoothed download speed in bytes/second; 0 while unknown. */
|
||||
val speedBps: Long = 0,
|
||||
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
|
||||
val qsoCount: Long = 0
|
||||
) {
|
||||
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
|
||||
val fraction: Float
|
||||
get() = if (expectedBytes > 0 && bytesRead > 0)
|
||||
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
|
||||
|
||||
/** Estimated seconds remaining; 0 while size or speed is unknown. */
|
||||
val remainingSeconds: Long
|
||||
get() = if (expectedBytes > 0 && speedBps > 0)
|
||||
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
|
||||
}
|
||||
|
||||
enum class LoTWPhase { Connecting, Downloading }
|
||||
+11
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
||||
enum class CompassAccuracy {
|
||||
UNAVAILABLE,
|
||||
UNRELIABLE,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
||||
val compassAccuracy: StateFlow<CompassAccuracy>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
|
||||
+6
@@ -110,5 +110,11 @@ interface ISettingsRepo {
|
||||
//region # LoTW confirmed-grids settings
|
||||
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
|
||||
fun getLastLotwSyncDate(): String
|
||||
fun setLastLotwSyncDate(date: String)
|
||||
/** Callsign of the last successful LoTW sync (empty when never synced). */
|
||||
fun getLastLotwSyncCallsign(): String
|
||||
fun setLastLotwSyncCallsign(callsign: String)
|
||||
//endregion
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
|
||||
* and the automatic sync on app start (MainApplication), so the two paths can
|
||||
* never drift apart:
|
||||
*
|
||||
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
|
||||
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
|
||||
* deduped by call + QSO time).
|
||||
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
|
||||
* gate granularity.
|
||||
*/
|
||||
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/**
|
||||
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
|
||||
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
|
||||
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
|
||||
* ignored by LoTW, which falls back to its own system-supplied default),
|
||||
* and it keeps enough precision to display the last-sync time like the
|
||||
* ephemeris update time.
|
||||
*/
|
||||
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.format(Date(now))
|
||||
|
||||
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
|
||||
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
|
||||
fun lotwCursorDate(cursor: String): String = when {
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
|
||||
else -> cursor.take(8)
|
||||
}
|
||||
|
||||
/** qso_qslsince value for the API: passes the full timestamp through and
|
||||
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
|
||||
fun lotwCursorApi(cursor: String): String = when {
|
||||
cursor.isBlank() -> ""
|
||||
cursor.length >= 10 && cursor[4] == '-' -> cursor
|
||||
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
|
||||
}
|
||||
|
||||
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
|
||||
fun lotwCursorEpochMs(cursor: String): Long = try {
|
||||
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
|
||||
SimpleDateFormat(pattern, Locale.US)
|
||||
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
.parse(cursor)?.time ?: 0L
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective sync mode for a given request:
|
||||
* - an explicit [LoTWSyncMode.Full] request is always honored;
|
||||
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
|
||||
* matches the last sync — a first sync, an unknown/empty record or a
|
||||
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
|
||||
* another account linger.
|
||||
* With [requested] == null (automatic sync) it picks incremental whenever
|
||||
* possible and full otherwise.
|
||||
*/
|
||||
fun resolveLoTWSyncMode(
|
||||
lastSyncCallsign: String,
|
||||
callsign: String,
|
||||
requested: LoTWSyncMode?
|
||||
): LoTWSyncMode {
|
||||
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
|
||||
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
|
||||
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
|
||||
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
|
||||
* least one prior manual sync (the first sync stays manual/full), and not
|
||||
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||
*/
|
||||
fun shouldAutoSyncLoTW(
|
||||
isConfigured: Boolean,
|
||||
autoLotwSyncEnabled: Boolean,
|
||||
lastSyncDate: String,
|
||||
today: String
|
||||
): Boolean {
|
||||
val lastDate = lotwCursorDate(lastSyncDate)
|
||||
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a successful LoTW report to the stored grid data and advances the
|
||||
* sync cursor so the next incremental pull asks LoTW for only the
|
||||
* confirmations since today. Persistence runs on the IO dispatcher — building
|
||||
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
|
||||
* thread, and for large accounts (multi-MB JSON) that froze the main thread
|
||||
* after a manual sync ('bar done but app stuck').
|
||||
*
|
||||
* @return the resulting number of worked grids.
|
||||
*/
|
||||
suspend fun applyLoTWGridResult(
|
||||
settingsRepo: ISettingsRepo,
|
||||
result: LoTWResult.Success,
|
||||
mode: LoTWSyncMode,
|
||||
callsign: String,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): Int {
|
||||
val existingGrids = settingsRepo.getWorkedGrids()
|
||||
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
|
||||
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
|
||||
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
|
||||
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
|
||||
settingsRepo.setLastLotwSyncCallsign(callsign)
|
||||
withContext(Dispatchers.IO) {
|
||||
settingsRepo.setWorkedGrids(mergedGrids)
|
||||
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||
}
|
||||
return mergedGrids.size
|
||||
}
|
||||
|
||||
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
||||
fun mergeGridQsos(
|
||||
existing: Map<String, List<GridQso>>,
|
||||
fresh: Map<String, List<GridQso>>
|
||||
): Map<String, List<GridQso>> {
|
||||
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
|
||||
fresh.forEach { (grid, list) ->
|
||||
val target = merged.getOrPut(grid) { mutableListOf() }
|
||||
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
|
||||
list.forEach { qso ->
|
||||
if (known.add(qso.call to qso.epochMs)) target.add(qso)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -46,3 +46,6 @@ sealed class LoTWResult {
|
||||
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||
data class NetworkError(val detail: String) : LoTWResult()
|
||||
}
|
||||
|
||||
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
|
||||
enum class LoTWSyncMode { Full, Incremental }
|
||||
@@ -31,6 +31,9 @@ interface ILocalSource {
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
suspend fun getRadiosTotal(): Int
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
suspend fun insertRadios(radios: List<SatRadio>)
|
||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
||||
|
||||
/** Delete all non-custom transceivers, keeping manually imported ones. */
|
||||
suspend fun deleteManagedRadios()
|
||||
suspend fun deleteRadios()
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.core.domain.predict
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import kotlin.math.abs
|
||||
|
||||
class CelestialComputerTest {
|
||||
|
||||
private data class RiseSetCase(
|
||||
val name: String,
|
||||
val observer: GeoPos,
|
||||
val startIso: String
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||
val cases = listOf(
|
||||
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||
)
|
||||
|
||||
cases.forEach { testCase ->
|
||||
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||
|
||||
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||
|
||||
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||
|
||||
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||
|
||||
private companion object {
|
||||
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* 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.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
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.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWGridSyncTest {
|
||||
|
||||
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
|
||||
private val now = 1_789_516_800_000L
|
||||
|
||||
// region resolveLoTWSyncMode
|
||||
|
||||
@Test
|
||||
fun resolveModeHonorsExplicitFull() {
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
|
||||
assertEquals(
|
||||
LoTWSyncMode.Incremental,
|
||||
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
assertEquals(
|
||||
LoTWSyncMode.Full,
|
||||
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
|
||||
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
|
||||
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region shouldAutoSyncLoTW
|
||||
|
||||
@Test
|
||||
fun autoSyncGateRequiresEverything() {
|
||||
val today = "20260916"
|
||||
// Not configured.
|
||||
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
|
||||
// LoTW auto-sync toggle off.
|
||||
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
|
||||
// Never synced manually — first sync stays manual/full.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
|
||||
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
|
||||
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
|
||||
// All gates open — yesterday in either format.
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
|
||||
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region cursor helpers
|
||||
|
||||
@Test
|
||||
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
|
||||
assertEquals("2026-09-16", lotwCursorApi("20260916"))
|
||||
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorApi(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorDateToleratesBothFormats() {
|
||||
assertEquals("20260916", lotwCursorDate("20260916"))
|
||||
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
|
||||
assertEquals("", lotwCursorDate(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cursorEpochMsParsesBothFormatsUtc() {
|
||||
// 2026-09-16T00:00:00Z in both spellings.
|
||||
assertEquals(now, lotwCursorEpochMs("20260916"))
|
||||
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
|
||||
assertEquals(0L, lotwCursorEpochMs(""))
|
||||
assertEquals(0L, lotwCursorEpochMs("garbage"))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region mergeGridQsos
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosDedupsByCallAndTime() {
|
||||
val existing = mapOf(
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
"OL63" to listOf(qso("BA7DEF", 2000L))
|
||||
)
|
||||
val fresh = mapOf(
|
||||
// Same grid, same QSO re-delivered by an overlapping since-date.
|
||||
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||
// Same grid, brand-new QSO.
|
||||
"OL62" to listOf(qso("BA7GHI", 3000L)),
|
||||
// New grid entirely.
|
||||
"PM95" to listOf(qso("BA7JKL", 4000L))
|
||||
)
|
||||
val merged = mergeGridQsos(existing, fresh)
|
||||
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
|
||||
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
|
||||
assertEquals(1, merged["OL63"]?.size)
|
||||
assertEquals(1, merged["PM95"]?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeGridQsosHandlesEmptySides() {
|
||||
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
|
||||
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
|
||||
// An empty incremental pull must leave the stored side untouched.
|
||||
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
|
||||
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region applyLoTWGridResult
|
||||
|
||||
@Test
|
||||
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62"))
|
||||
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
|
||||
setRoamedGrids(setOf("OL62"))
|
||||
setLastLotwSyncDate("20260915")
|
||||
setLastLotwSyncCallsign("BA7OPF")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("OL62", "PM95"),
|
||||
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
|
||||
|
||||
assertEquals(2, count)
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
|
||||
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
|
||||
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
|
||||
val repo = FakeSettingsRepo().apply {
|
||||
setWorkedGrids(setOf("OL62", "STALE"))
|
||||
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
|
||||
setRoamedGrids(setOf("STALE"))
|
||||
setLastLotwSyncDate("20260910")
|
||||
setLastLotwSyncCallsign("BG7OLD")
|
||||
}
|
||||
val result = LoTWResult.Success(
|
||||
grids = setOf("PM95"),
|
||||
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||
roamedGrids = setOf("PM95")
|
||||
)
|
||||
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
|
||||
|
||||
assertEquals(1, count)
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGrids())
|
||||
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
|
||||
assertEquals(setOf("PM95"), repo.getRoamedGrids())
|
||||
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun qso(call: String, epochMs: Long): GridQso =
|
||||
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
|
||||
|
||||
/**
|
||||
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
|
||||
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
|
||||
*/
|
||||
private class FakeSettingsRepo : ISettingsRepo {
|
||||
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
|
||||
override val appVersionName: String = "test"
|
||||
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
|
||||
)
|
||||
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(
|
||||
stateOfAutoUpdate = true,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
)
|
||||
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
private var workedGrids: Set<String> = emptySet()
|
||||
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||
private var roamedGrids: Set<String> = emptySet()
|
||||
private var lastSyncDate: String = ""
|
||||
private var lastSyncCallsign: String = ""
|
||||
|
||||
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||
override fun getLastLotwSyncDate(): String = lastSyncDate
|
||||
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
|
||||
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -375,7 +375,10 @@ fun SharedDialog(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
||||
.padding(
|
||||
start = if (onCancel != null) padding else 0.dp,
|
||||
end = if (onAccept != null) padding else 0.dp
|
||||
)
|
||||
)
|
||||
if (onAccept != null) {
|
||||
CardButton(
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Filtrar pases</string>
|
||||
<string name="pass_filter_elev">Elevación mínima</string>
|
||||
<string name="pass_filter_hours">Tiempo por delante</string>
|
||||
<string name="pass_filter_history_hours">Tiempo anterior</string>
|
||||
<string name="pass_filter_aos_time">Ventana AOS</string>
|
||||
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (período >225min)</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">Otros ajustes</string>
|
||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Sincronizar automáticamente las cuadrículas LoTW confirmadas</string>
|
||||
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
|
||||
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
|
||||
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<string name="pass_filter_title">Фильтровать пролеты</string>
|
||||
<string name="pass_filter_elev">Минимальная элевация</string>
|
||||
<string name="pass_filter_hours">Период расчёта</string>
|
||||
<string name="pass_filter_history_hours">Прошедший период</string>
|
||||
<string name="pass_filter_aos_time">Окно AOS</string>
|
||||
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (период >225мин)</string>
|
||||
@@ -146,6 +147,7 @@
|
||||
<string name="prefs_other_title">Другие настройки</string>
|
||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронизация подтверждённых сеток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
|
||||
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
|
||||
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
|
||||
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
|
||||
<string name="pass_filter_hours">ඉදිරි කාලය</string>
|
||||
<string name="pass_filter_history_hours">පෙර කාලය</string>
|
||||
<string name="pass_filter_aos_time">AOS කවුළුව</string>
|
||||
<string name="pass_filter_invert_time">AOS කවුළුව ප්රතිවිරුද්ධ කරන්න</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (කාලය >225min)</string>
|
||||
@@ -70,7 +71,7 @@
|
||||
<string name="map_next">ඊළග</string>
|
||||
<string name="map_azimuth">දිගංශය: %.1f°</string>
|
||||
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
|
||||
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
|
||||
<string name="map_altitude">උන්නතාශය: %.0f km</string>
|
||||
<string name="map_distance">පරතරය: %.0f km</string>
|
||||
<string name="map_latitude">අක්ෂාංශ: %.1f°</string>
|
||||
<string name="map_longitude">දේශාංශ: %.1f°</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
|
||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
|
||||
<string name="prefs_other_switch_lotw_sync">LoTW තහවුරු කළ ජාල ස්වයං සමමුහුර්තකරණය</string>
|
||||
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
|
||||
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
|
||||
<string name="prefs_other_switch_night_mode">රතු රාත්රී පෙරහන සබල කරන්න</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Geçişleri filtrele</string>
|
||||
<string name="pass_filter_elev">Minimum yükseklik açısı</string>
|
||||
<string name="pass_filter_hours">Gösterilecek zaman</string>
|
||||
<string name="pass_filter_history_hours">Geçmiş süre</string>
|
||||
<string name="pass_filter_aos_time">AOS aralığı</string>
|
||||
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (periyot >225dk)</string>
|
||||
@@ -189,6 +190,7 @@
|
||||
<string name="prefs_other_title">Diğer ayarlar</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_lotw_sync">LoTW onaylı kareleri otomatik senkronize et</string>
|
||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
|
||||
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
|
||||
@@ -207,7 +209,7 @@
|
||||
\n• Alexandru Csete (Gpredict)
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• BG7NTA (CW decoding feature)</string>
|
||||
\n• BG7NTA BG5JSU (CW decoding feature)</string>
|
||||
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
|
||||
|
||||
</resources>
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="pass_filter_title">Фільтр прольотів</string>
|
||||
<string name="pass_filter_elev">Мінімальне піднесень</string>
|
||||
<string name="pass_filter_hours">Час вперед</string>
|
||||
<string name="pass_filter_history_hours">Час назад</string>
|
||||
<string name="pass_filter_aos_time">Вікно AOS</string>
|
||||
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (період >225хв)</string>
|
||||
@@ -147,6 +148,7 @@
|
||||
<string name="prefs_other_title">Інші налаштування</string>
|
||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
|
||||
<string name="prefs_other_switch_lotw_sync">Автосинхронізація підтверджених сіток LoTW</string>
|
||||
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
|
||||
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
|
||||
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="pass_filter_title">筛选过境</string>
|
||||
<string name="pass_filter_elev">仰角值</string>
|
||||
<string name="pass_filter_hours">提前时间</string>
|
||||
<string name="pass_filter_history_hours">历史过境时间</string>
|
||||
<string name="pass_filter_aos_time">AOS 时间段</string>
|
||||
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace(周期 >225分钟)</string>
|
||||
@@ -40,7 +41,9 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
|
||||
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||
<string name="amsat_refresh">刷新</string>
|
||||
<string name="amsat_retry">重试</string>
|
||||
@@ -109,6 +112,7 @@
|
||||
<string name="map_next">下一个</string>
|
||||
<string name="map_grid_mode">网格模式</string>
|
||||
<string name="map_satellite_mode">卫星模式</string>
|
||||
<string name="map_first_call_mode">首通呼号</string>
|
||||
<string name="map_azimuth">方位角:%.1f°</string>
|
||||
<string name="map_elevation">仰角:%.1f°</string>
|
||||
<string name="map_altitude">高度:%.0f km</string>
|
||||
@@ -121,9 +125,12 @@
|
||||
<string name="map_velocity">速度:%.2f km/s</string>
|
||||
<string name="map_visibility">能见度:%s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_calls_count" formatted="false">%d 个呼号 · %d 次通联</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">首通</string>
|
||||
<string name="grid_qso_no_sat">卫星未知</string>
|
||||
<string name="grid_qso_match">匹配</string>
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -131,7 +138,7 @@
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||
<string name="prefs_updated_time">yyyy年M月d日 HH:mm:ss</string>
|
||||
|
||||
<string name="prefs_loc_title">站点位置</string>
|
||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||
@@ -158,11 +165,23 @@
|
||||
<string name="prefs_lotw_callsign">LoTW 呼号</string>
|
||||
<string name="prefs_lotw_password">LoTW 密码</string>
|
||||
<string name="prefs_lotw_hint">直接从 ARRL LoTW 拉取该呼号已确认 QSO 的网格并覆盖本地已存集合(当前 %1$d 个)。密码仅保存在手机本地。点"同步"会自动保存凭据并拉取。</string>
|
||||
<string name="prefs_lotw_sync_duration_hint">同步通常约需 3–10 秒;首次同步或 ARRL 服务器繁忙时可能更久。</string>
|
||||
<string name="prefs_lotw_sync">同步</string>
|
||||
<string name="prefs_lotw_syncing">同步中…</string>
|
||||
<string name="prefs_lotw_configured">已从 LoTW 同步 — 共 %1$d 个网格</string>
|
||||
<string name="prefs_lotw_not_configured">未配置 — 直接登录 ARRL LoTW 拉取已确认 QSO 的网格,无需自建服务器</string>
|
||||
<string name="lotw_sync_error_not_configured">LoTW 呼号/密码未配置</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW 同步失败——呼号或密码错误</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW 同步失败——服务器限流,请等几分钟后再试</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW 同步失败——连接超时,请更换网络重试</string>
|
||||
<string name="lotw_sync_error_network">LoTW 同步失败——网络错误(%1$s)</string>
|
||||
<string name="lotw_sync_full">全量同步</string>
|
||||
<string name="lotw_sync_incremental">增量合并</string>
|
||||
<string name="lotw_sync_rate_hint">下载速度约每 100 条 QSO 需 8 秒(另有 5–40 秒服务器生成时间)</string>
|
||||
<string name="lotw_sync_progress_connecting">正在连接 LoTW 服务器…</string>
|
||||
<string name="lotw_sync_progress_downloading">正在下载报告…</string>
|
||||
<string name="lotw_sync_progress_saving">正在处理下载的数据…</string>
|
||||
<string name="lotw_sync_progress_count">本次同步 %1$d 条 QSO…</string>
|
||||
<string name="lotw_sync_progress_qso">本次同步 %1$d 条 QSO,预计还需 %2$d 秒</string>
|
||||
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||
|
||||
<string name="prefs_data_title">卫星数据更新</string>
|
||||
@@ -223,8 +242,22 @@
|
||||
<string name="prefs_other_title">其他设置</string>
|
||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||
<string name="prefs_other_switch_lotw_sync">自动同步 LoTW 已确认网格</string>
|
||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
|
||||
<string name="prefs_compass_calibration_button">校准指南针</string>
|
||||
<string name="prefs_compass_calibration_title">指南针校准</string>
|
||||
<string name="prefs_compass_calibration_instruction">缓慢移动手机画“8”字,直到精度提高。请远离磁铁和金属物体。</string>
|
||||
<string name="prefs_compass_accuracy">精度:%s</string>
|
||||
<string name="prefs_compass_accuracy_high">高——校准完成</string>
|
||||
<string name="prefs_compass_accuracy_medium">中——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_low">低——请继续移动</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">不可靠——请开始“8”字动作</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">设备没有可用的磁场传感器</string>
|
||||
<string name="prefs_compass_heading">校正后航向:%.1f°</string>
|
||||
<string name="prefs_compass_offset">手动航向偏置(°)</string>
|
||||
<string name="prefs_compass_offset_support">范围 −180° 至 +180°;正值为顺时针偏移。</string>
|
||||
<string name="prefs_compass_offset_reset">偏置归零</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
@@ -242,7 +275,7 @@
|
||||
\n• Dr T.S. Kelso (Celestrak)
|
||||
\n• Libre Space Foundation (SatNOGS)
|
||||
\n• xdsopl 和 Robot36 贡献者!
|
||||
\n• BG7NTA(CW解码功能)</string>
|
||||
\n• BG7NTA BG5JSU(CW解码功能)</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<string name="pass_filter_title">Filter passes</string>
|
||||
<string name="pass_filter_elev">Minimal elevation</string>
|
||||
<string name="pass_filter_hours">Time ahead</string>
|
||||
<string name="pass_filter_history_hours">Time before</string>
|
||||
<string name="pass_filter_aos_time">AOS window</string>
|
||||
<string name="pass_filter_invert_time">Invert AOS window</string>
|
||||
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
|
||||
@@ -51,10 +52,7 @@
|
||||
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.</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>
|
||||
<string name="pass_whatsnew_message">All changes since 4.4.6 (the 4.4.7 series):\n\n• Grid-mode map: LoTW confirmed grids in green, roamed grids with blue stripes, current grid highlighted; mode switch pill at the map\'s top-right.\n\n• First-call labels (VUCC view): show the first callsign worked in each green grid instead of the grid code; non-worked cells stay empty.\n\n• Worked grids filtered by operated grid (with an All option); tap a grid to see its QSO detail dialog; per-grid QSO counts.\n\n• Per-operated-grid VUCC counting; polar grid labels; antimeridian fixes; consistent roamed-stripe density.\n\n• WAPC/WAJA map labels follow the system language.\n\n• Grid-mode viewport and award filter survive page switches.\n\n• LoTW sync: direct login, full/incremental modes, live progress, cancel button, auto-sync on app start with its own toggle, last-sync time shown.\n\n• Pass history: filter can look back over past passes (Time before).\n\n• Compass calibration: figure-eight accuracy meter plus manual heading offset.\n\n• AMSAT status page: consecutive same-status report counts.\n\n• Passes/radar: UTC toggle recalculates passes; sunrise/sunset and elevation color fixes; satellite fuzzy search; mutual page keeps scroll, follows UTC, radar shortcut.\n\n• CW decoder for 32/64-bit devices; Android 17 (API 37) support.</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<string name="nav_amsat">AMSAT</string>
|
||||
@@ -133,6 +131,7 @@
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_grid_mode">Grid mode</string>
|
||||
<string name="map_satellite_mode">Satellite mode</string>
|
||||
<string name="map_first_call_mode">First call</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevation: %.1f°</string>
|
||||
<string name="map_altitude">Altitude: %.0f km</string>
|
||||
@@ -146,9 +145,12 @@
|
||||
<string name="map_velocity">Velocity: %.2f km/s</string>
|
||||
<string name="map_visibility">Visibility: %s</string>
|
||||
<!-- Worked-grid QSO dialog -->
|
||||
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_calls_count" formatted="false">%d callsigns · %d QSOs</string>
|
||||
<string name="grid_qso_count_one">%d QSO</string>
|
||||
<string name="grid_qso_count_many">%d QSOs</string>
|
||||
<string name="grid_qso_first">First</string>
|
||||
<string name="grid_qso_no_sat">satellite unknown</string>
|
||||
<string name="grid_qso_match">Match</string>
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
@@ -193,11 +195,23 @@
|
||||
<string name="prefs_lotw_callsign">LoTW callsign</string>
|
||||
<string name="prefs_lotw_password">LoTW password</string>
|
||||
<string name="prefs_lotw_hint">Fetch confirmed-QSO grids directly from ARRL LoTW for this callsign and replace the stored set (currently %1$d). The password is stored on this device only. Tapping Sync saves the credentials and fetches in one step.</string>
|
||||
<string name="prefs_lotw_sync_duration_hint">A sync usually takes about 3–10 seconds; the first one or a busy ARRL server can take longer.</string>
|
||||
<string name="prefs_lotw_sync">Sync</string>
|
||||
<string name="prefs_lotw_syncing">Syncing…</string>
|
||||
<string name="prefs_lotw_configured">Synced from LoTW — %1$d grids total</string>
|
||||
<string name="prefs_lotw_not_configured">Not configured — pull confirmed grids directly from ARRL LoTW, no self-hosted server needed</string>
|
||||
<string name="lotw_sync_error_not_configured">LoTW callsign/password not configured</string>
|
||||
<string name="lotw_sync_error_credentials">LoTW sync failed — callsign or password incorrect</string>
|
||||
<string name="lotw_sync_error_rate_limited">LoTW sync failed — rate limited by server, wait a few minutes and retry</string>
|
||||
<string name="lotw_sync_error_timeout">LoTW sync failed — connection timed out, try another network</string>
|
||||
<string name="lotw_sync_error_network">LoTW sync failed — network error (%1$s)</string>
|
||||
<string name="lotw_sync_full">Full sync</string>
|
||||
<string name="lotw_sync_incremental">Incremental merge</string>
|
||||
<string name="lotw_sync_rate_hint">Download runs at ~8 s per 100 QSOs (plus 5–40 s server generation time)</string>
|
||||
<string name="lotw_sync_progress_connecting">Connecting to LoTW…</string>
|
||||
<string name="lotw_sync_progress_downloading">Downloading report…</string>
|
||||
<string name="lotw_sync_progress_saving">Processing downloaded data…</string>
|
||||
<string name="lotw_sync_progress_count">Syncing %1$d QSOs…</string>
|
||||
<string name="lotw_sync_progress_qso">Syncing %1$d QSOs, ~%2$d s remaining</string>
|
||||
<string name="prefs_locator_text">Set station\'s position using locator</string>
|
||||
|
||||
<string name="prefs_data_title">Satellite data</string>
|
||||
@@ -257,8 +271,22 @@
|
||||
<string name="prefs_other_title">Other settings</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_lotw_sync">Auto-sync LoTW confirmed grids</string>
|
||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
|
||||
<string name="prefs_compass_calibration_button">Calibrate compass</string>
|
||||
<string name="prefs_compass_calibration_title">Compass calibration</string>
|
||||
<string name="prefs_compass_calibration_instruction">Move the phone slowly in a figure-eight until accuracy improves. Keep it away from magnets and metal.</string>
|
||||
<string name="prefs_compass_accuracy">Accuracy: %s</string>
|
||||
<string name="prefs_compass_accuracy_high">High — calibration complete</string>
|
||||
<string name="prefs_compass_accuracy_medium">Medium — keep moving</string>
|
||||
<string name="prefs_compass_accuracy_low">Low — keep moving</string>
|
||||
<string name="prefs_compass_accuracy_unreliable">Unreliable — start the figure-eight motion</string>
|
||||
<string name="prefs_compass_accuracy_unavailable">Magnetic sensor unavailable</string>
|
||||
<string name="prefs_compass_heading">Corrected heading: %.1f°</string>
|
||||
<string name="prefs_compass_offset">Manual heading offset (°)</string>
|
||||
<string name="prefs_compass_offset_support">Range −180° to +180°. Positive values rotate clockwise.</string>
|
||||
<string name="prefs_compass_offset_reset">Reset offset</string>
|
||||
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
|
||||
<string name="prefs_other_switch_map_grid">Show Maidenhead grid on map</string>
|
||||
|
||||
@@ -276,7 +304,7 @@
|
||||
\n* Dr T.S. Kelso (Celestrak)
|
||||
\n* Libre Space Foundation (SatNOGS)
|
||||
\n* xdsopl and Robot36 contributors!
|
||||
\n* BG7NTA (CW decoding feature)
|
||||
\n* BG7NTA BG5JSU (CW decoding feature)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[-69.6203,60.2212]Line truncated
|
||||
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791],[180.0,-16.0671]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]]},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]]},{"id":2,"code":Line truncated
|
||||
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]],"name_en":"Kyoto"},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]]},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[117.7539,39.5797],[11Line truncated
|
||||
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]],"name_en":"Beijing"},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[1Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.177,44.5509],[-111.1808,44.569Line truncated
|
||||
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0],[-109.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0],[-109.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.17Line truncated
|
||||
+10
-2
@@ -35,7 +35,8 @@ import kotlin.math.min
|
||||
* One administrative/boundary region loaded from the award GeoJSON assets.
|
||||
*
|
||||
* @param code match key against award statistics (e.g. "GD", "34", "24", "CA", "318")
|
||||
* @param name display label (province/prefecture/state/country name)
|
||||
* @param name display label in the source language (province/prefecture/state/country name)
|
||||
* @param nameEn optional English display label (null when the source already is English)
|
||||
* @param labelLon/labelLat preferred label anchor (usually the region centroid)
|
||||
* @param rings polygon exterior rings as lon/lat pairs; the overlay projects
|
||||
* them per frame with the same projection helpers as the grid overlay
|
||||
@@ -43,6 +44,7 @@ import kotlin.math.min
|
||||
data class AwardRegion(
|
||||
val code: String,
|
||||
val name: String,
|
||||
val nameEn: String? = null,
|
||||
val labelLon: Double,
|
||||
val labelLat: Double,
|
||||
val rings: List<List<DoubleArray>>,
|
||||
@@ -93,6 +95,7 @@ object AwardBoundaryData {
|
||||
AwardRegion(
|
||||
code = o.optString("code"),
|
||||
name = o.optString("name"),
|
||||
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
|
||||
labelLon = o.optDouble("label_lon", 0.0),
|
||||
labelLat = o.optDouble("label_lat", 0.0),
|
||||
rings = rings,
|
||||
@@ -184,6 +187,10 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = labelPaint.fontMetrics
|
||||
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||
// Label language follows the system: Chinese UI keeps the source
|
||||
// (Chinese/Japanese) names; any other UI language uses the English
|
||||
// name when the asset provides one (falls back to the source name).
|
||||
val useEnglishLabels = mapView.context.resources.configuration.locales[0].language != "zh"
|
||||
|
||||
for (i in regions.indices) {
|
||||
val region = regions[i]
|
||||
@@ -236,7 +243,8 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
// anchor sits out on the sea where there is room for the text.
|
||||
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
|
||||
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
|
||||
canvas.drawText(region.name, lx, ly - textHalfHeight, labelPaint)
|
||||
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
|
||||
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-22
@@ -46,23 +46,31 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(160, 255, 224, 130)
|
||||
}
|
||||
// 网格标签与首通呼号标签同字号 (用户要求 2026-09-21: 字体一样大)
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 26f
|
||||
textSize = LABEL_TEXT_SIZE
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(220, 255, 224, 130)
|
||||
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||
}
|
||||
// 首通呼号标签: 与网格标签同字号.
|
||||
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = LABEL_TEXT_SIZE
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(230, 255, 224, 130)
|
||||
setShadowLayer(3f, 2f, 2f, Color.BLACK)
|
||||
}
|
||||
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 7f
|
||||
// Fully opaque blue: stripe bands stay pure blue even over a green
|
||||
// worked fill — no alpha blend into a teal/green mix (user requirement:
|
||||
// the two colors must never stack into a single mixed color).
|
||||
color = Color.argb(255, 66, 133, 244)
|
||||
strokeWidth = 14f
|
||||
// Same alpha as the green worked fill (90) — user req: stripe opacity
|
||||
// must match the green grid. Wide 14f stripes at 44f spacing read as a
|
||||
// sparse GridMaster-style zebra (user picked spacing 44 / width 14).
|
||||
color = Color.argb(90, 66, 133, 244)
|
||||
}
|
||||
private val ownLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 6f
|
||||
@@ -79,6 +87,15 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
|
||||
/** Grid-mode first-call labels: label worked (green) cells with the first
|
||||
* callsign worked in that grid instead of the Maidenhead code; non-worked
|
||||
* cells get no label at all. Only meaningful at sub-square zoom. */
|
||||
var showFirstCallLabels: Boolean = false
|
||||
|
||||
/** Worked grid -> first callsign worked in it (earliest QSO by time).
|
||||
* Only read when [showFirstCallLabels] is on. */
|
||||
var firstCallsByGrid: Map<String, String> = emptyMap()
|
||||
|
||||
/**
|
||||
* Gridsquares the station operated from (4-char, uppercase) — drawn with
|
||||
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
|
||||
@@ -226,7 +243,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label in roamedGrids && label != ownGrid) {
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +263,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
if (yBottom < 0f || yTop > canvas.height) continue
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom)
|
||||
drawStripes(canvas, xLeft, yTop, xRight, yBottom, zoom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,9 +363,15 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
// Labels: centered in each cell, only when the cell is large enough on
|
||||
// screen to hold a label (avoid clutter at low zoom).
|
||||
// Field (2-char) labels show at every zoom, subject only to the pixel-
|
||||
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
|
||||
// (one level above the grid lines, so zoom 6 shows lines but no names).
|
||||
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
|
||||
// size check below; sub-square (4-char) labels appear together with the
|
||||
// grid LINES at GRID_ZOOM_SUB (user req 2026-09-21: 与首通呼号同一显示缩放).
|
||||
// First-call labels replace the 4-char grid codes and only exist at
|
||||
// sub-square zoom; at field zoom NO labels are drawn at all (the user
|
||||
// requirement is that non-worked cells carry no grid characters).
|
||||
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
|
||||
// 网格标签与首通呼号同一显示缩放 (用户要求 2026-09-21): 子方块(4字符)
|
||||
// 标签与首通呼号都在网格线出现的 GRID_ZOOM_SUB 同时显示, 不再晚一档.
|
||||
val showLabels = zoom >= GRID_ZOOM_SUB || cellLat == FIELD_LAT
|
||||
if (!showLabels) return
|
||||
// Estimate on-screen cell height to avoid clutter at low zoom:
|
||||
// project two points 1° apart in latitude and measure the pixel distance.
|
||||
@@ -356,10 +379,14 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val y2 = projectionToY(projection, 1.0)
|
||||
if (y1 == null || y2 == null) return
|
||||
val pixelsPerDegree = Math.abs(y2 - y1)
|
||||
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
|
||||
// 两种模式同一像素门限: GRID_ZOOM_SUB 处 1° 格约 45px, 低于 48px
|
||||
// 常规阈值, 统一用放宽的 0.6x 门限, 保证网格标签与首通呼号同时出现.
|
||||
val minCellPx = MIN_LABEL_CELL_PX * 0.6f
|
||||
if (pixelsPerDegree * cellLat < minCellPx) return
|
||||
|
||||
labelPaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = labelPaint.fontMetrics
|
||||
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
|
||||
activePaint.textAlign = Paint.Align.CENTER
|
||||
val fontMetrics = activePaint.fontMetrics
|
||||
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
@@ -386,7 +413,17 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
val xRight = xRightBase + turn * worldWidthPx.toFloat()
|
||||
if (xRight < 0f || xLeft > canvas.width) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
if (showFirstCallLabels) {
|
||||
// 首通呼号模式: 只有绿格(worked)标注该格第一个通联的呼号,
|
||||
// 非绿格空着不写网格字符.
|
||||
if (label in workedGrids) {
|
||||
firstCallsByGrid[label]?.let { call ->
|
||||
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,17 +433,21 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
* rectangle, from top-left to bottom-right. The paint is nearly opaque so
|
||||
* stripes stay blue even over a green worked fill underneath.
|
||||
*/
|
||||
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float) {
|
||||
private fun drawStripes(canvas: Canvas, xLeft: Float, yTop: Float, xRight: Float, yBottom: Float, zoom: Double) {
|
||||
if (xRight <= xLeft || yBottom <= yTop) return
|
||||
canvas.save()
|
||||
canvas.clipRect(xLeft, yTop, xRight, yBottom)
|
||||
val height = yBottom - yTop
|
||||
// Geographic density is kept constant: spacing is the reference pixel
|
||||
// spacing (STRIPE_SPACING_PX at the map's max zoom) scaled by 2^(zoom-max),
|
||||
// so stripes shrink/grow with the map instead of staying fixed on screen.
|
||||
val spacing = STRIPE_SPACING_PX * Math.pow(2.0, zoom - MAX_GRID_ZOOM).toFloat()
|
||||
// Start one stripe-width left of the cell so the top-left corner is
|
||||
// always covered; each stripe runs from (x, top) to (x+height, bottom).
|
||||
var x = xLeft - height
|
||||
while (x < xRight) {
|
||||
canvas.drawLine(x, yTop, x + height, yBottom, roamStripePaint)
|
||||
x += STRIPE_SPACING_PX
|
||||
x += spacing
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
@@ -428,7 +469,15 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
|
||||
/** Y pixel for a latitude, or null when outside the viewport. */
|
||||
private fun projectionToY(projection: Projection, lat: Double): Float? {
|
||||
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
|
||||
// Web Mercator is undefined beyond ±85.0511° (osmdroid clamps the world
|
||||
// to TileSystemWebMercator limits). Projecting the polar field rows
|
||||
// (lat=±90: Maidenhead row R = 80..90°N, row A = -90..-80°S) yields
|
||||
// ±9.2e18 pixel coordinates that the canvas cannot rasterize, so the
|
||||
// polar grid lines silently vanish. Clamp to the Mercator limit: the
|
||||
// polar rows then project onto the screen edge and their lines are
|
||||
// drawn (the off-limit sliver beyond 85.05° is invisible anyway).
|
||||
val clamped = lat.coerceIn(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT)
|
||||
val geo = org.osmdroid.util.GeoPoint(clamped, 0.0)
|
||||
val p = projection.toPixels(geo, null)
|
||||
return p.y.toFloat()
|
||||
}
|
||||
@@ -484,17 +533,28 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
const val FIELD_LON = 20.0
|
||||
const val SUB_SQUARE_LAT = 1.0
|
||||
const val SUB_SQUARE_LON = 2.0
|
||||
// Web Mercator latitude limit (osmdroid TileSystemWebMercator): projecting
|
||||
// beyond it produces ±9.2e18 pixel coordinates the canvas cannot draw.
|
||||
const val MAX_MERCATOR_LAT = 85.05112877980658
|
||||
// Zoom at which the 2°x1° sub-square grid lines/fills appear.
|
||||
// 5.0 → 6.0: at zoom 5 a full field spans too little screen width and
|
||||
// the sub-square grid is too dense to read; 6 roughly doubles the
|
||||
// on-screen size of each square.
|
||||
const val GRID_ZOOM_SUB = 6.0
|
||||
// Zoom at which the 4-char sub-square names appear (one level above the
|
||||
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
|
||||
const val LABEL_ZOOM_SUB = 6.5
|
||||
// 网格标签与首通呼号标签统一字号 (用户要求 2026-09-21: 字体一样大).
|
||||
const val LABEL_TEXT_SIZE = 20f
|
||||
const val MIN_LABEL_CELL_PX = 48f
|
||||
const val MAX_OVERSHOOT_PX = 64
|
||||
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
|
||||
const val STRIPE_SPACING_PX = 16f
|
||||
// 44f spacing (was 16f — too dense per user) with 14f-wide stripes;
|
||||
// sparse GridMaster-style zebra.
|
||||
const val STRIPE_SPACING_PX = 44f
|
||||
/**
|
||||
* Reference zoom for stripe spacing: the map's max zoom (MapScreen sets
|
||||
* maxZoomLevel = 7.0). At this zoom stripes are STRIPE_SPACING_PX apart;
|
||||
* at lower zooms spacing scales by 2^(zoom-max) so the geographic
|
||||
* density matches the max-zoom look at every level.
|
||||
*/
|
||||
const val MAX_GRID_ZOOM = 7.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.model.AwardType
|
||||
|
||||
/**
|
||||
* Session-scoped holder for the map's award filter selection.
|
||||
*
|
||||
* Scoped to the Activity (not the navigation entry) so the selection survives
|
||||
* switching to another page and back; it resets to VUCC only when the process
|
||||
* starts fresh (cold start), which is what the user expects as the default.
|
||||
*/
|
||||
class MapFilterViewModel : ViewModel() {
|
||||
|
||||
/** Last chosen award chip; null means "All". Initialized to VUCC once per process. */
|
||||
val selectedAward: MutableState<AwardType?> = mutableStateOf(AwardType.VUCC)
|
||||
|
||||
/** Last map viewport (center + zoom), saved when leaving the page. Null until first visit. */
|
||||
var mapCenterLat: Double? = null
|
||||
var mapCenterLon: Double? = null
|
||||
var mapZoom: Double? = null
|
||||
|
||||
/** Persists the current viewport so a later re-entry restores exactly where the user left off. */
|
||||
fun saveMapViewState(centerLat: Double, centerLon: Double, zoom: Double) {
|
||||
mapCenterLat = centerLat
|
||||
mapCenterLon = centerLon
|
||||
mapZoom = zoom
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory() = viewModelFactory {
|
||||
initializer { MapFilterViewModel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import kotlin.math.cos
|
||||
@@ -39,9 +40,11 @@ import kotlin.math.sin
|
||||
* 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
|
||||
* 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.
|
||||
* evaluations per row, which is imperceptible. draw() is called on every
|
||||
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
|
||||
* single GeoPoint instance.
|
||||
*/
|
||||
class MapNightOverlay : Overlay() {
|
||||
|
||||
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
private val rect = RectF()
|
||||
|
||||
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
|
||||
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
|
||||
|
||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||
if (shadow) return
|
||||
|
||||
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
|
||||
val h = mapView.height
|
||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||
|
||||
// The map is never rotated, so latitude depends only on y and longitude only on x.
|
||||
// Resolve the top/bottom latitudes once instead of once per column.
|
||||
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
|
||||
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
|
||||
val sinLatTop = sin(latTopRad)
|
||||
val cosLatTop = cos(latTopRad)
|
||||
val sinLatBot = sin(latBotRad)
|
||||
val cosLatBot = cos(latBotRad)
|
||||
|
||||
// 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
|
||||
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
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 geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: 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
|
||||
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
|
||||
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
|
||||
|
||||
when {
|
||||
dotTop < 0 && dotBot < 0 -> {
|
||||
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
|
||||
var hi = yBot
|
||||
while (hi - lo > 1) {
|
||||
val mid = (lo + hi) / 2
|
||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
||||
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: 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
|
||||
|
||||
@@ -29,11 +29,14 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -45,10 +48,12 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -62,6 +67,7 @@ import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -90,6 +96,10 @@ import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
import org.osmdroid.events.DelayedMapListener
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
@@ -139,17 +149,58 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapDestination() {
|
||||
fun MapDestination(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
// Invoked with the tapped grid when the QSO dialog's "Match" button is
|
||||
// pressed: navigate to the match page pre-filled for that grid.
|
||||
onMatchGrid: (String) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val mapView = rememberMapViewWithLifecycle()
|
||||
MapScreen(uiState, viewModel::onAction, mapView)
|
||||
val mapView = rememberMapViewWithLifecycle(mapFilterViewModel, restoreViewport = uiState.isGridMode)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
// Save the viewport when leaving the page, but only in grid mode: the
|
||||
// satellite mode intentionally keeps its original behavior (default center,
|
||||
// follow the selected satellite), so a satellite-mode exit must not
|
||||
// overwrite the last grid-mode viewport.
|
||||
DisposableEffect(mapView, uiState.isGridMode) {
|
||||
onDispose {
|
||||
if (uiState.isGridMode) {
|
||||
mapFilterViewModel.saveMapViewState(
|
||||
mapView.mapCenter.latitude,
|
||||
mapView.mapCenter.longitude,
|
||||
mapView.zoomLevelDouble
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
|
||||
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView: MapView) {
|
||||
private fun MapScreen(
|
||||
uiState: MapState,
|
||||
onAction: (MapAction) -> Unit,
|
||||
mapView: MapView,
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
onMatchGrid: (String) -> Unit
|
||||
) {
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
@@ -157,27 +208,70 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||
// Selected award filter. Entering grid mode defaults to VUCC (the plain
|
||||
// worked-grid view); the reset effect below re-asserts that on every entry.
|
||||
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
|
||||
// Selected award filter. Lives in an Activity-scoped ViewModel so it
|
||||
// survives page switches; defaults to VUCC only once per process (cold start).
|
||||
var selectedAward by mapFilterViewModel.selectedAward
|
||||
// Six-award progress derived from the confirmed QSO store; recomputed when
|
||||
// the store changes (LoTW/Wavelog sync).
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
|
||||
AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
|
||||
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
|
||||
val m = mutableMapOf<String, MutableSet<String>>()
|
||||
for ((grid, qsos) in uiState.workedGridQsos) {
|
||||
for (q in qsos) {
|
||||
val mg = q.myGrid ?: continue
|
||||
m.getOrPut(mg) { mutableSetOf() }.add(grid)
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
// First callsign worked in each grid (earliest QSO by time), used by the
|
||||
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
|
||||
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
|
||||
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
|
||||
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
|
||||
}.toMap()
|
||||
}
|
||||
// Selected operated grid for VUCC counting; defaults to the grid with the
|
||||
// most worked grids. Null when the store carries no per-QSO myGrid data
|
||||
// (requires a LoTW resync) — then VUCC falls back to the global count.
|
||||
var selectedMyGrid by remember(vuccByMyGrid) {
|
||||
mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key)
|
||||
}
|
||||
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) {
|
||||
val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() }
|
||||
val base = AwardCalculator.calculate(uiState.workedGridQsos)
|
||||
if (vuccGrids == null) base
|
||||
else base.map { p ->
|
||||
if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p
|
||||
}
|
||||
}
|
||||
// Worked grids drawn on the map: filtered by the selected operated grid
|
||||
// (null = all operated grids). Drives both the green fills and the tap
|
||||
// listener below, so switching the selector changes which cells are green.
|
||||
val workedGrids = remember(uiState.workedGrids, vuccByMyGrid, selectedMyGrid) {
|
||||
if (selectedMyGrid == null) uiState.workedGrids
|
||||
else vuccByMyGrid[selectedMyGrid] ?: emptySet()
|
||||
}
|
||||
// Attach the tap listener whenever grid mode / worked grids change.
|
||||
val workedGrids = uiState.workedGrids
|
||||
val isGridMode = uiState.isGridMode
|
||||
// True when this composition restored a saved viewport. Only grid mode
|
||||
// restores the viewport the user left (satellite mode keeps its original
|
||||
// follow-the-satellite behavior); this implies grid mode was already on
|
||||
// when re-entering, which is why the grid-mode auto-centering below can
|
||||
// treat the entry as already centered.
|
||||
val restoredViewport = remember {
|
||||
isGridMode && mapFilterViewModel.mapCenterLat != null && mapFilterViewModel.mapZoom != null
|
||||
}
|
||||
DisposableEffect(isGridMode, workedGrids) {
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
// Allow tapping worked cells as soon as the 4-char grid LINES
|
||||
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
|
||||
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
|
||||
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
|
||||
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
|
||||
if (grid !in workedGrids) return false
|
||||
// Every grid is tappable: worked grids show their confirmed QSOs,
|
||||
// unworked grids open the same dialog with an empty list.
|
||||
selectedGrid = grid
|
||||
return true
|
||||
}
|
||||
@@ -193,12 +287,6 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
// Re-assert the VUCC default each time grid mode is entered; while already
|
||||
// in grid mode the user's chip choice is preserved.
|
||||
LaunchedEffect(uiState.isGridMode) {
|
||||
if (uiState.isGridMode) selectedAward = AwardType.VUCC
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.track) {
|
||||
// In grid mode the map is centered on the local grid square; following
|
||||
// the satellite subpoint here would override that centering on every
|
||||
@@ -234,9 +322,14 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.weight(1f)) {
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
// Track grid-mode transitions; initial value false so that entering
|
||||
// the map page with grid mode already ON also centers the map.
|
||||
var prevGridMode by remember { mutableStateOf(false) }
|
||||
// Track grid-mode transitions. When the map was just restored to
|
||||
// the viewport the user left (re-entry after a page switch) and
|
||||
// grid mode is already on, treat the entry as already centered so
|
||||
// the restored position survives. Cold-start entries and in-page
|
||||
// toggles still center on the station grid as before.
|
||||
var prevGridMode by remember {
|
||||
mutableStateOf(restoredViewport && uiState.isGridMode)
|
||||
}
|
||||
AndroidView({ mapView }) { view ->
|
||||
// Award filter mode: show the selected award's regions with
|
||||
// worked ones filled green; hide satellite layers like grid mode.
|
||||
@@ -250,14 +343,21 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
prevGridMode = uiState.isGridMode
|
||||
} else {
|
||||
// Center on the station grid whenever entering grid mode.
|
||||
// (prevGridMode is initialized to "already centered" when
|
||||
// the map was just restored to the viewport the user left,
|
||||
// so a re-entry keeps the restored position; in-page
|
||||
// toggles still center normally.)
|
||||
// Only mark the transition as consumed once a real position
|
||||
// was available; otherwise a first frame with a null
|
||||
// stationPosition would swallow the centering forever.
|
||||
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||
setGridMode(
|
||||
uiState.isGridMode, uiState.workedGrids, uiState.roamedGrids, view,
|
||||
uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
|
||||
uiState.stationPosition,
|
||||
centerOnStation = shouldCenter
|
||||
centerOnStation = shouldCenter,
|
||||
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
|
||||
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
|
||||
firstCallsByGrid = firstCallsByGrid
|
||||
)
|
||||
if (!shouldCenter || uiState.stationPosition != null) {
|
||||
prevGridMode = uiState.isGridMode
|
||||
@@ -279,13 +379,40 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
}
|
||||
}
|
||||
GridModeToggle(
|
||||
isGridMode = uiState.isGridMode,
|
||||
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
|
||||
// Top-left: operated-grid selector for VUCC counting (grid mode only).
|
||||
// Shown whenever per-grid QSO data exists; "All" (null) is available.
|
||||
if (uiState.isGridMode && selectedAward == AwardType.VUCC && vuccByMyGrid.isNotEmpty()) {
|
||||
VuccGridSelector(
|
||||
options = vuccByMyGrid,
|
||||
allCount = uiState.workedGrids.size,
|
||||
selected = selectedMyGrid,
|
||||
onSelect = { selectedMyGrid = it },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
// Top-right: grid-mode pill + (grid mode only) the first-call
|
||||
// label pill, stacked vertically.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
GridModeToggle(
|
||||
isGridMode = uiState.isGridMode,
|
||||
onToggle = { onAction(MapAction.ToggleGridMode(it)) }
|
||||
)
|
||||
// 首通呼号开关仅 VUCC 视图显示(与 VuccGridSelector 同条件).
|
||||
if (uiState.isGridMode && selectedAward == AwardType.VUCC) {
|
||||
FirstCallToggle(
|
||||
checked = uiState.showFirstCallLabels,
|
||||
onToggle = { onAction(MapAction.ToggleFirstCallLabels(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,7 +423,8 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
isUtc = uiState.isUtc,
|
||||
onDismiss = { selectedGrid = null }
|
||||
onDismiss = { selectedGrid = null },
|
||||
onMatch = { onMatchGrid(grid) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +436,8 @@ private fun WorkedGridQsoDialog(
|
||||
grid: String,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
onDismiss: () -> Unit,
|
||||
onMatch: () -> Unit
|
||||
) {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
// Centered card, ~85% width, internal scroll for long lists.
|
||||
@@ -317,29 +446,43 @@ private fun WorkedGridQsoDialog(
|
||||
modifier = Modifier.fillMaxWidth(0.88f)
|
||||
) {
|
||||
Column {
|
||||
// Header: grid + counts
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
text = grid,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
val distinctCalls = qsos.map { it.call }.distinct().size
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// Header: grid + counts on the left, "Match" action on the right
|
||||
// (jumps to the match page pre-filled with this grid).
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = grid,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
val distinctCalls = qsos.map { it.call }.distinct().size
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onMatch()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_radio_tower),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.grid_qso_match))
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
if (qsos.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_no_sat),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
if (qsos.isNotEmpty()) {
|
||||
// Group by callsign preserving first-contact order (list is
|
||||
// already sorted oldest-first); expandable rows.
|
||||
val grouped = remember(qsos) {
|
||||
@@ -381,18 +524,36 @@ private fun WorkedGridCallRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
// Plurals resources don't work for zh (only 'other' matches), so
|
||||
// pick the singular/plural string explicitly.
|
||||
text = if (callQsos.size == 1) stringResource(R.string.grid_qso_count_one, 1)
|
||||
else stringResource(R.string.grid_qso_count_many, callQsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
// First-QSO summary line, always visible.
|
||||
Text(
|
||||
@@ -560,6 +721,97 @@ private fun AwardChipsRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill toggle for switching between satellite view and grid mode,
|
||||
* floated over the map's top-right corner. The label reflects the active
|
||||
* mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent
|
||||
* background keeps the map readable underneath.
|
||||
*/
|
||||
@Composable
|
||||
private fun VuccGridSelector(
|
||||
options: Map<String, Set<String>>,
|
||||
allCount: Int,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
// Most-worked grid first; selection is made on tap. "All" sits last.
|
||||
val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } }
|
||||
Surface(
|
||||
color = ComposeColor.Black.copy(alpha = 0.45f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier
|
||||
) {
|
||||
// Width = widest child (header or list item), never the full screen:
|
||||
// the expanded rows use fillMaxWidth, which would otherwise stretch
|
||||
// this box to the whole map width.
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (selected == null) "All ($allCount)"
|
||||
else "$selected (${options[selected]?.size ?: 0})",
|
||||
color = ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f))
|
||||
sorted.forEach { (grid, grids) ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(grid)
|
||||
expanded = false
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "$grid (${grids.size})",
|
||||
color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
// "All" always listed last: shows every worked grid combined.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onSelect(null)
|
||||
expanded = false
|
||||
}
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "All ($allCount)",
|
||||
color = if (selected == null) MaterialTheme.colorScheme.primary else ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill toggle for switching between satellite view and grid mode,
|
||||
* floated over the map's top-right corner. The label reflects the active
|
||||
@@ -598,6 +850,40 @@ private fun GridModeToggle(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网格模式下的"首通呼号"开关: 打开后绿格(worked)的标签显示该格第一个通联的
|
||||
* 呼号, 非绿格不显示任何文字. 仅在网格模式下显示(与网格模式开关叠放).
|
||||
*/
|
||||
@Composable
|
||||
private fun FirstCallToggle(
|
||||
checked: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
color = ComposeColor.Black.copy(alpha = 0.45f),
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.map_first_call_mode),
|
||||
color = ComposeColor.White,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(end = 2.dp)
|
||||
)
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Map overlay helpers
|
||||
@@ -641,7 +927,9 @@ private fun setGridMode(
|
||||
roamedGrids: Set<String>,
|
||||
mapView: MapView,
|
||||
stationPosition: GeoPos?,
|
||||
centerOnStation: Boolean = false
|
||||
centerOnStation: Boolean = false,
|
||||
showFirstCallLabels: Boolean = false,
|
||||
firstCallsByGrid: Map<String, String> = emptyMap()
|
||||
) {
|
||||
try {
|
||||
val gridOverlay = mapView.overlays[OVERLAY_GRID]
|
||||
@@ -649,6 +937,9 @@ private fun setGridMode(
|
||||
gridOverlay.isEnabled = gridMode
|
||||
gridOverlay.workedGrids = workedGrids
|
||||
gridOverlay.roamedGrids = roamedGrids
|
||||
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
|
||||
gridOverlay.showFirstCallLabels = showFirstCallLabels
|
||||
gridOverlay.firstCallsByGrid = firstCallsByGrid
|
||||
// ownGrid must be set on EVERY update — the position is available
|
||||
// regardless of whether this frame centers (centering happens only
|
||||
// on entry, but passing null here would wipe the bold outline).
|
||||
@@ -658,6 +949,8 @@ private fun setGridMode(
|
||||
isEnabled = gridMode
|
||||
this.workedGrids = workedGrids
|
||||
this.roamedGrids = roamedGrids
|
||||
this.showFirstCallLabels = showFirstCallLabels
|
||||
this.firstCallsByGrid = firstCallsByGrid
|
||||
this.ownGrid = ownGridOf(stationPosition)
|
||||
}
|
||||
}
|
||||
@@ -731,6 +1024,27 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Above this many satellites inside the viewport labels are dropped in favor of a single
|
||||
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
|
||||
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway. */
|
||||
private const val LABEL_LIMIT = 128
|
||||
|
||||
/** Degrees of space around the viewport so markers don't pop in at the edges */
|
||||
private const val VIEWPORT_MARGIN = 8.0
|
||||
|
||||
/** Debounce for viewport-driven marker refreshes, in milliseconds */
|
||||
private const val MAP_LISTENER_DELAY = 128L
|
||||
|
||||
/** Shared dot icon used when too many satellites are visible to label them */
|
||||
private var dotIcon: Drawable? = null
|
||||
|
||||
/** Scratch list reused every frame to avoid per-tick allocation */
|
||||
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
|
||||
|
||||
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
|
||||
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
|
||||
private var lastAction: ((OrbitalObject) -> Unit)? = null
|
||||
|
||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||
private val markerPool = HashMap<String, Marker>()
|
||||
private var lastMapView: MapView? = null
|
||||
@@ -741,13 +1055,20 @@ private fun setPositions(
|
||||
action: (OrbitalObject) -> Unit
|
||||
) {
|
||||
try {
|
||||
lastPositions = posMap
|
||||
lastAction = action
|
||||
// Clear caches when the MapView instance changes (e.g. config change)
|
||||
if (lastMapView !== mapView) {
|
||||
lastMapView = mapView
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
footprintPolyline = null
|
||||
footprintPoints = null
|
||||
mapView.addMapListener(DelayedMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
|
||||
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
|
||||
}, MAP_LISTENER_DELAY))
|
||||
}
|
||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||
@@ -756,17 +1077,48 @@ private fun setPositions(
|
||||
}
|
||||
folder.items.clear()
|
||||
|
||||
val activeNames = HashSet<String>(posMap.size)
|
||||
posMap.forEach { (satellite, geoPos) ->
|
||||
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
|
||||
// exactly when marker labels are shown and drawing is most expensive.
|
||||
visibleSats.clear()
|
||||
if (mapView.width > 0 && mapView.height > 0) {
|
||||
val box = mapView.boundingBox
|
||||
val latNorth = box.latNorth + VIEWPORT_MARGIN
|
||||
val latSouth = box.latSouth - VIEWPORT_MARGIN
|
||||
val lonWest = box.lonWest - VIEWPORT_MARGIN
|
||||
val lonEast = box.lonEast + VIEWPORT_MARGIN
|
||||
val wrapsDateLine = box.lonWest > box.lonEast
|
||||
for ((satellite, geoPos) in posMap) {
|
||||
val lat = geoPos.latitude
|
||||
if (lat !in latSouth..latNorth) continue
|
||||
val lon = geoPos.longitude
|
||||
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
|
||||
else lon in lonWest..lonEast
|
||||
if (isLonVisible) visibleSats.add(satellite to geoPos)
|
||||
}
|
||||
} else {
|
||||
for (entry in posMap) visibleSats.add(entry.key to entry.value)
|
||||
}
|
||||
|
||||
val showLabels = visibleSats.size <= LABEL_LIMIT
|
||||
val activeNames = HashSet<String>(visibleSats.size)
|
||||
for ((satellite, geoPos) in visibleSats) {
|
||||
val name = satellite.data.name
|
||||
activeNames.add(name)
|
||||
val marker = markerPool.getOrPut(name) {
|
||||
Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = getCachedTextIcon(name, mapView)
|
||||
// Resolve the satellite via relatedObject so the listener is allocated
|
||||
// once per marker instead of once per satellite per tick
|
||||
setOnMarkerClickListener { clicked, _ ->
|
||||
(clicked.relatedObject as? OrbitalObject)?.let(action)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
marker.relatedObject = satellite
|
||||
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
|
||||
if (marker.icon !== icon) marker.icon = icon
|
||||
// Update position in-place — reuse existing GeoPoint if available
|
||||
val pos = marker.position
|
||||
if (pos != null) {
|
||||
@@ -775,22 +1127,34 @@ private fun setPositions(
|
||||
} else {
|
||||
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
|
||||
}
|
||||
marker.setOnMarkerClickListener { _, _ ->
|
||||
action(satellite)
|
||||
true
|
||||
}
|
||||
folder.add(marker)
|
||||
}
|
||||
// Evict markers for satellites no longer tracked
|
||||
val iter = markerPool.keys.iterator()
|
||||
while (iter.hasNext()) {
|
||||
if (iter.next() !in activeNames) iter.remove()
|
||||
}
|
||||
// Evict markers that are no longer tracked or no longer visible
|
||||
markerPool.keys.retainAll(activeNames)
|
||||
visibleSats.clear()
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-applies the last known positions against the new viewport after a pan or zoom */
|
||||
private fun refreshPositions(mapView: MapView): Boolean {
|
||||
// Grid mode disables the satellite layer — don't re-populate markers off-screen
|
||||
if (mapView.overlays.getOrNull(OVERLAY_POSITIONS)?.isEnabled == false) return true
|
||||
val posMap = lastPositions ?: return false
|
||||
val action = lastAction ?: return false
|
||||
setPositions(posMap, mapView, action)
|
||||
mapView.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
|
||||
val size = 20
|
||||
val bitmap = createBitmap(size, size)
|
||||
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
|
||||
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
|
||||
}
|
||||
|
||||
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||
iconCache[name]?.let { return it }
|
||||
val labelRect = Rect()
|
||||
@@ -933,7 +1297,10 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
|
||||
|
||||
// region MapView lifecycle
|
||||
@Composable
|
||||
private fun rememberMapViewWithLifecycle(): MapView {
|
||||
private fun rememberMapViewWithLifecycle(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
restoreViewport: Boolean
|
||||
): MapView {
|
||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
val context = LocalContext.current
|
||||
val isVertical = isVerticalLayout()
|
||||
@@ -949,8 +1316,19 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
setTileSource(tileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||
maxZoomLevel = 7.0
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
// Restore the viewport the user left on the previous visit, but only
|
||||
// in grid mode: satellite mode intentionally keeps its original
|
||||
// behavior (default center, then follows the selected satellite).
|
||||
val savedLat = mapFilterViewModel.mapCenterLat
|
||||
val savedLon = mapFilterViewModel.mapCenterLon
|
||||
val savedZoom = mapFilterViewModel.mapZoom
|
||||
if (restoreViewport && savedLat != null && savedLon != null && savedZoom != null) {
|
||||
controller.setCenter(GeoPoint(savedLat, savedLon))
|
||||
controller.setZoom(savedZoom)
|
||||
} else {
|
||||
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
|
||||
@@ -966,9 +1344,24 @@ private fun rememberMapViewWithLifecycle(): MapView {
|
||||
lifecycle.addObserver(lifecycleObserver)
|
||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||
}
|
||||
// The overlay caches below are file-level (shared across MapView instances), so they must be
|
||||
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
|
||||
// (The viewport save lives in MapDestination, where the grid/satellite mode is known.)
|
||||
DisposableEffect(mapView) {
|
||||
onDispose { clearMapCaches() }
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
||||
private fun clearMapCaches() {
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
lastPositions = null
|
||||
lastAction = null
|
||||
lastMapView = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||
LifecycleEventObserver { _, event ->
|
||||
|
||||
@@ -27,6 +27,9 @@ data class MapState(
|
||||
val isLightUi: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val isGridMode: Boolean = false,
|
||||
/** Grid mode: label worked (green) cells with the first callsign worked in
|
||||
* that grid instead of the grid code; non-worked cells get no label. */
|
||||
val showFirstCallLabels: Boolean = false,
|
||||
val workedGrids: Set<String> = emptySet(),
|
||||
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
|
||||
@@ -48,6 +51,8 @@ sealed interface MapAction {
|
||||
data class SelectItem(val item: OrbitalObject) : MapAction
|
||||
data class SelectDefaultItem(val catnum: Int) : MapAction
|
||||
data class ToggleGridMode(val value: Boolean) : MapAction
|
||||
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
|
||||
data class SetVisible(val isVisible: Boolean) : MapAction
|
||||
}
|
||||
|
||||
data class MapData(
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -45,6 +46,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -69,12 +71,21 @@ class MapViewModel(
|
||||
private var dataUpdateJob: Job? = null
|
||||
private var dataUpdateRate = 1000L
|
||||
private var selectedOrbitalObject: OrbitalObject? = null
|
||||
|
||||
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
|
||||
private val isScreenVisible = MutableStateFlow(true)
|
||||
val uiState: StateFlow<MapState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
isGridMode = settings.stateOfMapGrid,
|
||||
showFirstCallLabels = settings.stateOfMapFirstCall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -100,6 +111,8 @@ class MapViewModel(
|
||||
is MapAction.SelectItem -> selectSatellite(action.item)
|
||||
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
|
||||
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
|
||||
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +150,11 @@ class MapViewModel(
|
||||
selectedOrbitalObject = orbitalObject
|
||||
viewModelScope.launch {
|
||||
dataUpdateJob?.cancelAndJoin()
|
||||
dataUpdateJob = launch {
|
||||
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
|
||||
// satelliteRepo call internally hops to Default and resumes back on the caller's
|
||||
// dispatcher. On Main that posts one continuation per satellite per tick, which
|
||||
// floods the looper and ANRs for users tracking thousands of objects.
|
||||
dataUpdateJob = launch(Dispatchers.Default) {
|
||||
val dateNow = Date()
|
||||
getStationPosition()
|
||||
getSatTrack(orbitalObject, stationPos, dateNow)
|
||||
@@ -148,6 +165,8 @@ class MapViewModel(
|
||||
else -> updateFreq
|
||||
}
|
||||
while (isActive) {
|
||||
// Suspends while the map is off-screen instead of predicting into the void
|
||||
isScreenVisible.first { it }
|
||||
dateNow.time = System.currentTimeMillis()
|
||||
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
|
||||
delay(effectiveRate)
|
||||
|
||||
+7
-1
@@ -45,6 +45,7 @@ import com.rtbishop.look4sat.core.presentation.R
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,7 @@ fun ElevationCurveChart(
|
||||
startTime: Long,
|
||||
endTime: Long,
|
||||
maxElev: Double,
|
||||
isUtc: Boolean = false,
|
||||
progress: Float = 0.5f,
|
||||
onProgressChange: (Float) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
@@ -74,7 +76,11 @@ fun ElevationCurveChart(
|
||||
val textColorArgb = textColor.toArgb()
|
||||
val onSurfaceArgb = onSurfaceColor.toArgb()
|
||||
val gridColorArgb = gridColor.toArgb()
|
||||
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(isUtc) {
|
||||
SimpleDateFormat("HH:mm", Locale.getDefault()).apply {
|
||||
if (isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Canvas(
|
||||
|
||||
+117
-42
@@ -29,8 +29,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
@@ -40,6 +42,7 @@ import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -47,11 +50,13 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -71,6 +76,8 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun MutualScreen(
|
||||
@@ -147,6 +154,7 @@ fun MutualScreen(
|
||||
}
|
||||
) { isVertical ->
|
||||
MutualContent(
|
||||
viewModel = viewModel,
|
||||
state = state,
|
||||
isVertical = isVertical,
|
||||
onQuery = viewModel::queryMutualPasses,
|
||||
@@ -169,6 +177,7 @@ fun MutualScreen(
|
||||
|
||||
@Composable
|
||||
private fun MutualContent(
|
||||
viewModel: MutualViewModel,
|
||||
state: MutualUiState,
|
||||
isVertical: Boolean,
|
||||
onQuery: () -> Unit,
|
||||
@@ -186,10 +195,34 @@ private fun MutualContent(
|
||||
onHoursAhead: (Int) -> Unit,
|
||||
onClearError: () -> Unit
|
||||
) {
|
||||
val timeFormat = remember { SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()) }
|
||||
val timeFormat = remember(state.isUtc) {
|
||||
SimpleDateFormat("MM/dd HH:mm", Locale.getDefault()).apply {
|
||||
if (state.isUtc) timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
|
||||
// List state survives page switches via the Activity-scoped ViewModel: the
|
||||
// initial position is restored from the VM, and scrolling writes back to it,
|
||||
// so returning to this page keeps exactly where the user left off. A new
|
||||
// query (queryGeneration bump) rebuilds the state at the top.
|
||||
val listState = remember(viewModel.queryGeneration) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = viewModel.listScrollIndex,
|
||||
firstVisibleItemScrollOffset = viewModel.listScrollOffset
|
||||
)
|
||||
}
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
viewModel.listScrollIndex = index
|
||||
viewModel.listScrollOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
// Error message
|
||||
@@ -213,7 +246,7 @@ private fun MutualContent(
|
||||
}
|
||||
|
||||
// Input form
|
||||
item {
|
||||
item(key = "stationInputs") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
@@ -290,16 +323,21 @@ private fun MutualContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Time range + query card. Its own LazyColumn item so a prefill from
|
||||
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
|
||||
// to the time-range selector.
|
||||
item(key = "matchSearch") {
|
||||
MatchSearchCard(
|
||||
hoursAhead = state.hoursAhead,
|
||||
isCalculating = state.isCalculating,
|
||||
onHoursAhead = onHoursAhead,
|
||||
onQuery = onQuery
|
||||
)
|
||||
}
|
||||
|
||||
// Results
|
||||
itemsIndexed(state.mutualPasses) { index, pass ->
|
||||
val mutualData = MutualPassData(
|
||||
@@ -323,6 +361,7 @@ private fun MutualContent(
|
||||
pass = pass,
|
||||
isExpanded = state.selectedPassIndex == index,
|
||||
timeFormat = timeFormat,
|
||||
isUtc = state.isUtc,
|
||||
minElevA = state.stationAMinElev,
|
||||
minElevB = state.stationBMinElev,
|
||||
onClick = { onSelectPass(if (state.selectedPassIndex == index) -1 else index) },
|
||||
@@ -330,6 +369,17 @@ private fun MutualContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prefill from the map's grid-QSO dialog "Match" button: jump straight to
|
||||
// the time-range card. Index accounts for the optional error card at the
|
||||
// top (error = 0, station inputs = 1, time range = 2; else 1).
|
||||
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
|
||||
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex) {
|
||||
if (state.scrollToTimeRange) {
|
||||
listState.scrollToItem(matchSearchIndex)
|
||||
viewModel.consumeScrollToTimeRange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -527,6 +577,7 @@ private fun MutualPassCard(
|
||||
pass: MutualPass,
|
||||
isExpanded: Boolean,
|
||||
timeFormat: SimpleDateFormat,
|
||||
isUtc: Boolean,
|
||||
minElevA: Double,
|
||||
minElevB: Double,
|
||||
onClick: () -> Unit,
|
||||
@@ -558,39 +609,62 @@ private fun MutualPassCard(
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
// Header: pass info (name/time + elevations) on the left, and a
|
||||
// larger radar shortcut on the right that spans both rows vertically.
|
||||
// The pass info column narrows, so the "Opposite" elevation shifts
|
||||
// left to make room for the taller button.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = pass.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = "${timeFormat.format(Date(pass.startTime))} - ${timeFormat.format(Date(pass.endTime))}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_you, pass.maxElevationA.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.mutual_elevation_opposite, pass.maxElevationB.toInt()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
// Radar shortcut, visible even on the collapsed card: a taller
|
||||
// button spanning both header rows. The card's own click
|
||||
// (expand) is not triggered by the IconButton's tap.
|
||||
IconButton(
|
||||
onClick = onNavigateToRadar,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_radar),
|
||||
contentDescription = stringResource(R.string.mutual_open_radar),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -622,6 +696,7 @@ private fun MutualPassCard(
|
||||
startTime = visibleStart,
|
||||
endTime = visibleEnd,
|
||||
maxElev = adjustedMaxElev,
|
||||
isUtc = isUtc,
|
||||
progress = dragProgress,
|
||||
onProgressChange = { dragProgress = it }
|
||||
)
|
||||
|
||||
+55
-2
@@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -53,7 +54,12 @@ data class MutualUiState(
|
||||
val isCalculating: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
val selectedPassIndex: Int = -1,
|
||||
val errorMessage: String? = null
|
||||
val isUtc: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
|
||||
// the page scrolls to the time-range card, then consumeScrollToTimeRange()
|
||||
// clears it.
|
||||
val scrollToTimeRange: Boolean = false
|
||||
)
|
||||
|
||||
class MutualViewModel(
|
||||
@@ -67,6 +73,15 @@ class MutualViewModel(
|
||||
private val _uiState = MutableStateFlow(MutualUiState())
|
||||
val uiState: StateFlow<MutualUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Session-scoped scroll position of the pass list (Activity-scoped VM, so it
|
||||
// survives switching to another page and back). Reset to the top whenever a
|
||||
// new query replaces the results.
|
||||
var listScrollIndex: Int = 0
|
||||
var listScrollOffset: Int = 0
|
||||
|
||||
/** Bumped on every new query so the list scrolls back to the top. */
|
||||
var queryGeneration: Int = 0
|
||||
|
||||
init {
|
||||
// Pre-fill station A with the user's current station position (as grid),
|
||||
// and default min elevation to the same value used by the main radar passes
|
||||
@@ -77,8 +92,15 @@ class MutualViewModel(
|
||||
stationALat = "%.4f".format(pos.latitude),
|
||||
stationALon = "%.4f".format(pos.longitude),
|
||||
stationAMinElev = 0.0,
|
||||
stationBMinElev = 0.0
|
||||
stationBMinElev = 0.0,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc
|
||||
) }
|
||||
// Keep the time display in sync with the UTC toggle in Settings
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStationALat(value: String) {
|
||||
@@ -165,6 +187,32 @@ class MutualViewModel(
|
||||
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
|
||||
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
|
||||
|
||||
/**
|
||||
* Pre-fill the match page for a target grid picked from the map's grid-QSO
|
||||
* dialog ("Match" button): set the opposite-station grid (with its
|
||||
* coordinates), reset the time range to 24h, flag the page to scroll
|
||||
* straight to the time-range card, and start the query immediately so
|
||||
* results are ready when the page opens.
|
||||
*/
|
||||
fun prefillMatchFromGrid(grid: String) {
|
||||
val g = grid.trim().uppercase()
|
||||
val pos = qthToPosition(g)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stationBGrid = g,
|
||||
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
|
||||
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
|
||||
hoursAhead = 24,
|
||||
scrollToTimeRange = true
|
||||
)
|
||||
}
|
||||
queryMutualPasses()
|
||||
}
|
||||
|
||||
fun consumeScrollToTimeRange() {
|
||||
_uiState.update { it.copy(scrollToTimeRange = false) }
|
||||
}
|
||||
|
||||
fun queryMutualPasses() {
|
||||
val state = _uiState.value
|
||||
|
||||
@@ -192,6 +240,11 @@ class MutualViewModel(
|
||||
selectedPassIndex = -1
|
||||
)
|
||||
}
|
||||
// The results list is about to be replaced, so the scroll position must
|
||||
// not leak from the previous query's list.
|
||||
listScrollIndex = 0
|
||||
listScrollOffset = 0
|
||||
queryGeneration += 1
|
||||
|
||||
viewModelScope.launch {
|
||||
val time = System.currentTimeMillis()
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||
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.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||
@@ -62,7 +65,17 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||
)
|
||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||
OtherSettings(false, false, false, false, false, false, false, false)
|
||||
OtherSettings(
|
||||
stateOfAutoUpdate = false,
|
||||
stateOfSensors = false,
|
||||
stateOfSweep = false,
|
||||
stateOfUtc = false,
|
||||
stateOfLightTheme = false,
|
||||
stateOfNightMode = false,
|
||||
stateOfMapGrid = false,
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
)
|
||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||
@@ -70,6 +83,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||
)
|
||||
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||
|
||||
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||
@@ -93,4 +108,16 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
override fun getWorkedGrids(): Set<String> = TODO()
|
||||
override fun setWorkedGrids(grids: Set<String>) = TODO()
|
||||
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = TODO()
|
||||
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
|
||||
override fun getRoamedGrids(): Set<String> = TODO()
|
||||
override fun setRoamedGrids(grids: Set<String>) = TODO()
|
||||
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||
override fun getLastLotwSyncDate(): String = ""
|
||||
override fun setLastLotwSyncDate(date: String) = Unit
|
||||
override fun getLastLotwSyncCallsign(): String = ""
|
||||
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||
}
|
||||
@@ -73,6 +73,7 @@ private val allModes = listOf(
|
||||
)
|
||||
|
||||
private val hourSteps = listOf(1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168, 192, 216, 240)
|
||||
private val historyHourSteps = listOf(0) + hourSteps
|
||||
private const val dayMinutes = 24 * 60
|
||||
private const val minuteStep = 15
|
||||
private const val endOfDayMinute = dayMinutes - 1
|
||||
@@ -80,6 +81,7 @@ private const val quarterHourSlots = dayMinutes / minuteStep
|
||||
|
||||
data class PassFilterParams(
|
||||
val hours: Int,
|
||||
val hoursBefore: Int,
|
||||
val elevation: Double,
|
||||
val lowElevation: Double,
|
||||
val highElevation: Double,
|
||||
@@ -95,6 +97,7 @@ private fun PassesDialogPreview() {
|
||||
MainTheme {
|
||||
PassesFilterDialog(
|
||||
hours = 24,
|
||||
hoursBefore = 0,
|
||||
elevation = 16.0,
|
||||
lowElevation = 16.0,
|
||||
highElevation = 65.0,
|
||||
@@ -111,6 +114,7 @@ private fun PassesDialogPreview() {
|
||||
@Composable
|
||||
internal fun PassesFilterDialog(
|
||||
hours: Int,
|
||||
hoursBefore: Int,
|
||||
elevation: Double,
|
||||
lowElevation: Double,
|
||||
highElevation: Double,
|
||||
@@ -122,6 +126,9 @@ internal fun PassesFilterDialog(
|
||||
accept: (PassFilterParams) -> Unit
|
||||
) {
|
||||
val hoursIndex = remember { mutableIntStateOf(hourSteps.indexOfFirst { it >= hours }.coerceAtLeast(0)) }
|
||||
val hoursBeforeIndex = remember {
|
||||
mutableIntStateOf(historyHourSteps.indexOfFirst { it >= hoursBefore }.coerceAtLeast(0))
|
||||
}
|
||||
val elevationValueNew = remember { mutableDoubleStateOf(elevation) }
|
||||
val highlightBounds = 0f..90f
|
||||
var highlightRange by remember(lowElevation, highElevation) {
|
||||
@@ -136,6 +143,7 @@ internal fun PassesFilterDialog(
|
||||
accept(
|
||||
PassFilterParams(
|
||||
hours = hourSteps[hoursIndex.intValue],
|
||||
hoursBefore = historyHourSteps[hoursBeforeIndex.intValue],
|
||||
elevation = elevationValueNew.doubleValue,
|
||||
lowElevation = highlightRange.start.roundToInt().toDouble(),
|
||||
highElevation = highlightRange.endInclusive.roundToInt().toDouble(),
|
||||
@@ -164,6 +172,14 @@ internal fun PassesFilterDialog(
|
||||
valueRange = 0f..(hourSteps.size - 1).toFloat(),
|
||||
steps = hourSteps.size - 2
|
||||
) { hoursIndex.intValue = it.toInt().coerceIn(0, hourSteps.size - 1) }
|
||||
SliderRow(
|
||||
title = stringResource(R.string.pass_filter_history_hours),
|
||||
value = hoursBeforeIndex.intValue.toDouble(),
|
||||
displayValue = formatHoursLabel(historyHourSteps[hoursBeforeIndex.intValue]),
|
||||
valueResId = R.drawable.ic_clock,
|
||||
valueRange = 0f..(historyHourSteps.size - 1).toFloat(),
|
||||
steps = historyHourSteps.size - 2
|
||||
) { hoursBeforeIndex.intValue = it.toInt().coerceIn(0, historyHourSteps.size - 1) }
|
||||
ToggleRow(
|
||||
title = stringResource(R.string.pass_filter_deep_space),
|
||||
checked = deepSpaceEnabled,
|
||||
|
||||
@@ -103,6 +103,7 @@ private fun PassesScreen(
|
||||
if (uiState.isPassesDialogShown) {
|
||||
PassesFilterDialog(
|
||||
hours = uiState.hours,
|
||||
hoursBefore = uiState.hoursBefore,
|
||||
elevation = uiState.elevation,
|
||||
lowElevation = uiState.lowElevation,
|
||||
highElevation = uiState.highElevation,
|
||||
@@ -115,6 +116,7 @@ private fun PassesScreen(
|
||||
onAction(
|
||||
PassesAction.FilterPasses(
|
||||
hoursAhead = params.hours,
|
||||
hoursBefore = params.hoursBefore,
|
||||
minElevation = params.elevation,
|
||||
lowElevation = params.lowElevation,
|
||||
highElevation = params.highElevation,
|
||||
|
||||
@@ -28,6 +28,8 @@ data class PassesState(
|
||||
val nextTime: String = "00:00:00",
|
||||
val isNextTimeAos: Boolean = true,
|
||||
val hours: Int = 24,
|
||||
/** 历史过境回看窗口(小时), 0 = 只看未来. */
|
||||
val hoursBefore: Int = 0,
|
||||
val elevation: Double = 16.0,
|
||||
val lowElevation: Double = 16.0,
|
||||
val highElevation: Double = 65.0,
|
||||
@@ -47,6 +49,7 @@ sealed interface PassesAction {
|
||||
data object DismissWhatsNew : PassesAction
|
||||
data class FilterPasses(
|
||||
val hoursAhead: Int,
|
||||
val hoursBefore: Int,
|
||||
val minElevation: Double,
|
||||
val lowElevation: Double,
|
||||
val highElevation: Double,
|
||||
|
||||
+27
-12
@@ -55,6 +55,7 @@ class PassesViewModel(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
nextPass = defaultPass,
|
||||
hours = settingsRepo.passesSettings.value.hoursAhead,
|
||||
hoursBefore = settingsRepo.passesSettings.value.hoursBefore,
|
||||
elevation = settingsRepo.passesSettings.value.minElevation,
|
||||
lowElevation = settingsRepo.otherSettings.value.lowElevation,
|
||||
highElevation = settingsRepo.otherSettings.value.highElevation,
|
||||
@@ -95,8 +96,12 @@ class PassesViewModel(
|
||||
val timeNow = System.currentTimeMillis()
|
||||
val isUtc = _uiState.value.isUtc
|
||||
val showDeepSpace = _uiState.value.showDeepSpace
|
||||
// 历史过境窗口: 已结束的过境保留到超过 hoursBefore 小时后消失;
|
||||
// DeepSpace 保持原行为(始终显示). hoursBefore=0 时过境一结束即消失.
|
||||
val historyCutoff = timeNow - _uiState.value.hoursBefore * 3600_000L
|
||||
val filtered = allPasses
|
||||
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
|
||||
.filter { pass -> pass.isDeepSpace || pass.losTime >= historyCutoff }
|
||||
val processed = computePassProgress(filtered, timeNow)
|
||||
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
|
||||
val sunTimes = computeSunTimes(processed, isUtc)
|
||||
@@ -123,6 +128,7 @@ class PassesViewModel(
|
||||
is PassesAction.FilterPasses ->
|
||||
applyFilter(
|
||||
hoursAhead = action.hoursAhead,
|
||||
hoursBefore = action.hoursBefore,
|
||||
minElevation = action.minElevation,
|
||||
lowElevation = action.lowElevation,
|
||||
highElevation = action.highElevation,
|
||||
@@ -135,6 +141,7 @@ class PassesViewModel(
|
||||
is PassesAction.FilterRadios ->
|
||||
applyFilter(
|
||||
hoursAhead = _uiState.value.hours,
|
||||
hoursBefore = _uiState.value.hoursBefore,
|
||||
minElevation = _uiState.value.elevation,
|
||||
lowElevation = _uiState.value.lowElevation,
|
||||
highElevation = _uiState.value.highElevation,
|
||||
@@ -175,9 +182,12 @@ class PassesViewModel(
|
||||
val sdfDate = dateFormat(tz)
|
||||
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
|
||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||
// DeepSpace group always shows today's sun times
|
||||
// DeepSpace group always shows today's sun times.
|
||||
// The natural-day anchor is the DEVICE timezone, not the display timezone:
|
||||
// UTC midnight is 08:00 local for UTC+8 stations, when the sun is already
|
||||
// up — that would trip the white-night guard and render "--:--".
|
||||
if (passes.any { it.isDeepSpace }) {
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), TimeZone.getDefault()))
|
||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||
result["DeepSpace (period >225min)"] = rise to set
|
||||
@@ -186,7 +196,7 @@ class PassesViewModel(
|
||||
if (pass.isDeepSpace) continue
|
||||
val label = sdfDate.format(Date(pass.aosTime))
|
||||
if (label in result) continue
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, TimeZone.getDefault()))
|
||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||
result[label] = rise to set
|
||||
@@ -243,7 +253,7 @@ class PassesViewModel(
|
||||
return ordered
|
||||
}
|
||||
|
||||
/** Computes live progress for each pass, filtering out expired ones. */
|
||||
/** Computes live progress while retaining completed passes in the requested history window. */
|
||||
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
|
||||
val result = ArrayList<OrbitalPass>(passList.size)
|
||||
for (pass in passList) {
|
||||
@@ -251,9 +261,9 @@ class PassesViewModel(
|
||||
val deltaNow = time.minus(pass.aosTime).toFloat()
|
||||
val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat()
|
||||
val newProgress = (deltaNow / deltaTotal).round(2)
|
||||
if (newProgress >= 1.0f) continue
|
||||
if (newProgress != pass.progress) {
|
||||
result.add(pass.copy(progress = newProgress))
|
||||
val boundedProgress = newProgress.coerceIn(0f, 1f)
|
||||
if (boundedProgress != pass.progress) {
|
||||
result.add(pass.copy(progress = boundedProgress))
|
||||
} else {
|
||||
result.add(pass)
|
||||
}
|
||||
@@ -282,6 +292,7 @@ class PassesViewModel(
|
||||
|
||||
private fun applyFilter(
|
||||
hoursAhead: Int,
|
||||
hoursBefore: Int,
|
||||
minElevation: Double,
|
||||
lowElevation: Double,
|
||||
highElevation: Double,
|
||||
@@ -299,13 +310,15 @@ class PassesViewModel(
|
||||
aosStartMinute,
|
||||
aosEndMinute,
|
||||
invertAosTimeWindow,
|
||||
modes
|
||||
modes,
|
||||
hoursBefore
|
||||
)
|
||||
)
|
||||
settingsRepo.updateOtherSettings { it.copy(lowElevation = lowElevation, highElevation = highElevation) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
hours = hoursAhead,
|
||||
hoursBefore = hoursBefore,
|
||||
elevation = minElevation,
|
||||
lowElevation = lowElevation,
|
||||
highElevation = highElevation,
|
||||
@@ -316,9 +329,10 @@ class PassesViewModel(
|
||||
modes = modes
|
||||
)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = hoursAhead,
|
||||
time = now - hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = hoursBefore + hoursAhead,
|
||||
minElevation = minElevation,
|
||||
aosStartMinute = aosStartMinute,
|
||||
aosEndMinute = aosEndMinute,
|
||||
@@ -329,9 +343,10 @@ class PassesViewModel(
|
||||
|
||||
private fun refreshPasses() = viewModelScope.launch {
|
||||
val settings = settingsRepo.passesSettings.value
|
||||
// 下拉刷新也保留历史过境窗口, 否则刷新即丢 history (与筛选/启动计算口径一致)
|
||||
satelliteRepo.calculatePasses(
|
||||
time = System.currentTimeMillis(),
|
||||
hoursAhead = settings.hoursAhead,
|
||||
time = System.currentTimeMillis() - settings.hoursBefore * 60L * 60L * 1000L,
|
||||
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||
minElevation = settings.minElevation,
|
||||
aosStartMinute = settings.aosStartMinute,
|
||||
aosEndMinute = settings.aosEndMinute,
|
||||
|
||||
@@ -32,8 +32,11 @@ 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.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -98,14 +101,17 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
}
|
||||
LaunchedEffect(mutualData.endTime) {
|
||||
if (mutualData.endTime <= 0L) return@LaunchedEffect
|
||||
while (true) {
|
||||
val remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
if (remainingMs <= 0L) {
|
||||
navigateUpAndClearMutual()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Auto-return only while the mutual pass is actually in progress. An
|
||||
// already-finished pass must NOT bounce the radar page back instantly
|
||||
// (that made the pass-card radar shortcut look broken: tapping it while
|
||||
// the computed pass had ended returned to Mutual immediately).
|
||||
var remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
if (remainingMs <= 0L) return@LaunchedEffect
|
||||
while (remainingMs > 0L) {
|
||||
delay(remainingMs.coerceAtMost(1000L))
|
||||
remainingMs = mutualData.endTime - System.currentTimeMillis()
|
||||
}
|
||||
navigateUpAndClearMutual()
|
||||
}
|
||||
// Sync actual permission state on every recomposition so it survives screen re-entry
|
||||
val hasPermission = ContextCompat.checkSelfPermission(
|
||||
@@ -166,6 +172,7 @@ private fun RadarScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layoutPadding()
|
||||
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||
.keepScreenOn(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
|
||||
@@ -82,7 +82,7 @@ class RadarViewModel(
|
||||
private val _uiState = MutableStateFlow(
|
||||
RadarState(
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
orientationValues = sensorsRepo.sensorData.value,
|
||||
orientationValues = correctedOrientation(sensorsRepo.sensorData.value),
|
||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||
@@ -102,12 +102,19 @@ class RadarViewModel(
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsRepo.sensorData.collect { data ->
|
||||
val orientationValues = (data.first + magDeclination) to data.second
|
||||
val orientationValues = correctedOrientation(data)
|
||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 指南针方位 = 传感器方位 + 磁偏角 + 手动偏置(compassOffsetDegrees, 校准用). */
|
||||
private fun correctedOrientation(data: Pair<Float, Float>): Pair<Float, Float> {
|
||||
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
|
||||
val azimuth = ((data.first + magDeclination + offset) % 360f + 360f) % 360f
|
||||
return azimuth to data.second
|
||||
}
|
||||
|
||||
private fun collectSettingsChanges() {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
|
||||
+18
-1
@@ -48,6 +48,8 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -78,6 +80,7 @@ import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -123,7 +126,21 @@ fun TransceiversPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
|
||||
// contentPadding for the system navigation bar: the radar page is a
|
||||
// full-screen destination, so on gesture-nav devices the translucent
|
||||
// nav bar floats over the list bottom and covers the CW decoder's
|
||||
// decoded-text output when the panel is scrolled to the end. Padding
|
||||
// the last item above the bar fixes it (three-button nav already
|
||||
// resizes the window, where this padding is a harmless no-op).
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(
|
||||
bottom = with(LocalDensity.current) {
|
||||
(WindowInsets.navigationBars.getBottom(this) / density).dp
|
||||
}
|
||||
)
|
||||
) {
|
||||
itemsIndexed(items = transceivers, key = { _, radio -> radio.uuid }) { _, radio ->
|
||||
val isExpanded = radio.uuid == selectedUuid
|
||||
TransceiverItem(
|
||||
|
||||
+75
-15
@@ -50,6 +50,7 @@ import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -86,6 +87,7 @@ import androidx.compose.ui.zIndex
|
||||
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
@@ -228,23 +230,27 @@ fun LoTWDialog(
|
||||
initialSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
isSyncing: Boolean,
|
||||
syncMode: LoTWSyncMode?,
|
||||
progress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress?,
|
||||
message: String?,
|
||||
dismiss: () -> Unit,
|
||||
/** Called on cancel/back while a sync is running: aborts the job. */
|
||||
onCancelSync: () -> Unit,
|
||||
onSave: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
|
||||
onSync: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
|
||||
onSyncFull: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit,
|
||||
onSyncIncremental: (com.rtbishop.look4sat.core.domain.model.LoTWSettings) -> Unit
|
||||
) {
|
||||
val call = rememberSaveable { mutableStateOf(initialSettings.callsign) }
|
||||
val pass = rememberSaveable { mutableStateOf(initialSettings.password) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_lotw_title),
|
||||
onCancel = dismiss,
|
||||
// The confirm button IS the sync action: saving credentials and
|
||||
// fetching confirmed grids happen in one step.
|
||||
onAccept = {
|
||||
onSync(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value))
|
||||
},
|
||||
acceptText = stringResource(if (isSyncing) R.string.prefs_lotw_syncing else R.string.prefs_lotw_sync),
|
||||
acceptEnabled = !isSyncing
|
||||
// While syncing, cancel/back aborts the download instead of merely
|
||||
// hiding the dialog — a full sync started by mistake must be stoppable.
|
||||
onDismissRequest = { if (isSyncing) onCancelSync() else dismiss() },
|
||||
onCancel = { if (isSyncing) onCancelSync() else dismiss() },
|
||||
// The two sync buttons below are the primary actions: saving
|
||||
// credentials and fetching confirmed grids happen in one step.
|
||||
onAccept = null
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = call.value,
|
||||
@@ -268,12 +274,50 @@ fun LoTWDialog(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_lotw_sync_duration_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
if (isSyncing) {
|
||||
// Live progress: once the report header reveals the record count,
|
||||
// switch to "N QSOs, ~X s remaining" with a determinate bar.
|
||||
val progressText = when {
|
||||
progress == null ||
|
||||
progress.phase == com.rtbishop.look4sat.core.domain.repository.LoTWPhase.Connecting ->
|
||||
stringResource(R.string.lotw_sync_progress_connecting)
|
||||
// Download finished (or the size estimate reached its cap):
|
||||
// parsing and saving may still run — say so instead of the bar
|
||||
// sitting at 100% with no feedback, which reads as "stuck".
|
||||
progress.fraction >= 1f ->
|
||||
stringResource(R.string.lotw_sync_progress_saving)
|
||||
progress.qsoCount > 0 && progress.remainingSeconds > 0 ->
|
||||
stringResource(R.string.lotw_sync_progress_qso, progress.qsoCount, progress.remainingSeconds)
|
||||
progress.qsoCount > 0 ->
|
||||
stringResource(R.string.lotw_sync_progress_count, progress.qsoCount)
|
||||
else -> stringResource(R.string.lotw_sync_progress_downloading)
|
||||
}
|
||||
Text(
|
||||
text = progressText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
if (progress != null && progress.expectedBytes > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.fraction },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Pre-sync estimate: rate per 100 QSOs (measured ~8 s at ARRL's
|
||||
// ~10 KB/s stream rate), plus the fixed server-side generation time.
|
||||
Text(
|
||||
text = stringResource(R.string.lotw_sync_rate_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
@@ -282,6 +326,22 @@ fun LoTWDialog(
|
||||
modifier = Modifier.padding(horizontal = LocalSpacing.current.large)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large)
|
||||
) {
|
||||
// Full sync bottom-left, incremental merge bottom-right.
|
||||
CardButton(
|
||||
onClick = { onSyncFull(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
text = stringResource(R.string.lotw_sync_full),
|
||||
isEnabled = !isSyncing
|
||||
)
|
||||
CardButton(
|
||||
onClick = { onSyncIncremental(com.rtbishop.look4sat.core.domain.model.LoTWSettings(call.value, pass.value)) },
|
||||
text = stringResource(R.string.lotw_sync_incremental),
|
||||
isEnabled = !isSyncing
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
}
|
||||
|
||||
+159
-9
@@ -38,8 +38,10 @@ import androidx.compose.material3.ElevatedCard
|
||||
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.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -56,6 +58,8 @@ 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.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -66,8 +70,11 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.PrimaryIconCard
|
||||
@@ -203,15 +210,43 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
onSave = { onAction(SettingsAction.UpdateRadioControl(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.compassCalibration) {
|
||||
LaunchedEffect(Unit) { onAction(SettingsAction.StartCompassCalibration) }
|
||||
CompassCalibrationDialog(
|
||||
accuracy = uiState.compassAccuracy,
|
||||
headingDegrees = uiState.compassHeadingDegrees,
|
||||
initialOffsetDegrees = uiState.otherSettings.compassOffsetDegrees,
|
||||
onDismiss = {
|
||||
onAction(SettingsAction.StopCompassCalibration)
|
||||
dialogs.compassCalibration = false
|
||||
},
|
||||
onSave = { onAction(SettingsAction.SetCompassOffset(it)) }
|
||||
)
|
||||
}
|
||||
if (dialogs.lotw) {
|
||||
// LoTW sync failures are typed codes from the ViewModel; render them
|
||||
// through string resources so the dialog follows the system language.
|
||||
val lotwErrorMessage = uiState.lotwError?.let { error ->
|
||||
when (error) {
|
||||
LoTWError.NotConfigured -> stringResource(R.string.lotw_sync_error_not_configured)
|
||||
LoTWError.BadCredentials -> stringResource(R.string.lotw_sync_error_credentials)
|
||||
LoTWError.RateLimited -> stringResource(R.string.lotw_sync_error_rate_limited)
|
||||
LoTWError.Timeout -> stringResource(R.string.lotw_sync_error_timeout)
|
||||
is LoTWError.Network -> stringResource(R.string.lotw_sync_error_network, error.detail)
|
||||
}
|
||||
}
|
||||
LoTWDialog(
|
||||
initialSettings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
isSyncing = uiState.lotwSyncing,
|
||||
message = uiState.lotwMessage,
|
||||
syncMode = uiState.lotwSyncMode,
|
||||
progress = uiState.lotwProgress,
|
||||
message = lotwErrorMessage,
|
||||
dismiss = { dialogs.lotw = false },
|
||||
onCancelSync = { onAction(SettingsAction.CancelLoTWSync); dialogs.lotw = false },
|
||||
onSave = { onAction(SettingsAction.UpdateLoTW(it)) },
|
||||
onSync = { onAction(SettingsAction.SyncLoTWGrids(it)) }
|
||||
onSyncFull = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Full)) },
|
||||
onSyncIncremental = { onAction(SettingsAction.SyncLoTWGrids(it, LoTWSyncMode.Incremental)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -322,10 +357,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
LoTWCard(
|
||||
settings = uiState.lotwSettings,
|
||||
workedGridsCount = uiState.workedGridsCount,
|
||||
lastSyncEpochMs = uiState.lotwLastSyncEpochMs,
|
||||
showLoTWDialog = { dialogs.lotw = true }
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item {
|
||||
OtherCard(
|
||||
settings = uiState.otherSettings,
|
||||
onCompassCalibration = { dialogs.compassCalibration = true },
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
@@ -513,15 +555,19 @@ private fun OtherCardPreview() = MainTheme {
|
||||
shouldSeeWarning = false,
|
||||
shouldSeeWhatsNew = false
|
||||
)
|
||||
OtherCard(settings = values) {}
|
||||
OtherCard(settings = values, onCompassCalibration = {}, onAction = {})
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||
private fun OtherCard(
|
||||
settings: OtherSettings,
|
||||
onCompassCalibration: () -> Unit,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(312.dp)
|
||||
.height(370.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
@@ -534,19 +580,112 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
||||
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
|
||||
onAction(SettingsAction.ToggleUpdate(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_lotw_sync, settings.stateOfAutoLotwSync) {
|
||||
onAction(SettingsAction.ToggleAutoLotwSync(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
|
||||
onAction(SettingsAction.ToggleSweep(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_sensors, settings.stateOfSensors) {
|
||||
onAction(SettingsAction.ToggleSensor(it))
|
||||
}
|
||||
CardButton(
|
||||
onClick = onCompassCalibration,
|
||||
text = stringResource(R.string.prefs_compass_calibration_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||
onAction(SettingsAction.ToggleNightMode(it))
|
||||
}
|
||||
SwitchRow(R.string.prefs_other_switch_map_grid, settings.stateOfMapGrid) {
|
||||
onAction(SettingsAction.ToggleMapGrid(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指南针校准对话框: 8 字动作提升精度, 实时显示校正后航向, 可手动输入偏置(±180°).
|
||||
*/
|
||||
@Composable
|
||||
private fun CompassCalibrationDialog(
|
||||
accuracy: CompassAccuracy,
|
||||
headingDegrees: Float,
|
||||
initialOffsetDegrees: Float,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Float) -> Unit
|
||||
) {
|
||||
var offsetText by rememberSaveable(initialOffsetDegrees) {
|
||||
mutableStateOf(initialOffsetDegrees.toString())
|
||||
}
|
||||
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
|
||||
val statusRes = when (accuracy) {
|
||||
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
|
||||
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
|
||||
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
|
||||
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
|
||||
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
|
||||
}
|
||||
val progress = when (accuracy) {
|
||||
CompassAccuracy.HIGH -> 1f
|
||||
CompassAccuracy.MEDIUM -> 0.66f
|
||||
CompassAccuracy.LOW -> 0.33f
|
||||
CompassAccuracy.UNRELIABLE, CompassAccuracy.UNAVAILABLE -> 0f
|
||||
}
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_compass_calibration_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
onAccept = {
|
||||
parsedOffset?.let {
|
||||
onSave(it)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
) {
|
||||
Text(
|
||||
text = "∞",
|
||||
fontSize = 92.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
lineHeight = 92.sp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_calibration_instruction),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_accuracy, stringResource(statusRes)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (accuracy == CompassAccuracy.HIGH) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_heading, headingDegrees),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offsetText,
|
||||
onValueChange = { offsetText = it },
|
||||
label = { Text(stringResource(R.string.prefs_compass_offset)) },
|
||||
supportingText = { Text(stringResource(R.string.prefs_compass_offset_support)) },
|
||||
isError = parsedOffset == null,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
CardButton(
|
||||
onClick = { offsetText = "0" },
|
||||
text = stringResource(R.string.prefs_compass_offset_reset),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +705,7 @@ private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boole
|
||||
private fun LoTWCard(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
workedGridsCount: Int,
|
||||
lastSyncEpochMs: Long,
|
||||
showLoTWDialog: () -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -584,6 +724,14 @@ private fun LoTWCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2
|
||||
)
|
||||
// Last successful sync, shown exactly like the ephemeris update time.
|
||||
if (lastSyncEpochMs != 0L) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatUpdateTime(updateTime = lastSyncEpochMs),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = showLoTWDialog,
|
||||
@@ -715,6 +863,7 @@ private class DialogVisibility {
|
||||
var radioControl by mutableStateOf(false)
|
||||
var wavelog by mutableStateOf(false)
|
||||
var lotw by mutableStateOf(false)
|
||||
var compassCalibration by mutableStateOf(false)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -722,13 +871,14 @@ private fun rememberDialogVisibility(): DialogVisibility {
|
||||
return rememberSaveable(saver = run {
|
||||
androidx.compose.runtime.saveable.Saver(
|
||||
save = {
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw)
|
||||
listOf(it.position, it.locator, it.dataSources, it.network, it.bluetooth, it.radioControl, it.wavelog, it.lotw, it.compassCalibration)
|
||||
},
|
||||
restore = {
|
||||
DialogVisibility().apply {
|
||||
position = it[0]; locator = it[1]; dataSources = it[2]
|
||||
network = it[3]; bluetooth = it[4]; radioControl = it[5]; wavelog = it[6]
|
||||
lotw = it.getOrElse(7) { false }
|
||||
compassCalibration = it.getOrElse(8) { false }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+30
-3
@@ -24,6 +24,8 @@ import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import java.io.File
|
||||
|
||||
data class PositionSettings(
|
||||
@@ -61,10 +63,27 @@ data class SettingsState(
|
||||
val wavelogMessage: String? = null,
|
||||
val lotwSettings: com.rtbishop.look4sat.core.domain.model.LoTWSettings = com.rtbishop.look4sat.core.domain.model.LoTWSettings(),
|
||||
val lotwSyncing: Boolean = false,
|
||||
val lotwMessage: String? = null,
|
||||
val lotwSyncMode: LoTWSyncMode? = null,
|
||||
val lotwProgress: com.rtbishop.look4sat.core.domain.repository.LoTWProgress? = null,
|
||||
val lotwError: LoTWError? = null,
|
||||
/** Epoch ms of the last successful LoTW sync (0 = never) — shown like the ephemeris update time. */
|
||||
val lotwLastSyncEpochMs: Long = 0L,
|
||||
/** 指南针校准精度等级 (校准对话框进度条). */
|
||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
||||
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
||||
val compassHeadingDegrees: Float = 0f,
|
||||
val updateChecker: UpdateCheckerState = UpdateCheckerState()
|
||||
)
|
||||
|
||||
/** LoTW sync failure, kept as a translatable code until the UI renders it. */
|
||||
sealed interface LoTWError {
|
||||
data object NotConfigured : LoTWError
|
||||
data object BadCredentials : LoTWError
|
||||
data object RateLimited : LoTWError
|
||||
data object Timeout : LoTWError
|
||||
data class Network(val detail: String) : LoTWError
|
||||
}
|
||||
|
||||
sealed interface SettingsAction {
|
||||
// Position
|
||||
data object SetGpsPosition : SettingsAction
|
||||
@@ -81,11 +100,14 @@ sealed interface SettingsAction {
|
||||
// Toggles
|
||||
data class ToggleUtc(val value: Boolean) : SettingsAction
|
||||
data class ToggleUpdate(val value: Boolean) : SettingsAction
|
||||
data class ToggleAutoLotwSync(val value: Boolean) : SettingsAction
|
||||
data class ToggleSweep(val value: Boolean) : SettingsAction
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data object StartCompassCalibration : SettingsAction
|
||||
data object StopCompassCalibration : SettingsAction
|
||||
data class SetCompassOffset(val degrees: Float) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||
data class ToggleMapGrid(val value: Boolean) : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
@@ -100,7 +122,12 @@ sealed interface SettingsAction {
|
||||
|
||||
// LoTW confirmed grids
|
||||
data class UpdateLoTW(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
data class SyncLoTWGrids(val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) : SettingsAction
|
||||
data class SyncLoTWGrids(
|
||||
val settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
val mode: LoTWSyncMode
|
||||
) : SettingsAction
|
||||
/** Abort an in-flight LoTW sync (wrong button / changed mind). */
|
||||
data object CancelLoTWSync : SettingsAction
|
||||
|
||||
// Update checker
|
||||
data object CheckForUpdate : SettingsAction
|
||||
|
||||
+103
-30
@@ -25,8 +25,15 @@ import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
@@ -43,12 +50,17 @@ class SettingsViewModel(
|
||||
private val updateRepo: IUpdateRepository,
|
||||
private val wavelogRepo: IWavelogRepository,
|
||||
private val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository,
|
||||
private val sensorsRepo: ISensorsRepo,
|
||||
private val apkFile: File,
|
||||
private val showToast: IShowToast
|
||||
) : ViewModel() {
|
||||
|
||||
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
||||
private val defaultDataSettings = DataSettings(false, 0, 0, 0L)
|
||||
/** In-flight LoTW sync job, cancelled by CancelLoTWSync. */
|
||||
private var lotwSyncJob: kotlinx.coroutines.Job? = null
|
||||
/** 指南针校准: 传感器原始方位(未加磁偏角/偏置), 校准对话框实时显示校正后航向. */
|
||||
private var rawCompassHeading = sensorsRepo.sensorData.value.first
|
||||
private val _uiState = MutableStateFlow(
|
||||
SettingsState(
|
||||
appVersionName = settingsRepo.appVersionName,
|
||||
@@ -60,7 +72,10 @@ class SettingsViewModel(
|
||||
dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
|
||||
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
|
||||
wavelogSettings = settingsRepo.wavelogSettings.value,
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size
|
||||
workedGridsCount = settingsRepo.getWorkedGrids().size,
|
||||
compassAccuracy = sensorsRepo.compassAccuracy.value,
|
||||
compassHeadingDegrees = correctedCompassHeading(),
|
||||
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
|
||||
)
|
||||
)
|
||||
|
||||
@@ -70,7 +85,10 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { geoPos ->
|
||||
_uiState.update {
|
||||
it.copy(positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos))
|
||||
it.copy(
|
||||
positionSettings = it.positionSettings.copy(isUpdating = false, stationPos = geoPos),
|
||||
compassHeadingDegrees = correctedCompassHeading()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +113,20 @@ class SettingsViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collect { settings ->
|
||||
_uiState.update { it.copy(otherSettings = settings) }
|
||||
_uiState.update {
|
||||
it.copy(otherSettings = settings, compassHeadingDegrees = correctedCompassHeading())
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.compassAccuracy.collect { accuracy ->
|
||||
_uiState.update { it.copy(compassAccuracy = accuracy) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sensorsRepo.sensorData.collect { orientation ->
|
||||
rawCompassHeading = orientation.first
|
||||
_uiState.update { it.copy(compassHeadingDegrees = correctedCompassHeading()) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -145,11 +176,16 @@ class SettingsViewModel(
|
||||
// Toggles
|
||||
is SettingsAction.ToggleUtc -> settingsRepo.updateOtherSettings { it.copy(stateOfUtc = action.value) }
|
||||
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
|
||||
is SettingsAction.ToggleAutoLotwSync -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoLotwSync = action.value) }
|
||||
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
SettingsAction.StartCompassCalibration -> sensorsRepo.enableSensor()
|
||||
SettingsAction.StopCompassCalibration -> sensorsRepo.disableSensor()
|
||||
is SettingsAction.SetCompassOffset -> settingsRepo.updateOtherSettings {
|
||||
it.copy(compassOffsetDegrees = action.degrees.coerceIn(-180f, 180f))
|
||||
}
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.ToggleMapGrid -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
@@ -159,7 +195,8 @@ class SettingsViewModel(
|
||||
is SettingsAction.SyncWorkedGrids -> syncWorkedGrids(action.settings)
|
||||
// LoTW confirmed grids
|
||||
is SettingsAction.UpdateLoTW -> settingsRepo.updateLoTWSettings(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings)
|
||||
is SettingsAction.SyncLoTWGrids -> syncLoTWGrids(action.settings, action.mode)
|
||||
SettingsAction.CancelLoTWSync -> cancelLoTWSync()
|
||||
// Update checker
|
||||
SettingsAction.CheckForUpdate -> checkForUpdate()
|
||||
SettingsAction.DownloadUpdate -> downloadUpdate()
|
||||
@@ -192,61 +229,84 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncLoTWGrids(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
||||
private fun syncLoTWGrids(
|
||||
settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings,
|
||||
mode: LoTWSyncMode
|
||||
) {
|
||||
if (!settings.isConfigured) {
|
||||
_uiState.update { it.copy(lotwMessage = "LoTW callsign/password not configured") }
|
||||
_uiState.update { it.copy(lotwError = LoTWError.NotConfigured) }
|
||||
return
|
||||
}
|
||||
// Persist the credentials first, then sync with the freshly-typed values.
|
||||
settingsRepo.updateLoTWSettings(settings)
|
||||
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
|
||||
// Wavelog entry removed: LoTW is now the only source, so the
|
||||
// synced set fully replaces the stored worked grids.
|
||||
settingsRepo.setWorkedGrids(result.grids)
|
||||
settingsRepo.setWorkedGridQsos(result.qsos)
|
||||
settingsRepo.setRoamedGrids(result.roamedGrids)
|
||||
// Incremental only makes sense for the same callsign as the last sync;
|
||||
// a first sync, an unknown/empty record or a callsign change must fall
|
||||
// back to a full report so no stale grids from another account linger.
|
||||
val callsign = settings.callsign.trim().uppercase()
|
||||
val effectiveMode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, mode)
|
||||
val since = if (effectiveMode == LoTWSyncMode.Incremental) {
|
||||
lotwCursorApi(settingsRepo.getLastLotwSyncDate())
|
||||
} else ""
|
||||
_uiState.update {
|
||||
it.copy(lotwSyncing = true, lotwSyncMode = effectiveMode, lotwProgress = null, lotwError = null)
|
||||
}
|
||||
lotwSyncJob = viewModelScope.launch {
|
||||
val result = lotwRepo.fetchConfirmedGridQsos(callsign, settings.password, since) { progress ->
|
||||
_uiState.update { it.copy(lotwProgress = progress) }
|
||||
}
|
||||
when (result) {
|
||||
is LoTWResult.Success -> {
|
||||
// Incremental: merge new grids/QSOs into the stored set (dedup
|
||||
// by call + QSO time); full: the fresh report replaces it all.
|
||||
// Shared with the automatic sync on app start (MainApplication).
|
||||
val mergedGridsCount = applyLoTWGridResult(settingsRepo, result, effectiveMode, callsign)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false, workedGridsCount = result.grids.size,
|
||||
lotwMessage = null
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
workedGridsCount = mergedGridsCount, lotwError = null,
|
||||
lotwLastSyncEpochMs = lotwCursorEpochMs(settingsRepo.getLastLotwSyncDate())
|
||||
)
|
||||
}
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
|
||||
is LoTWResult.BadCredentials ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — callsign or password incorrect"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.BadCredentials
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
|
||||
is LoTWResult.RateLimited ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.RateLimited
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
|
||||
is LoTWResult.Timeout ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — connection timed out, try another network"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.Timeout
|
||||
)
|
||||
}
|
||||
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
|
||||
is LoTWResult.NetworkError ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
lotwSyncing = false,
|
||||
lotwMessage = "LoTW sync failed — network error (${result.detail})"
|
||||
lotwSyncing = false, lotwSyncMode = null, lotwProgress = null,
|
||||
lotwError = LoTWError.Network(result.detail)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort the in-flight LoTW sync job and reset its progress state. */
|
||||
private fun cancelLoTWSync() {
|
||||
lotwSyncJob?.cancel()
|
||||
lotwSyncJob = null
|
||||
_uiState.update { it.copy(lotwSyncing = false, lotwSyncMode = null, lotwProgress = null) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Update checker
|
||||
@@ -325,6 +385,18 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** 校正后航向 = 传感器原始方位 + 磁偏角 + 手动偏置. */
|
||||
private fun correctedCompassHeading(): Float {
|
||||
val declination = sensorsRepo.getMagDeclination(settingsRepo.stationPosition.value)
|
||||
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
|
||||
return ((rawCompassHeading + declination + offset) % 360f + 360f) % 360f
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
sensorsRepo.disableSensor()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Data update helpers — consolidated from 3 near-identical functions
|
||||
@@ -372,6 +444,7 @@ class SettingsViewModel(
|
||||
updateRepo = container.updateRepo,
|
||||
wavelogRepo = container.wavelogRepo,
|
||||
lotwRepo = container.lotwRepo,
|
||||
sensorsRepo = container.provideSensorsRepo(),
|
||||
apkFile = File(context.cacheDir, "look4sat-update.apk"),
|
||||
showToast = container.provideShowToast()
|
||||
)
|
||||
|
||||
+13
-8
@@ -58,7 +58,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
@@ -316,7 +315,6 @@ private fun HeaderRow(statuses: List<SatStatus>) {
|
||||
/** Satellite row: name takes remaining width; day tiles are fixed-width (tablet-safe). */
|
||||
@Composable
|
||||
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -329,9 +327,8 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
modifier = Modifier.weight(1f).padding(end = 4.dp)
|
||||
)
|
||||
status.days.forEach { day ->
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
DayCell(
|
||||
slot = slot,
|
||||
day = day,
|
||||
modifier = Modifier.width(TILE_WIDTH).padding(horizontal = 2.dp),
|
||||
onClick = { onClickDay(day) }
|
||||
)
|
||||
@@ -339,10 +336,18 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Day block: newest reported status among the day's 12 slots; gray when none. */
|
||||
/**
|
||||
* Day block: colored by the newest reported status among the day's 12 slots
|
||||
* (gray when none); the number is the count of the most recent consecutive
|
||||
* reports sharing that same status (day.streakCount), not the total report
|
||||
* count of the whole day.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
|
||||
val noReportGray = 0xFFC0C0C0L
|
||||
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
|
||||
val color = Color(slot.statusColor)
|
||||
val streakCount = day.streakCount
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(28.dp)
|
||||
@@ -351,8 +356,8 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (slot.count > 0) {
|
||||
Text(text = slot.count.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
if (streakCount > 0) {
|
||||
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "468"
|
||||
appVersionCode = "486"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.6-ba7opf.10.2"
|
||||
appVersionName = "4.4.7-ba7opf.12"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
@@ -79,6 +79,8 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe
|
||||
|
||||
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
|
||||
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
|
||||
# Real org.json for unit tests (android.jar stub throws "not mocked")
|
||||
test-json = { module = "org.json:json", version = "20240303" }
|
||||
|
||||
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
|
||||
androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }
|
||||
|
||||
Reference in new issue
Block a user