mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ef994e4cd | ||
|
|
4c3fdeaa9c | ||
|
|
33a7f23d7a | ||
|
|
fb4f14f75d | ||
|
|
473c3bf228 | ||
|
|
2110bf0cd6 | ||
|
|
95d2d792cc | ||
|
|
dd29c6681c | ||
|
|
45cd72faf7 | ||
|
|
5acf71b2a6 | ||
|
|
1871c05002 | ||
|
|
e9f58f76f6 | ||
|
|
800494b978 | ||
|
|
f1091acbbd | ||
|
|
99bf3d34a9 | ||
|
|
1dd185971f | ||
|
|
fe85703e56 | ||
|
|
caddba5b81 |
No files matched your search
@@ -4,6 +4,8 @@
|
||||
|
||||
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||
|
||||
非官方修改版(fork),独立维护;首次发布 2026-09,持续修改中。
|
||||
|
||||
## 本仓库特色功能
|
||||
|
||||
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="cn.ba7opf.look4sat" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -27,7 +27,9 @@ import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
@@ -47,9 +49,12 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
|
||||
observeNightFilterState()
|
||||
setContent {
|
||||
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||
val otherSettings by settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||
// Light theme is opt-in: the app stays dark until the user turns it on.
|
||||
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ class MainApplication : Application(), IContainerProvider {
|
||||
container.appScope.launch { checkAutoUpdate() }
|
||||
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||
container.appScope.launch { checkLoTWAutoSync() }
|
||||
// repair logbooks split by the old name/band identity mismatch, independent of
|
||||
// whether a sync is due (cheap read + in-memory match on every start)
|
||||
container.appScope.launch { runCatching { container.qsoRepository.consolidateConfirmations() } }
|
||||
// load satellite data on every app start
|
||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||
}
|
||||
|
||||
@@ -75,11 +75,14 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.GridFinderDestination
|
||||
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWUploadDestination
|
||||
import com.rtbishop.look4sat.core.presentation.RadarDestination
|
||||
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.gridfinder.GridFinderDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||
import com.rtbishop.look4sat.feature.map.MapFilterViewModel
|
||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||
@@ -88,6 +91,7 @@ import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||
import com.rtbishop.look4sat.feature.settings.LoTWUploadDestination
|
||||
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||
|
||||
@Composable
|
||||
@@ -99,6 +103,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
}
|
||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||
val navigateToGridFinder: () -> Unit = { rootBackStack.add(GridFinderDestination) }
|
||||
val navigateToLoTWStation: () -> Unit = { rootBackStack.add(LoTWUploadDestination) }
|
||||
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||
@@ -132,7 +138,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
entryProvider = entryProvider {
|
||||
entry<Screen.Passes> {
|
||||
MainScreen(
|
||||
navigateToRadar = navigateToRadar
|
||||
navigateToRadar = navigateToRadar,
|
||||
navigateToGridFinder = navigateToGridFinder
|
||||
)
|
||||
}
|
||||
entry<RadarDestination> {
|
||||
@@ -143,6 +150,25 @@ fun NavRoot(deeplink: String? = null) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
entry<GridFinderDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
GridFinderDestination(
|
||||
navigateUp = navigateBack,
|
||||
onOpenLoTWStation = navigateToLoTWStation
|
||||
)
|
||||
}
|
||||
}
|
||||
entry<LoTWUploadDestination> {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LoTWUploadDestination(navigateUp = navigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -150,7 +176,8 @@ fun NavRoot(deeplink: String? = null) {
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navigateToRadar: () -> Unit = {}
|
||||
navigateToRadar: () -> Unit = {},
|
||||
navigateToGridFinder: () -> Unit = {}
|
||||
) {
|
||||
val backStack = rememberNavBackStack(Screen.Passes)
|
||||
val currentKey = backStack.lastOrNull()
|
||||
@@ -291,7 +318,7 @@ fun MainScreen(
|
||||
}
|
||||
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||
entry<Screen.Settings> {
|
||||
SettingsDestination()
|
||||
SettingsDestination(onOpenGridFinder = navigateToGridFinder)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
||||
implementation(project(":core:domain"))
|
||||
implementation(project(":core:presentation"))
|
||||
implementation(project(":feature:map"))
|
||||
implementation(project(":feature:gridfinder"))
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:passes"))
|
||||
implementation(project(":feature:radar"))
|
||||
|
||||
+4
-3
@@ -330,12 +330,13 @@ object IcomCivProtocol {
|
||||
|
||||
/**
|
||||
* Parse frequency + mode from a CMD_READ_FREQ reply payload.
|
||||
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte]
|
||||
* Payload layout after stripping command byte: [5 freq bytes]
|
||||
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
|
||||
*/
|
||||
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
|
||||
if (payload.size < 6) return null
|
||||
if (payload.size < 5) return null
|
||||
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
|
||||
val mode = BYTE_TO_MODE[payload[5]] ?: return null
|
||||
val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
|
||||
return freqHz to mode
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.injection
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.room.Room
|
||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||
@@ -43,6 +46,7 @@ import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||
import com.rtbishop.look4sat.core.data.repository.LocationRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
@@ -59,6 +63,7 @@ import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||
@@ -107,13 +112,25 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||
}
|
||||
override val locationRepo: ILocationRepo by lazy {
|
||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
LocationRepo(manager) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
private val _mutualPassData = MutableStateFlow(MutualPassData())
|
||||
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
|
||||
private val _pendingLoTWStationGrid = MutableStateFlow<String?>(null)
|
||||
override val pendingLoTWStationGrid: StateFlow<String?> = _pendingLoTWStationGrid.asStateFlow()
|
||||
|
||||
override fun setMutualPassData(data: MutualPassData) {
|
||||
_mutualPassData.value = data
|
||||
}
|
||||
override fun setPendingLoTWStationGrid(grid: String?) {
|
||||
_pendingLoTWStationGrid.value = grid
|
||||
}
|
||||
|
||||
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
|
||||
@@ -58,18 +77,23 @@ internal class LoTWConfig(input: InputStream) {
|
||||
return value
|
||||
}
|
||||
|
||||
/** Every satellite name ARRL's config.tq6 knows, sorted: the only names LoTW accepts. */
|
||||
fun satelliteNames(): List<String> = satellites.keys.sorted()
|
||||
|
||||
/**
|
||||
* Resolve a tracker name to the ARRL name, or null when it is not an ARRL satellite.
|
||||
* Exact official name first, then the alias table ("SAUDISAT 1C" -> "SO-50",
|
||||
* "ISS (ZARYA)" -> "ARISS"), then an official name contained in the tracker name.
|
||||
*/
|
||||
fun resolveSatellite(name: String): String? = LoTWSatelliteAliases.resolve(name, satellites.keys)
|
||||
|
||||
fun satellite(name: String, date: String): String {
|
||||
val normalized = name.trim().uppercase(Locale.US)
|
||||
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
|
||||
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
|
||||
// first, then accept any satellite whose official name is a substring of the
|
||||
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
|
||||
// contact is unavailable for upload.
|
||||
val sat = satellites[normalized]
|
||||
?: satellites.entries.firstOrNull { (official, _) ->
|
||||
official.length > 1 && normalized.contains(official)
|
||||
}?.value
|
||||
?: fail(LoTWProblem.SATELLITE, normalized)
|
||||
// Tracker names differ from ARRL's: SatNOGS stores "SAUDISAT 1C" for "SO-50" and
|
||||
// "ISS (ZARYA)" for "ARISS". resolveSatellite() checks the exact name, the alias
|
||||
// table and finally substring containment; anything else is not uploadable.
|
||||
val official = resolveSatellite(name) ?: fail(LoTWProblem.SATELLITE, normalized)
|
||||
val sat = satellites.getValue(official)
|
||||
val first = sat.getAttribute("startDate")
|
||||
val last = sat.getAttribute("endDate")
|
||||
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
@@ -24,14 +42,6 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||
fail(LoTWProblem.QSO_DATE, call)
|
||||
}
|
||||
val grids = buildList {
|
||||
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
|
||||
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
|
||||
}.filter(String::isNotBlank)
|
||||
if (record.myGrid.isNotBlank() && grids.none { grid ->
|
||||
val local = record.myGrid.trim().uppercase(Locale.US)
|
||||
grid.startsWith(local) || local.startsWith(grid)
|
||||
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
|
||||
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||
val fields = linkedMapOf(
|
||||
"BAND" to config.band(record.band, record.txFrequencyHz, true),
|
||||
@@ -46,8 +56,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
|
||||
).filterValues { it.isNotBlank() }
|
||||
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
|
||||
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
|
||||
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
|
||||
// deliberately excluded: changing the station location must not change
|
||||
// the fingerprint, otherwise previously-uploaded contacts would lose
|
||||
// their ledger entry and be re-uploaded (and rejected as duplicates).
|
||||
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
|
||||
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
|
||||
fields.entries.joinToString("") { field(it.key, it.value) }
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
return LoTWContact(record, fields, signData, hash)
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.lotw
|
||||
|
||||
import android.content.Context
|
||||
|
||||
+84
-9
@@ -1,7 +1,26 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.lotw
|
||||
|
||||
import android.content.Context
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||
@@ -154,6 +173,24 @@ class LoTWUploadRepository internal constructor(
|
||||
config().stationMeta(dxcc)
|
||||
}
|
||||
|
||||
/** The ARRL satellite catalogue is static for the lifetime of the bundled config.tq6. */
|
||||
private val satelliteNames: List<String> by lazy { config().satelliteNames() }
|
||||
|
||||
override suspend fun satelliteCatalog(): List<String> = withContext(Dispatchers.IO) { satelliteNames }
|
||||
|
||||
/**
|
||||
* What makes two records the same contact for batch de-duplication: the opposite station,
|
||||
* the satellite (resolved to its ARRL identity, so the tracker spelling and the official
|
||||
* name are one) and the minute the QSO started. Frequency and mode are deliberately not
|
||||
* part of the key — the same contact logged twice with slightly different tuning (or with
|
||||
* the frequency fields empty) must still be caught before LoTW rejects the duplicate.
|
||||
*/
|
||||
private fun duplicateKey(record: QsoRecord): String = listOf(
|
||||
record.theirCallsign.trim().uppercase(Locale.US),
|
||||
satelliteIdentity(record.satelliteName),
|
||||
(record.startUtcMillis / 60_000L).toString()
|
||||
).joinToString("|")
|
||||
|
||||
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val signing = signingContext()
|
||||
@@ -163,20 +200,34 @@ class LoTWUploadRepository internal constructor(
|
||||
var uploaded = 0
|
||||
var unknown = 0
|
||||
var unavailable = 0
|
||||
var duplicates = 0
|
||||
var incomplete = 0
|
||||
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||
val details = mutableMapOf<LoTWProblem, String>()
|
||||
records.sortedBy { it.startUtcMillis }.forEach { record ->
|
||||
coroutineContext.ensureActive()
|
||||
when {
|
||||
record.status != QsoStatus.COMPLETE -> unavailable++
|
||||
record.lotwReceived -> uploaded++
|
||||
record.status != QsoStatus.COMPLETE -> {
|
||||
unavailable++
|
||||
incomplete++
|
||||
}
|
||||
|
||||
record.lotwReceived || record.lotwUploaded -> uploaded++
|
||||
else -> {
|
||||
val contact = try {
|
||||
signing.signer.contact(record, signing.key, signing.location, now())
|
||||
} catch (_: LoTWOperationException) {
|
||||
} catch (e: LoTWOperationException) {
|
||||
unavailable++
|
||||
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||
if (e.detail.isNotBlank()) details.putIfAbsent(e.reason, e.detail)
|
||||
null
|
||||
}
|
||||
if (contact != null) when {
|
||||
!unique.add(contact.fingerprint) -> unavailable++
|
||||
!unique.add(duplicateKey(contact.record)) -> {
|
||||
unavailable++
|
||||
duplicates++
|
||||
}
|
||||
|
||||
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
||||
ledger[contact.fingerprint] == "unknown" -> unknown++
|
||||
else -> pendingCount++
|
||||
@@ -184,7 +235,10 @@ class LoTWUploadRepository internal constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
|
||||
LoTWUploadAudit(
|
||||
records.size, pendingCount, uploaded, unknown, unavailable,
|
||||
reasons, details, duplicates, incomplete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +250,31 @@ class LoTWUploadRepository internal constructor(
|
||||
val ledger = ledger()
|
||||
var skipped = 0
|
||||
var unknown = 0
|
||||
var unavailable = 0
|
||||
var duplicates = 0
|
||||
val unique = hashSetOf<String>()
|
||||
val reasons = mutableMapOf<LoTWProblem, Int>()
|
||||
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||
coroutineContext.ensureActive()
|
||||
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
|
||||
val contact = signing.signer.contact(record, signing.key, signing.location, now())
|
||||
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
||||
val contact = try {
|
||||
signing.signer.contact(record, signing.key, signing.location, now())
|
||||
} catch (e: LoTWOperationException) {
|
||||
// One un-signable record must not abort the whole batch:
|
||||
// skip it, count the real reason, and let the rest upload.
|
||||
unavailable++
|
||||
reasons[e.reason] = (reasons[e.reason] ?: 0) + 1
|
||||
return@mapNotNull null
|
||||
}
|
||||
val previous = ledger[contact.fingerprint]
|
||||
when {
|
||||
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
|
||||
(previous == "accepted" && !resubmit) -> { skipped++; null }
|
||||
!unique.add(duplicateKey(contact.record)) -> {
|
||||
skipped++
|
||||
duplicates++
|
||||
null
|
||||
}
|
||||
|
||||
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
|
||||
else -> contact
|
||||
}
|
||||
@@ -214,7 +285,11 @@ class LoTWUploadRepository internal constructor(
|
||||
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
|
||||
unknown
|
||||
unknown,
|
||||
unavailable,
|
||||
reasons.toMap(),
|
||||
duplicates,
|
||||
contacts.map { it.record.id }
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||
|
||||
+2
-2
@@ -322,9 +322,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
line.startsWith("<MODE:") ->
|
||||
mode = adifValue(line)
|
||||
line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandDown = adifValue(line).uppercase()
|
||||
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||
bandUp = adifValue(line).uppercase()
|
||||
line.startsWith("<DXCC:") ->
|
||||
dxcc = adifValue(line).toIntOrNull()
|
||||
line.startsWith("<COUNTRY:") ->
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.data.repository
|
||||
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
private const val UPDATE_INTERVAL_MS = 1_000L
|
||||
private const val UPDATE_MIN_DISTANCE_M = 0f
|
||||
|
||||
/**
|
||||
* Live-location adapter for the field tools. Deliberately independent of
|
||||
* [SettingsRepo]'s station position: a walk around a grid corner must not
|
||||
* rewrite the station that every pass prediction is built on.
|
||||
*
|
||||
* Unlike [SettingsRepo.setStationPosition], this class always calls
|
||||
* [LocationManager.removeUpdates] in [stopUpdates], so the listener is only
|
||||
* alive while a screen is actually collecting fixes.
|
||||
*/
|
||||
class LocationRepo(
|
||||
private val locationManager: LocationManager,
|
||||
private val permissionGranted: () -> Boolean
|
||||
) : ILocationRepo, LocationListener {
|
||||
|
||||
private val _state = MutableStateFlow(initialState())
|
||||
override val state: StateFlow<LocationState> = _state.asStateFlow()
|
||||
|
||||
private var isRegistered = false
|
||||
|
||||
override fun hasPermission(): Boolean = permissionGranted()
|
||||
|
||||
override fun startUpdates() {
|
||||
if (isRegistered) return
|
||||
val request = describeProvider() ?: return
|
||||
if (!request.permissionGranted || !request.providerEnabled) return
|
||||
try {
|
||||
// A last-known fix is better than an empty screen while GPS warms up.
|
||||
locationManager.getLastKnownLocation(request.provider)?.let(::publish)
|
||||
locationManager.requestLocationUpdates(
|
||||
request.provider, UPDATE_INTERVAL_MS, UPDATE_MIN_DISTANCE_M, this
|
||||
)
|
||||
isRegistered = true
|
||||
} catch (exception: SecurityException) {
|
||||
println("LocationRepo: no permission - $exception")
|
||||
} catch (exception: IllegalArgumentException) {
|
||||
println("LocationRepo: provider unavailable - $exception")
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopUpdates() {
|
||||
if (!isRegistered) return
|
||||
isRegistered = false
|
||||
try {
|
||||
locationManager.removeUpdates(this)
|
||||
} catch (exception: SecurityException) {
|
||||
println("LocationRepo: removing updates without permission - $exception")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) = publish(location)
|
||||
|
||||
override fun onProviderDisabled(provider: String) = refreshWaitingState()
|
||||
|
||||
override fun onProviderEnabled(provider: String) = refreshWaitingState()
|
||||
|
||||
private fun publish(location: Location) {
|
||||
_state.value = LocationState.Fix(
|
||||
LocationFix(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
altitudeMeters = location.altitude.takeIf { location.hasAltitude() } ?: 0.0,
|
||||
// Providers that cannot report accuracy use -1; treat that as
|
||||
// useless rather than perfect.
|
||||
accuracyMeters = location.accuracy.takeIf { location.hasAccuracy() } ?: Float.MAX_VALUE,
|
||||
epochMs = location.time.takeIf { it > 0L } ?: System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialState(): LocationState {
|
||||
val request = describeProvider()
|
||||
return LocationState.Waiting(
|
||||
permissionGranted = request?.permissionGranted ?: false,
|
||||
providerEnabled = request?.providerEnabled ?: false
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshWaitingState() {
|
||||
if (_state.value is LocationState.Fix) return
|
||||
_state.value = initialState()
|
||||
}
|
||||
|
||||
private fun describeProvider(): ProviderRequest? {
|
||||
if (!permissionGranted()) {
|
||||
return ProviderRequest(LocationManager.GPS_PROVIDER, false, false)
|
||||
}
|
||||
val enabled = LocationManagerCompat.isLocationEnabled(locationManager)
|
||||
val provider = when {
|
||||
locationManager.allProviders.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
|
||||
locationManager.allProviders.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
|
||||
else -> null
|
||||
} ?: return null
|
||||
return ProviderRequest(provider, true, enabled)
|
||||
}
|
||||
|
||||
private data class ProviderRequest(
|
||||
val provider: String,
|
||||
val permissionGranted: Boolean,
|
||||
val providerEnabled: Boolean
|
||||
)
|
||||
}
|
||||
+157
-17
@@ -16,9 +16,13 @@ import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
|
||||
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
||||
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -40,18 +44,33 @@ class QsoRepository(
|
||||
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
|
||||
importMutex.withLock {
|
||||
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
|
||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||
record.withConfirmation(previous)
|
||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
||||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
||||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
||||
)) false else saved.lotwReceived
|
||||
dao.save(saved.copy(lotwReceived = received).toEntity())
|
||||
// Editing a record that was already uploaded or confirmed must not rewrite it in
|
||||
// place: that contact already exists on LoTW under the old values. The original row
|
||||
// keeps its upload/confirmation state, and the edited content is saved as a NEW
|
||||
// record (without any LoTW state, so it can be uploaded again).
|
||||
if (previous != null && (previous.lotwUploaded || previous.lotwConfirmed) &&
|
||||
!record.sameEditableContentAs(previous)
|
||||
) {
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
lotwQslDate = "", vuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
).toEntity())
|
||||
} else {
|
||||
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||
record.withConfirmation(previous)
|
||||
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
||||
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
||||
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
||||
)) false else saved.lotwReceived
|
||||
dao.save(saved.copy(lotwReceived = received).toEntity())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,14 +105,42 @@ class QsoRepository(
|
||||
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
||||
}
|
||||
|
||||
override suspend fun consolidateConfirmations(): Int = withContext(dispatcher) {
|
||||
importMutex.withLock {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
val consolidation = consolidateSplitRows(working)
|
||||
val renames = rewriteOfficialNames(working)
|
||||
if (consolidation.merged.isEmpty() && consolidation.redundant.isEmpty() && renames.isEmpty()) {
|
||||
return@withLock 0
|
||||
}
|
||||
val toSave = LinkedHashMap(consolidation.merged).apply { putAll(renames) }
|
||||
dao.saveBatch(toSave.values.map(QsoRecord::toEntity))
|
||||
consolidation.redundant.forEach { dao.delete(it.id) }
|
||||
consolidation.redundant.size
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
|
||||
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||
// Consolidate first: confirmations synced before the identity fix sit as their own
|
||||
// rows (the tracker name and a mirrored band direction both failed the comparison),
|
||||
// and left in place they would make the lookup below ambiguous between the operator's
|
||||
// own row and the stale duplicate.
|
||||
val consolidation = consolidateSplitRows(working)
|
||||
// Rows imported under a tracking-source name join the ARRL name here, so what is stored
|
||||
// is the identity both sides of a match resolve to (the lookup below is built on it).
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
|
||||
val changes = linkedMapOf<Int, QsoRecord>()
|
||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = keyIndex.keys.toMutableSet()
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||
putAll(consolidation.merged)
|
||||
putAll(renames)
|
||||
}
|
||||
var imported = 0
|
||||
var updated = 0
|
||||
var updated = consolidation.merged.size
|
||||
var skipped = 0
|
||||
records.forEach { remote ->
|
||||
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
@@ -102,10 +149,27 @@ class QsoRepository(
|
||||
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||
if (match == null) {
|
||||
// A confirmation downloaded by the pre-fix parser sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). It has no
|
||||
// local partner (so nothing folds it) and its dedupe key equals the fresh report,
|
||||
// so it would otherwise be skipped forever, keeping the wrong band on display.
|
||||
// When the bands are exactly mirrored, adopt the report's direction instead.
|
||||
val stale = if (fromLoTW) keyIndex[stableQsoKey(remote)]?.firstOrNull()
|
||||
?.takeIf { index -> working[index].lotwConfirmed &&
|
||||
working[index].band.equals(remote.rxBand, true) &&
|
||||
working[index].rxBand.equals(remote.band, true) } else null
|
||||
if (stale != null) {
|
||||
val fixed = working[stale].copy(band = remote.band, rxBand = remote.rxBand)
|
||||
working[stale] = fixed
|
||||
changes[stale] = fixed
|
||||
updated++
|
||||
return@forEach
|
||||
}
|
||||
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
val added = remote.copy(id = 0L)
|
||||
val added = remote.copy(id = 0L, satelliteName = officialSatelliteName(remote.satelliteName))
|
||||
changes[working.size] = added
|
||||
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||
keyIndex.getOrPut(stableQsoKey(added)) { mutableListOf() }.add(working.size)
|
||||
knownKeys += stableQsoKey(added)
|
||||
working += added
|
||||
imported++
|
||||
@@ -120,8 +184,60 @@ class QsoRepository(
|
||||
}
|
||||
}
|
||||
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
|
||||
consolidation.redundant.forEach { dao.delete(it.id) }
|
||||
return AdifImportResult(imported, skipped, updated)
|
||||
}
|
||||
|
||||
/** Result of folding already-split rows back together. */
|
||||
private data class Consolidation(
|
||||
/** index in the stored list -> the record to save (local row + its confirmation). */
|
||||
val merged: Map<Int, QsoRecord>,
|
||||
/** imported confirmation rows that are now part of a local record. */
|
||||
val redundant: List<QsoRecord>
|
||||
)
|
||||
|
||||
/**
|
||||
* Rewrites the names records were imported under into their ARRL names ("SAUDISAT 1C" ->
|
||||
* "SO-50"), so the logbook, the ADIF export and the signed record all carry one name.
|
||||
* Names the alias table does not know (recycled placeholders, satellites ARRL does not
|
||||
* list) are kept exactly as they are. [working] is updated in place; the rewritten records
|
||||
* are returned for the caller to save.
|
||||
*/
|
||||
private fun rewriteOfficialNames(working: MutableList<QsoRecord>): Map<Int, QsoRecord> {
|
||||
val renamed = mutableMapOf<Int, QsoRecord>()
|
||||
working.indices.forEach { index ->
|
||||
val record = working[index]
|
||||
val official = officialSatelliteName(record.satelliteName)
|
||||
if (official != record.satelliteName) {
|
||||
val updated = record.copy(satelliteName = official)
|
||||
working[index] = updated
|
||||
renamed[index] = updated
|
||||
}
|
||||
}
|
||||
return renamed
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds confirmation rows that were imported as separate QSOs back into the local
|
||||
* record they belong to (see [splitConfirmationPairs]). [working] is updated in
|
||||
* place; the redundant rows are returned for the caller to delete.
|
||||
*/
|
||||
private fun consolidateSplitRows(working: MutableList<QsoRecord>): Consolidation {
|
||||
val merged = mutableMapOf<Int, QsoRecord>()
|
||||
val redundant = mutableListOf<QsoRecord>()
|
||||
// Snapshot the pairs first: the list is rewritten as pairs are applied.
|
||||
splitConfirmationPairs(working.toList()).forEach { pair ->
|
||||
val local = working[pair.localIndex]
|
||||
val confirmation = working[pair.confirmationIndex]
|
||||
val folded = local.withConfirmation(confirmation)
|
||||
if (folded != local) {
|
||||
working[pair.localIndex] = folded
|
||||
merged[pair.localIndex] = folded
|
||||
}
|
||||
redundant += confirmation
|
||||
}
|
||||
return Consolidation(merged, redundant)
|
||||
}
|
||||
}
|
||||
|
||||
private fun QsoEntity.toDomain() = QsoRecord(
|
||||
@@ -202,5 +318,29 @@ internal fun stableQsoKey(record: QsoRecord): String = listOf(
|
||||
record.txFrequencyHz?.toString().orEmpty(),
|
||||
record.mode.trim().uppercase(Locale.US),
|
||||
record.submode.trim().uppercase(Locale.US),
|
||||
record.satelliteName.trim().uppercase(Locale.US)
|
||||
// The identity, not the spelling: a row stored as "SO-50" is the same QSO as one imported
|
||||
// as "SAUDISAT 1C", so re-importing an older export does not duplicate it.
|
||||
satelliteIdentity(record.satelliteName)
|
||||
).joinToString("|")
|
||||
|
||||
/**
|
||||
* Whether two rows carry the same operator-editable content.
|
||||
*
|
||||
* Minutes are the granularity for time: the edit dialog edits whole minutes (and rounds the
|
||||
* stored seconds away when saving), so a seconds-only difference must not count as an edit.
|
||||
* The satellite is compared by its ARRL identity, the mode by its display label — both sides
|
||||
* of the same contact written differently (tracker vs official name, MFSK vs FT4) are the same
|
||||
* content. Used to decide whether saving an already-uploaded/confirmed row should create a new
|
||||
* record or leave the row alone.
|
||||
*/
|
||||
private fun QsoRecord.sameEditableContentAs(other: QsoRecord): Boolean =
|
||||
theirCallsign.trim().equals(other.theirCallsign.trim(), true) &&
|
||||
startUtcMillis / 60_000L == other.startUtcMillis / 60_000L &&
|
||||
txFrequencyHz == other.txFrequencyHz &&
|
||||
rxFrequencyHz == other.rxFrequencyHz &&
|
||||
displayMode == other.displayMode &&
|
||||
satelliteIdentity(satelliteName) == satelliteIdentity(other.satelliteName) &&
|
||||
sentReport.trim() == other.sentReport.trim() &&
|
||||
receivedReport.trim() == other.receivedReport.trim() &&
|
||||
theirGrid.trim().uppercase(Locale.US) == other.theirGrid.trim().uppercase(Locale.US) &&
|
||||
comment == other.comment
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||
|
||||
@@ -29,7 +29,7 @@ class AddToCalendar(private val context: Context) : IAddToCalendar {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
setData(CalendarContract.Events.CONTENT_URI)
|
||||
putExtra(CalendarContract.Events.TITLE, name)
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat-BA7OPF Pass")
|
||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
|
||||
}
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.framework
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.framework
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class IcomCivProtocolTest {
|
||||
|
||||
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_ic705ReadFreqReply() {
|
||||
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
|
||||
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
|
||||
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
|
||||
assertNotNull(response)
|
||||
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_withModeByte() {
|
||||
assertEquals(
|
||||
435611000L to "USB",
|
||||
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseFreqModePayload_tooShort() {
|
||||
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.lotw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -75,4 +77,50 @@ class LoTWConfigTest {
|
||||
assertNull(meta.regionField)
|
||||
assertTrue(meta.countryZones.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tracker catalogue names resolve to the ARRL satellite`() {
|
||||
// The names the TLE sources store vs the single name ARRL knows for that satellite.
|
||||
assertEquals("SO-50", config.resolveSatellite("SAUDISAT 1C"))
|
||||
assertEquals("ARISS", config.resolveSatellite("ISS (ZARYA)"))
|
||||
assertEquals("PO-101", config.resolveSatellite("DIWATA 2B"))
|
||||
assertEquals("AO-91", config.resolveSatellite("FOX-1B"))
|
||||
assertEquals("IO-86", config.resolveSatellite("LAPAN-A2"))
|
||||
assertEquals("RS-44", config.resolveSatellite("DOSAAF-85"))
|
||||
assertEquals("BO-102", config.resolveSatellite("CAS-7B"))
|
||||
assertEquals("SO-50", config.resolveSatellite("SO-50"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite signing accepts tracker names and keeps ARRL spelling`() {
|
||||
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2026-09-27"))
|
||||
assertEquals("ARISS", config.satellite("ISS (ZARYA)", "2026-09-27"))
|
||||
assertEquals("SO-50", config.satellite("SO-50 (SaudiOSCAR 50)", "2026-09-27"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite signing rejects names ARRL does not know`() {
|
||||
// Placeholder designations are deliberately unmapped: signing them would name the
|
||||
// wrong object, so the record must stay un-uploadable.
|
||||
listOf("OBJECT AY", "MARINA", "NOT A SATELLITE").forEach { name ->
|
||||
val error = runCatching { config.satellite(name, "2026-09-27") }.exceptionOrNull()
|
||||
assertTrue("$name should be rejected", error is LoTWOperationException)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `satellite signing enforces the ARRL service dates`() {
|
||||
// SO-50 is listed from 2002-12-20: a QSO before that cannot be signed.
|
||||
val error = runCatching { config.satellite("SAUDISAT 1C", "2002-01-01") }.exceptionOrNull()
|
||||
assertTrue(error is LoTWOperationException)
|
||||
assertEquals("SO-50", config.satellite("SAUDISAT 1C", "2003-01-01"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every alias target is a real ARRL satellite name`() {
|
||||
val catalogue = config.satelliteNames().toSet()
|
||||
assertTrue("ARRL catalogue is unexpectedly small", catalogue.size > 100)
|
||||
val unknown = LoTWSatelliteAliases.table.filterValues { it !in catalogue }
|
||||
assertEquals("alias targets missing from config.tq6: $unknown", emptyMap<String, String>(), unknown)
|
||||
}
|
||||
}
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||
|
||||
+24
-3
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.data.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||
@@ -112,9 +130,12 @@ class LoTWRepositoryTest {
|
||||
assertEquals("A50QO", qso.call)
|
||||
assertEquals("FO-29", qso.satName)
|
||||
assertEquals("CW", qso.mode)
|
||||
assertEquals("2M", qso.bandUp)
|
||||
assertEquals("70CM", qso.bandDown)
|
||||
assertEquals("V/U", qso.bandLabel)
|
||||
// ADIF BAND is the transmitted (uplink) band, BAND_RX the received (downlink) one.
|
||||
// Confirmed against the live report of the own uploads: SO-50 QSOs arrive as
|
||||
// BAND=2M/FREQ=145.85000 + BAND_RX=70CM/FREQ_RX=436.80500, i.e. BAND=uplink.
|
||||
assertEquals("70CM", qso.bandUp)
|
||||
assertEquals("2M", qso.bandDown)
|
||||
assertEquals("U/V", qso.bandLabel)
|
||||
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||
}
|
||||
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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.data.database.QsoDao
|
||||
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The logbook split reported 2026-09-28: a QSO uploaded from the app and the confirmation LoTW
|
||||
* reported for it ended up as two rows, because the record keeps the tracking-source name
|
||||
* ("SAUDISAT 1C") and the uplink band while the report carries the ARRL name ("SO-50") — and,
|
||||
* before the direction fix, the mirrored band pair.
|
||||
*/
|
||||
class QsoRepositoryTest {
|
||||
|
||||
private val dao = FakeQsoDao()
|
||||
private val repository = QsoRepository(dao, Dispatchers.Unconfined)
|
||||
|
||||
/** 2026-09-16 07:45Z, an SO-50 contact of the user's own report. */
|
||||
private val qsoStart = 1_789_544_700_000L
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_confirmsTheUploadedRecordInsteadOfAddingASecondRow() = runBlocking {
|
||||
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity()) // 145.850 -> 2M
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(uploadedId, rows.first().id)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
// The row is also renamed to the ARRL spelling while it is merged.
|
||||
assertEquals("SO-50", rows.first().satelliteName)
|
||||
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_foldsRowsSplitByTheOldNameAndBandMismatch() = runBlocking {
|
||||
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||
// Row imported before the fix: ARRL name and the mirrored band pair (BAND_RX read as
|
||||
// the uplink), so it never folded into the local record.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(uploadedId, rows.first().id)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
// The operator's own frequencies survive the consolidation.
|
||||
assertEquals(145_850_000L, rows.first().txFrequencyHz)
|
||||
assertEquals(436_795_000L, rows.first().rxFrequencyHz)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_keepsAnotherContactOfTheSamePassApart() = runBlocking {
|
||||
dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||
val otherOperator = reportConfirmation(satName = "SO-50").copy(theirCallsign = "BG5JVM")
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50"), otherOperator))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(2, rows.size)
|
||||
// The local row is confirmed by its own report entry; the second operator's contact
|
||||
// is a separate QSO and stays a separate, separately confirmed row.
|
||||
assertEquals(setOf("BG5JSB", "BG5JVM"), rows.map { it.theirCallsign }.toSet())
|
||||
assertTrue(rows.all { it.lotwConfirmed })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consolidateConfirmations_repairsAnAlreadySplitLogbook() = runBlocking {
|
||||
val uploadedId = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||
// Row imported before the fix: ARRL name + mirrored band pair.
|
||||
dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
|
||||
|
||||
assertEquals(1, repository.consolidateConfirmations())
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(uploadedId, rows.first().id)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
// Nothing left to fold on a second pass.
|
||||
assertEquals(0, repository.consolidateConfirmations())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consolidateConfirmations_renamesRowsImportedUnderATrackerName() = runBlocking {
|
||||
// A record logged before names were normalised at write time.
|
||||
val id = dao.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).toEntity())
|
||||
|
||||
repository.consolidateConfirmations()
|
||||
|
||||
val row = dao.getAll().single()
|
||||
assertEquals(id, row.id)
|
||||
assertEquals("SO-50", row.satelliteName)
|
||||
// Upload state and the operator's own frequencies survive the rename.
|
||||
assertTrue(row.lotwUploaded)
|
||||
assertEquals(145_850_000L, row.txFrequencyHz)
|
||||
// A satellite ARRL does not list keeps whatever the tracker published.
|
||||
dao.save(loggedInApp("FOO-1", lotwUploaded = false).toEntity())
|
||||
repository.consolidateConfirmations()
|
||||
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
||||
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
||||
// mirrored (downlink stored as the uplink), and no local row to fold it into, so it
|
||||
// sits as its own QSL row.
|
||||
val staleId = dao.save(reportConfirmation(satName = "SO-50").copy(band = "70CM", rxBand = "2M").toEntity())
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(staleId, rows.first().id)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
// The stale row adopts the report's (uplink-first) direction instead of being skipped
|
||||
// forever by the dedupe key.
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAnUploadedRecordCreatesANewRecord() = runBlocking {
|
||||
val originalId = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||
val edited = loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = originalId, theirCallsign = "BG5JVM")
|
||||
|
||||
val newId = repository.save(edited)
|
||||
|
||||
assertNotEquals(originalId, newId)
|
||||
val rows = dao.getAll()
|
||||
assertEquals(2, rows.size)
|
||||
// The original keeps its identity and upload state, untouched.
|
||||
val original = rows.first { it.id == originalId }
|
||||
assertEquals("BG5JSB", original.theirCallsign)
|
||||
assertTrue(original.lotwUploaded)
|
||||
// The edited content is a fresh, uploadable record.
|
||||
val created = rows.first { it.id == newId }
|
||||
assertEquals("BG5JVM", created.theirCallsign)
|
||||
assertFalse(created.lotwUploaded)
|
||||
assertFalse(created.lotwConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAnUnuploadedRecordUpdatesInPlace() = runBlocking {
|
||||
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false))
|
||||
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = false).copy(id = id, theirCallsign = "BG5JVM"))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("BG5JVM", rows.first().theirCallsign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAnUploadedRecordWithoutChangesKeepsOneRow() = runBlocking {
|
||||
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id))
|
||||
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_editingAnUploadedRecordWithOnlySecondsChangedKeepsOneRow() = runBlocking {
|
||||
val id = repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true))
|
||||
// The edit dialog edits whole minutes and rounds the seconds away on save, so a
|
||||
// seconds-only difference must not be treated as an edit (no new record).
|
||||
repository.save(loggedInApp("SAUDISAT 1C", lotwUploaded = true).copy(id = id, startUtcMillis = qsoStart + 10_000L))
|
||||
|
||||
assertEquals(1, dao.getAll().size)
|
||||
}
|
||||
|
||||
/** A record as the log page creates it: tracker name, repeater pair, uplink band. */
|
||||
private fun loggedInApp(satelliteName: String, lotwUploaded: Boolean) = QsoRecord(
|
||||
startUtcMillis = qsoStart,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = 145_850_000L,
|
||||
rxFrequencyHz = 436_795_000L,
|
||||
band = "2M",
|
||||
rxBand = "70CM",
|
||||
mode = "FM",
|
||||
satelliteName = satelliteName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwUploaded = lotwUploaded
|
||||
)
|
||||
|
||||
/** A confirmation as the report parser + [toConfirmedRecord] build it (BAND carries the uplink). */
|
||||
private fun reportConfirmation(satName: String) = GridQso(
|
||||
call = "BG5JSB",
|
||||
epochMs = qsoStart,
|
||||
satName = satName,
|
||||
mode = "FM",
|
||||
bandUp = "2M",
|
||||
bandDown = "70CM",
|
||||
dxcc = 318,
|
||||
country = "China",
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OL62",
|
||||
myGrids = setOf("OL62", "OL63")
|
||||
).toConfirmedRecord("BA7OPF")
|
||||
|
||||
private fun QsoRecord.toEntity() = QsoEntity(
|
||||
id = id,
|
||||
startUtcMillis = startUtcMillis,
|
||||
endUtcMillis = endUtcMillis,
|
||||
theirCallsign = theirCallsign,
|
||||
myCallsign = myCallsign,
|
||||
theirGrid = theirGrid,
|
||||
myGrid = myGrid,
|
||||
sentReport = sentReport,
|
||||
receivedReport = receivedReport,
|
||||
txFrequencyHz = txFrequencyHz,
|
||||
rxFrequencyHz = rxFrequencyHz,
|
||||
band = band,
|
||||
rxBand = rxBand,
|
||||
mode = mode,
|
||||
submode = submode,
|
||||
satelliteName = satelliteName,
|
||||
transponderName = transponderName,
|
||||
satelliteMode = satelliteMode,
|
||||
passAosUtcMillis = passAosUtcMillis,
|
||||
automatic = automatic,
|
||||
status = status.name,
|
||||
dedupeKey = listOf(startUtcMillis, theirCallsign, myCallsign, txFrequencyHz, mode, submode, satelliteName)
|
||||
.joinToString("|"),
|
||||
propagationMode = propagationMode,
|
||||
lotwConfirmed = lotwConfirmed,
|
||||
lotwUploaded = lotwUploaded,
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
region = region,
|
||||
comment = comment
|
||||
)
|
||||
|
||||
private class FakeQsoDao : QsoDao {
|
||||
private val rows = linkedMapOf<Long, QsoEntity>()
|
||||
private var nextId = 1L
|
||||
|
||||
override fun observeAll(): Flow<List<QsoEntity>> = flowOf(rows.values.toList())
|
||||
|
||||
override suspend fun find(id: Long): QsoEntity? = rows[id]
|
||||
|
||||
override suspend fun getAll(): List<QsoEntity> = rows.values.toList()
|
||||
|
||||
override suspend fun getDedupeKeys(): List<String> = rows.values.map { it.dedupeKey }
|
||||
|
||||
override suspend fun save(record: QsoEntity): Long {
|
||||
val id = if (record.id == 0L) nextId++ else record.id
|
||||
rows[id] = record.copy(id = id)
|
||||
return id
|
||||
}
|
||||
|
||||
override suspend fun importRecords(records: List<QsoEntity>): List<Long> = records.map { save(it) }
|
||||
|
||||
override suspend fun delete(id: Long) {
|
||||
rows.remove(id)
|
||||
}
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) {
|
||||
ids.forEach { id -> rows[id]?.let { rows[id] = it.copy(lotwUploaded = true) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,12 @@ interface IQsoRepository {
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
/**
|
||||
* Folds LoTW confirmations that were imported as their own rows back into the local
|
||||
* record they belong to (see [com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs]).
|
||||
* Confirmations merged before the tracker-name/band identity fix split every contact in
|
||||
* two; this repairs the existing logbook. Returns the number of merged rows.
|
||||
*/
|
||||
suspend fun consolidateConfirmations(): Int
|
||||
}
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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.logbook
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Satellite names of the ARRL LoTW catalogue and the tracker names that map onto them.
|
||||
*
|
||||
* ARRL's config.tq6 knows every satellite by exactly ONE name ("SO-50", "ARISS", "PO-101",
|
||||
* "BO-102"), while the TLE sources the tracker uses carry their own catalogue names: SatNOGS
|
||||
* lists SO-50 as "SAUDISAT 1C", the ISS as "ISS (ZARYA)", PO-101 as "DIWATA 2B" and AO-123 as
|
||||
* "OBJECT AY". Without this mapping a signed QSO on those satellites is rejected as an unknown
|
||||
* satellite.
|
||||
*
|
||||
* Keys are tracker names normalised by [normalize] (upper case, A-Z0-9 only); values are the
|
||||
* exact config.tq6 satellite names. Generated 2026-09-27 from:
|
||||
* - SatNOGS DB satellites (`name` + `names` alias list) joined to config.tq6 by exact alias,
|
||||
* - live SatNOGS 3LE names and Celestrak amateur names joined to the same NORAD id,
|
||||
* - designator tokens of the config.tq6 satellite descriptions (e.g. CAS-7B -> BO-102),
|
||||
* - the ISS family, which SatNOGS does not list under its ARRL name "ARISS".
|
||||
* Placeholder names ("OBJECT AY" and friends) are deliberately NOT mapped: they are recycled
|
||||
* between objects and would sign the wrong satellite. LoTWConfigTest asserts that every target
|
||||
* here is a real config.tq6 name.
|
||||
*/
|
||||
object LoTWSatelliteAliases {
|
||||
|
||||
val table: Map<String, String> = mapOf(
|
||||
"AMSATOSCAR7" to "AO-7", "AO123" to "AO-123", "AO16" to "AO-16",
|
||||
"AO27" to "AO-27", "AO7" to "AO-7", "AO73" to "AO-73",
|
||||
"AO85" to "AO-85", "AO91" to "AO-91", "AO92" to "AO-92",
|
||||
"ARISSAT1" to "KEDR", "ASRTU1" to "AO-123", "ASRTUFRIENDSHIP" to "AO-123",
|
||||
"BJ2CR" to "AO-123", "BREEZEKMRB" to "RS-44", "BRICSAT2" to "NO-103",
|
||||
"BRICSATP" to "NO-83", "BY701" to "BY70-1", "CAMSAT" to "FO-118",
|
||||
"CAS10" to "HO-119", "CAS2F" to "XW-2F", "CAS2T" to "CAS-2T",
|
||||
"CAS3A" to "XW-2A", "CAS3B" to "XW-2B", "CAS3C" to "XW-2C",
|
||||
"CAS3CBJ1SD" to "XW-2C", "CAS3D" to "XW-2D", "CAS3E" to "XW-2E",
|
||||
"CAS3F" to "XW-2F", "CAS4A" to "CAS-4A", "CAS4B" to "CAS-4B",
|
||||
"CAS5A" to "FO-118", "CAS6" to "TO-108", "CAS7B" to "BO-102",
|
||||
"CAS9" to "HO-113", "CZ11RB" to "CAS-2T", "DELFIC3" to "DO-64",
|
||||
"DIWATA2" to "PO-101", "DIWATA2B" to "PO-101", "DO64" to "DO-64",
|
||||
"DOSAAF85" to "RS-44", "DRUZHBAATURK" to "AO-123", "EO79" to "EO-79",
|
||||
"EO88" to "EO-88", "ESHAIL2" to "QO-100", "EUROPEANOSCAR79" to "EO-79",
|
||||
"EYESAT1" to "AO-27", "EYESATA" to "AO-27", "FENGTAIOSCAR118" to "FO-118",
|
||||
"FO118" to "FO-118", "FO29" to "FO-29", "FOX1" to "AO-85",
|
||||
"FOX1A" to "AO-85", "FOX1B" to "AO-91", "FOX1D" to "AO-92",
|
||||
"FRESCO" to "LO-87", "FUNCUBE1" to "AO-73", "FUNCUBE2" to "UKUBE1",
|
||||
"FUNCUBE3" to "EO-79", "FUNCUBE5" to "EO-88", "GREENCUBE" to "IO-117",
|
||||
"HADESD" to "SO-121", "HADESICM" to "SO-125", "HADESR" to "SO-124",
|
||||
"HAIL2" to "QO-100", "HAMSAT" to "VO-52", "HO113" to "HO-113",
|
||||
"HO119" to "HO-119", "HOPE4" to "HO-119", "HYDRA1" to "SO-121",
|
||||
"INSPIRESAT7" to "INSPR7", "IO117" to "IO-117", "IO86" to "IO-86",
|
||||
"ISS" to "ARISS", "ISSZARYA" to "ARISS", "JAS2" to "FO-29",
|
||||
"JINNIUZUO1" to "TAURUS", "JO97" to "JO-97", "JORDANOSCAR97" to "JO-97",
|
||||
"JY1SAT" to "JO-97", "LAPANA2" to "IO-86", "LILACSAT1" to "LO-90",
|
||||
"LILACSAT2" to "CAS-3H", "LITUANICASAT1" to "LO-78", "LO19" to "LO-19",
|
||||
"LO87" to "LO-87", "LUSAT" to "LO-19", "LUSEX" to "LO-87",
|
||||
"MAYA3" to "MAYA-3", "MAYA4" to "MAYA-4", "MESAT1" to "MO-122",
|
||||
"MESAT1OSCAR" to "MO-122", "MESAT1OSCAR122" to "MO-122", "MIRSAT1" to "MO-112",
|
||||
"MO122" to "MO-122", "MTCUBE2" to "IO-117", "NA1SS" to "ARISS",
|
||||
"NAYIF1" to "EO-88", "NEWSAT1" to "LO-87", "NO103" to "NO-103",
|
||||
"NO104" to "NO-104", "NO44" to "NO-44", "NO83" to "NO-83",
|
||||
"NO84" to "NO-84", "NUSAT1" to "LO-87", "ORARI" to "IO-86",
|
||||
"OSCAR16PACSAT" to "AO-16", "OSCAR19LUSAT" to "LO-19", "OSCAR64" to "DO-64",
|
||||
"OSCAR7" to "AO-7", "PACSAT" to "AO-16", "PARKINSONSAT" to "NO-84",
|
||||
"PCSAT" to "NO-44", "PHILLIPINESOSCAR101" to "PO-101", "PO101" to "PO-101",
|
||||
"PSAT" to "NO-84", "PSAT2" to "NO-104", "QB50P1" to "EO-79",
|
||||
"QO100" to "QO-100", "RADFXSAT" to "AO-91", "RADIOROSTO" to "RS-15",
|
||||
"RS0ISS" to "ARISS", "RS14" to "AO-21", "RS15" to "RS-15",
|
||||
"RS44" to "RS-44", "RS64S" to "AO-123", "SAUDISAT1C" to "SO-50",
|
||||
"SO121" to "SO-121", "SO124" to "SO-124", "SO125" to "SO-125",
|
||||
"SO50" to "SO-50", "SOLUTUSNANOSATTELITE" to "SONATE", "SONATE2" to "SONATE",
|
||||
"TAURUS1" to "TAURUS", "TEVEL21" to "TEV2-1", "TEVEL22" to "TEV2-2",
|
||||
"TEVEL23" to "TEV2-3", "TEVEL24" to "TEV2-4", "TEVEL25" to "TEV2-5",
|
||||
"TEVEL26" to "TEV2-6", "TEVEL27" to "TEV2-7", "TEVEL28" to "TEV2-8",
|
||||
"TEVEL29" to "TEV2-9", "USAT1" to "LO-87", "USNAP1" to "NO-103",
|
||||
"VO52" to "VO-52", "W3ADO" to "NO-44", "XW2A" to "XW-2A",
|
||||
"XW2B" to "XW-2B", "XW2C" to "XW-2C", "XW2D" to "XW-2D",
|
||||
"XW2E" to "XW-2E", "XW2F" to "XW-2F", "XW3" to "HO-113",
|
||||
"XW3CAS9" to "HO-113", "XW4" to "HO-119", "YB0X" to "IO-86",
|
||||
"ZARYA" to "ARISS",
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* Tracker name -> ARRL name. An exact table hit wins; otherwise the longest alias key
|
||||
* contained in the name matches, so a name carrying an extra suffix ("SAUDISAT 1C
|
||||
* (SO-50)") still resolves. Keys shorter than 5 characters never match loosely.
|
||||
*/
|
||||
fun lookup(name: String): String? {
|
||||
val normalized = normalize(name)
|
||||
table[normalized]?.let { return it }
|
||||
return table.entries
|
||||
.filter { it.key.length >= 5 && normalized.contains(it.key) }
|
||||
.maxByOrNull { it.key.length }
|
||||
?.value
|
||||
}
|
||||
|
||||
/** Upper case, everything that is not A-Z0-9 removed ("ISS (ZARYA)" -> "ISSZARYA"). */
|
||||
fun normalize(name: String): String = name.uppercase(Locale.US).filter { it.isLetterOrDigit() }
|
||||
|
||||
/**
|
||||
* Resolve any tracker name against the ARRL catalogue: exact official name, then an alias
|
||||
* from [table], then an official name contained in the tracker name. Returns the official
|
||||
* name, or null when the tracker name is not an ARRL satellite at all.
|
||||
*/
|
||||
fun resolve(name: String, officialNames: Collection<String>): String? {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = trimmed.uppercase(Locale.US)
|
||||
officialNames.firstOrNull { it.equals(trimmed, true) }?.let { return it }
|
||||
lookup(trimmed)?.let { alias -> officialNames.firstOrNull { it.equals(alias, true) }?.let { return it } }
|
||||
return officialNames.firstOrNull { it.length > 1 && normalized.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue candidates for a partly typed name. Alias keys are matched too, so the
|
||||
* operator's own spelling ("diwata", "saudisat 1c", "ao9") reaches the ARRL name even
|
||||
* when the two share no characters.
|
||||
*/
|
||||
fun suggestions(query: String, officialNames: Collection<String>, limit: Int = 6): List<String> {
|
||||
val normalized = normalize(query)
|
||||
if (normalized.isEmpty()) return emptyList()
|
||||
val fromAliases = table.entries
|
||||
.filter { it.key.contains(normalized) || normalized.contains(it.key) }
|
||||
.map { it.value }
|
||||
val fromNames = officialNames.filter { normalize(it).contains(normalized) }
|
||||
return (fromAliases + fromNames + listOfNotNull(lookup(query)))
|
||||
.distinct()
|
||||
.filter { candidate -> officialNames.any { it.equals(candidate, true) } }
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.logbook
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||
|
||||
/**
|
||||
* Wording for the reasons a QSO cannot be signed for LoTW.
|
||||
*
|
||||
* The logbook used to report every one of these as "invalid call/date", which sent the operator
|
||||
* looking at the callsign and date even when the real cause was a missing frequency (no BAND) or
|
||||
* a satellite name ARRL does not know.
|
||||
*/
|
||||
fun LoTWProblem.label(): String = when (this) {
|
||||
LoTWProblem.BAND -> "missing frequency/band"
|
||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||
LoTWProblem.STATION_REGION -> "station region missing"
|
||||
LoTWProblem.STATION_ZONE -> "station zones missing"
|
||||
LoTWProblem.STATION_IOTA -> "station IOTA missing"
|
||||
LoTWProblem.TOO_MANY_CONTACTS -> "too many contacts in one batch"
|
||||
LoTWProblem.EMPTY_SELECTION -> "nothing selected"
|
||||
else -> name.lowercase().replace('_', ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line explanation of why records were left out of an upload, e.g.
|
||||
* "3 QSO(s) can't be uploaded — 2× missing frequency/band, 1× satellite name not in ARRL's list (SAUDISAT 1C)".
|
||||
* Returns an empty string when nothing was skipped.
|
||||
*/
|
||||
fun unavailableUploadSummary(
|
||||
total: Int,
|
||||
reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
details: Map<LoTWProblem, String> = emptyMap(),
|
||||
duplicates: Int = 0,
|
||||
incomplete: Int = 0
|
||||
): String {
|
||||
if (total <= 0) return ""
|
||||
val parts = reasons.entries
|
||||
.sortedByDescending { it.value }
|
||||
.map { (problem, count) ->
|
||||
val detail = details[problem]?.takeIf { it.isNotBlank() }?.let { " ($it)" }.orEmpty()
|
||||
"$count× ${problem.label()}$detail"
|
||||
}
|
||||
.toMutableList()
|
||||
if (duplicates > 0) parts += "$duplicates× duplicate of another record in the batch"
|
||||
if (incomplete > 0) parts += "$incomplete× record not completed"
|
||||
val subject = if (total == 1) "1 QSO can't be uploaded" else "$total QSO(s) can't be uploaded"
|
||||
val detail = parts.joinToString(", ").ifBlank { "check the logbook" }
|
||||
return "$subject — $detail"
|
||||
}
|
||||
+118
-10
@@ -1,26 +1,134 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.logbook
|
||||
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
|
||||
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||
/** LoTW official name vs tracking-source name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||
* parenthetical-free prefix. Kept as the last-resort identity for names the alias
|
||||
* table does not know (see [satelliteIdentity]). */
|
||||
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||
|
||||
/**
|
||||
* The satellite identity both sides of a match resolve to.
|
||||
*
|
||||
* A local record keeps whatever the tracking source published ("SAUDISAT 1C",
|
||||
* "ISS (ZARYA)", "DIWATA 2B", "JAS 2") while the LoTW report always carries the ARRL
|
||||
* name ("SO-50", "ARISS", "PO-101", "FO-29"). Comparing the raw names made the two
|
||||
* sides of the SAME contact unequal, so a downloaded confirmation was stored as a
|
||||
* second, separate QSO instead of confirming the uploaded one.
|
||||
*
|
||||
* Both sides are therefore resolved through [LoTWSatelliteAliases], which maps the
|
||||
* tracker names onto the ARRL names and every ARRL name onto itself. Names the table
|
||||
* does not know (e.g. the recycled "OBJECT xx" placeholders) keep falling back to the
|
||||
* parenthetical-free prefix, i.e. they simply never match anything else.
|
||||
*/
|
||||
fun satelliteIdentity(name: String): String {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
val prefix = satMatchKey(trimmed)
|
||||
return LoTWSatelliteAliases.lookup(trimmed)
|
||||
?: LoTWSatelliteAliases.lookup(prefix)
|
||||
?: prefix
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a QSO is stored under: the ARRL name whenever the tracking source's name maps onto
|
||||
* one, otherwise the name as published.
|
||||
*
|
||||
* Records used to keep the tracker's spelling ("SAUDISAT 1C", "ISS (ZARYA)", "OBJECT AY") while
|
||||
* every signed ADIF carries the ARRL name, so the logbook showed a different satellite than the
|
||||
* one LoTW knows and only the upload path resolved the two. Storing the ARRL name keeps one name
|
||||
* for the logbook, the ADIF export and the signature. Names the alias table does not know are
|
||||
* left exactly as they are: satellites ARRL does not list have no official name to store, and the
|
||||
* recycled "OBJECT xx" placeholders must never be mapped onto a real satellite.
|
||||
*/
|
||||
fun officialSatelliteName(name: String, officialNames: Collection<String> = emptyList()): String {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
LoTWSatelliteAliases.resolve(trimmed, officialNames)?.let { return it }
|
||||
val prefix = satMatchKey(trimmed)
|
||||
return LoTWSatelliteAliases.lookup(trimmed) ?: LoTWSatelliteAliases.lookup(prefix) ?: trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything about a contact except its bands: the same opposite station, the same
|
||||
* satellite, the same mode and less than a minute apart. LoTW reports the minute, so
|
||||
* the time window is what makes a match unambiguous.
|
||||
*/
|
||||
fun sameContactIdentity(local: QsoRecord, remote: QsoRecord): Boolean {
|
||||
val localSat = satelliteIdentity(local.satelliteName)
|
||||
return localSat.isNotBlank() &&
|
||||
localSat == satelliteIdentity(remote.satelliteName) &&
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
}
|
||||
|
||||
/** Bands agree when both sides name one and they are the same; a missing band is not a mismatch. */
|
||||
private fun sameBandOrBlank(one: String, other: String): Boolean =
|
||||
one.isBlank() || other.isBlank() || one.equals(other, true)
|
||||
|
||||
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||
data class SplitConfirmationPair(val localIndex: Int, val confirmationIndex: Int)
|
||||
|
||||
/**
|
||||
* Rows that a confirmation should have been folded into but was not, so they can be
|
||||
* consolidated back into one.
|
||||
*
|
||||
* Confirmations merged before the identity fix landed as their own rows: the tracker
|
||||
* name vs ARRL name comparison failed, and the band direction of the report was read
|
||||
* mirrored (BAND_RX was taken as the uplink), so even satellites with matching names
|
||||
* were stored twice. The pair is only accepted when the contact identity matches and
|
||||
* the bands are either identical or exactly mirrored — the signature of that second
|
||||
* mismatch — which keeps unrelated QSOs of the same operator and minute apart.
|
||||
*/
|
||||
fun splitConfirmationPairs(records: List<QsoRecord>): List<SplitConfirmationPair> {
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
val taken = mutableSetOf<Int>()
|
||||
return records.indices.filter { records[it].lotwConfirmed }.mapNotNull { confirmationIndex ->
|
||||
val confirmation = records[confirmationIndex]
|
||||
val localIndex = pendingIndices.firstOrNull { index ->
|
||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||
bandsMatchOrMirror(records[index], confirmation)
|
||||
} ?: return@mapNotNull null
|
||||
taken += localIndex
|
||||
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
(sameBandOrBlank(local.band, confirmation.band) && sameBandOrBlank(local.rxBand, confirmation.rxBand)) ||
|
||||
(sameBandOrBlank(local.band, confirmation.rxBand) && sameBandOrBlank(local.rxBand, confirmation.band))
|
||||
|
||||
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.model
|
||||
|
||||
/** One public AMSAT satellite status report submission. */
|
||||
|
||||
@@ -26,9 +26,12 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
* @param epochMs QSO date+time in UTC milliseconds
|
||||
* @param satName satellite name (e.g. "FO-29")
|
||||
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
* @param bandUp uplink band, the band of the transmitted frequency (ADIF BAND:
|
||||
* "2M", "70CM", "10M"...). Measured against the live report of the
|
||||
* user's own uploads: SO-50 arrives as BAND=2M/FREQ=145.85 with
|
||||
* BAND_RX=70CM/FREQ_RX=436.8, i.e. BAND carries the uplink.
|
||||
* @param bandDown downlink band (ADIF BAND_RX) — may be empty when LoTW did not
|
||||
* include it
|
||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.model
|
||||
|
||||
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.model
|
||||
|
||||
/** One satellite status report (AMSAT site tooltip data) */
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.AmSatReportSubmission
|
||||
|
||||
+41
-3
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.logbook.QsoRecord
|
||||
@@ -11,6 +29,9 @@ interface ILoTWUploadRepository {
|
||||
suspend fun removeCertificate()
|
||||
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
|
||||
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
|
||||
/** Every satellite name ARRL accepts (config.tq6), sorted. Only these names may be
|
||||
* signed, so the logbook UI validates/picks satellite names from this list. */
|
||||
suspend fun satelliteCatalog(): List<String>
|
||||
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||
suspend fun upload(previewId: String): LoTWUploadResult
|
||||
@@ -66,16 +87,33 @@ data class LoTWUploadPreview(
|
||||
val firstUtc: String,
|
||||
val lastUtc: String,
|
||||
val contacts: List<String>,
|
||||
val unknownSkipped: Int = 0
|
||||
val unknownSkipped: Int = 0,
|
||||
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
|
||||
val unavailableSkipped: Int = 0,
|
||||
/** Why those records were un-signable, so the operator sees the real cause. */
|
||||
val unavailableReasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
/** Records dropped because the same contact already appears earlier in the batch. */
|
||||
val duplicateSkipped: Int = 0,
|
||||
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
||||
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
|
||||
val submittedIds: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||
data class LoTWUploadAudit(
|
||||
val total: Int,
|
||||
val pending: Int,
|
||||
val uploaded: Int,
|
||||
val unknown: Int,
|
||||
val unavailable: Int
|
||||
val unavailable: Int,
|
||||
/** Signing failures counted per reason (see [com.rtbishop.look4sat.core.domain.logbook.label]). */
|
||||
val reasons: Map<LoTWProblem, Int> = emptyMap(),
|
||||
/** First offending value per reason, e.g. SATELLITE -> "SAUDISAT 1C". */
|
||||
val details: Map<LoTWProblem, String> = emptyMap(),
|
||||
/** Records dropped as duplicates of an earlier record in the same selection. */
|
||||
val duplicates: Int = 0,
|
||||
/** Records that are not marked complete, so they are not uploadable at all. */
|
||||
val incomplete: Int = 0
|
||||
)
|
||||
|
||||
sealed interface LoTWUploadResult {
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A single position fix from the device's location provider, with the
|
||||
* horizontal accuracy reported by the platform.
|
||||
*/
|
||||
data class LocationFix(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val altitudeMeters: Double = 0.0,
|
||||
val accuracyMeters: Float,
|
||||
val epochMs: Long
|
||||
) {
|
||||
/** ARRL VUCC work needs a fix good to the 20 ft (6.1 m) rule. */
|
||||
fun isPreciseEnough(limitMeters: Float): Boolean = accuracyMeters in 0f..limitMeters
|
||||
}
|
||||
|
||||
/** What the live-location source is currently able to give. */
|
||||
sealed interface LocationState {
|
||||
/** No fix yet - either permission is missing or the provider is warming up. */
|
||||
data class Waiting(val permissionGranted: Boolean, val providerEnabled: Boolean) : LocationState
|
||||
|
||||
data class Fix(val location: LocationFix) : LocationState
|
||||
}
|
||||
|
||||
/**
|
||||
* Live device position for field tools (currently the Grid Finder). Kept
|
||||
* separate from [ISettingsRepo.stationPosition] on purpose: collecting fixes
|
||||
* here must never move the user's station, which is what drives every pass
|
||||
* prediction in the app.
|
||||
*
|
||||
* Callers register with [startUpdates] while their screen is alive and MUST
|
||||
* call [stopUpdates] when it goes away.
|
||||
*/
|
||||
interface ILocationRepo {
|
||||
val state: StateFlow<LocationState>
|
||||
|
||||
/** Whether the app currently holds the fine-location permission. */
|
||||
fun hasPermission(): Boolean
|
||||
|
||||
/** Begins delivering fixes; no-op when permission is missing. */
|
||||
fun startUpdates()
|
||||
|
||||
/** Stops delivering fixes and releases the platform listener. */
|
||||
fun stopUpdates()
|
||||
}
|
||||
+4
@@ -38,8 +38,12 @@ interface IMainContainer {
|
||||
val qsoRepository: IQsoRepository
|
||||
val lotwUploadRepository: ILoTWUploadRepository
|
||||
val radioTrackingService: IRadioTrackingService
|
||||
val locationRepo: ILocationRepo
|
||||
val mutualPassData: StateFlow<MutualPassData>
|
||||
/** Grid Finder → LoTW station page prefill, consumed once by that page. */
|
||||
val pendingLoTWStationGrid: StateFlow<String?>
|
||||
fun setMutualPassData(data: MutualPassData)
|
||||
fun setPendingLoTWStationGrid(grid: String?)
|
||||
fun provideAddToCalendar(): IAddToCalendar
|
||||
fun provideShowToast(): IShowToast
|
||||
fun provideBluetoothReporter(): IReporter
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.LatestRelease
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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.utility
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.round
|
||||
|
||||
/**
|
||||
* Grid-line / grid-corner geometry for the 4-character Maidenhead squares that
|
||||
* VUCC credits, used by the Grid Finder field tool.
|
||||
*
|
||||
* A 4-character square is 1 degree tall (boundaries at integer latitudes) and
|
||||
* 2 degrees wide (boundaries at even offsets from the -180 degree antimeridian),
|
||||
* so a station's distance to the nearest VUCC line and corner is a matter of
|
||||
* simple planar geometry at the station's latitude.
|
||||
*
|
||||
* Geometry ported from OrbitDeck for iOS (MIT License, (c) 2026 Paul Stoetzer,
|
||||
* N8HM) - `GridGeometry` in `Views/HomeView.swift` and `vuccGrids` in
|
||||
* `Engine/FeatureEngine.swift`. Golden values are pinned by GridGeometryTest.
|
||||
*/
|
||||
|
||||
/** ARRL VUCC "20 ft rule": a fix within this distance of a boundary counts as on it. */
|
||||
const val VUCC_BOUNDARY_TOLERANCE_METERS = 20.0 * 0.3048
|
||||
|
||||
/** One degree of latitude in metres; 4-character squares are 1 degree tall. */
|
||||
private const val METERS_PER_DEGREE_LATITUDE = 111_320.0
|
||||
|
||||
/** Nudge used to name the square on the far side of a boundary (display only). */
|
||||
private const val NEIGHBOUR_NUDGE_DEGREES = 0.0005
|
||||
|
||||
/** Nudge used by [vuccClaimableGrids] so the neighbour lands squarely across. */
|
||||
private const val CLAIM_NEIGHBOUR_NUDGE_DEGREES = 0.001
|
||||
|
||||
private const val DEG_TO_RAD = 0.017453292519943295
|
||||
private const val RAD_TO_DEG = 57.29577951308232
|
||||
|
||||
/** Where the nearest 4-character boundary lies relative to the fix. */
|
||||
enum class GridBoundaryDirection { NORTH, SOUTH, EAST, WEST }
|
||||
|
||||
/** ARRL VUCC standing of a fix, derived from how many squares it may claim. */
|
||||
enum class GridFixStatus { INSIDE_GRID, ON_GRID_LINE, ON_GRID_CORNER }
|
||||
|
||||
/**
|
||||
* Distance and bearing to the nearest 4-character grid corner and grid line,
|
||||
* plus the locators of the squares that meet there. All distances are metres
|
||||
* computed on a flat approximation local to the fix (the same approximation
|
||||
* the source implementation uses; over the <=100 km distances involved the
|
||||
* error is far below a GPS fix's own accuracy).
|
||||
*/
|
||||
data class GridGeometry(
|
||||
val grid4: String,
|
||||
val grid6: String,
|
||||
val grid8: String,
|
||||
val latLineMeters: Double,
|
||||
val latLineDirection: GridBoundaryDirection,
|
||||
val lonLineMeters: Double,
|
||||
val lonLineDirection: GridBoundaryDirection,
|
||||
val cornerMeters: Double,
|
||||
val cornerBearingDegrees: Double,
|
||||
val cornerGrids: List<String>,
|
||||
val nearestLineMeters: Double,
|
||||
val nearestLineBearingDegrees: Double,
|
||||
val nearestLineIsLatitude: Boolean,
|
||||
val nearestLineGrids: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* The geometry of the VUCC (4-character) grid around a position, or null when
|
||||
* the position is out of range.
|
||||
*/
|
||||
fun gridGeometry(latitude: Double, longitude: Double): GridGeometry? {
|
||||
val grid4 = positionToGrid4(latitude, longitude) ?: return null
|
||||
val grid6 = positionToQth(latitude, longitude) ?: return null
|
||||
val grid8 = positionToGrid8(latitude, longitude) ?: return null
|
||||
|
||||
// 4-character boundaries: integer latitudes, even-degree longitudes.
|
||||
val latBoundary = round(latitude)
|
||||
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
|
||||
|
||||
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
|
||||
val dNorth = (latBoundary - latitude) * METERS_PER_DEGREE_LATITUDE
|
||||
val dEast = (lonBoundary - longitude) * metersPerDegreeLon
|
||||
|
||||
val otherLat = latBoundary - if (latitude >= latBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
|
||||
val otherLon = lonBoundary - if (longitude >= lonBoundary) NEIGHBOUR_NUDGE_DEGREES else -NEIGHBOUR_NUDGE_DEGREES
|
||||
|
||||
val cornerGrids = setOfNotNull(
|
||||
positionToGrid4(latitude, longitude),
|
||||
positionToGrid4(otherLat, longitude),
|
||||
positionToGrid4(latitude, otherLon),
|
||||
positionToGrid4(otherLat, otherLon)
|
||||
).sorted()
|
||||
|
||||
val onLatitude = abs(dNorth) <= abs(dEast)
|
||||
val nearestLineGrids = if (onLatitude) {
|
||||
setOfNotNull(grid4, positionToGrid4(otherLat, longitude)).sorted()
|
||||
} else {
|
||||
setOfNotNull(grid4, positionToGrid4(latitude, otherLon)).sorted()
|
||||
}
|
||||
|
||||
return GridGeometry(
|
||||
grid4 = grid4,
|
||||
grid6 = grid6,
|
||||
grid8 = grid8,
|
||||
latLineMeters = abs(dNorth),
|
||||
latLineDirection = if (dNorth >= 0) GridBoundaryDirection.NORTH else GridBoundaryDirection.SOUTH,
|
||||
lonLineMeters = abs(dEast),
|
||||
lonLineDirection = if (dEast >= 0) GridBoundaryDirection.EAST else GridBoundaryDirection.WEST,
|
||||
cornerMeters = hypot(dNorth, dEast),
|
||||
cornerBearingDegrees = bearingDegrees(dNorth, dEast),
|
||||
cornerGrids = cornerGrids,
|
||||
nearestLineMeters = if (onLatitude) abs(dNorth) else abs(dEast),
|
||||
nearestLineBearingDegrees = if (onLatitude) {
|
||||
if (dNorth >= 0) 0.0 else 180.0
|
||||
} else {
|
||||
if (dEast >= 0) 90.0 else 270.0
|
||||
},
|
||||
nearestLineIsLatitude = onLatitude,
|
||||
nearestLineGrids = nearestLineGrids
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The 4-character squares a station at this position may claim under ARRL VUCC
|
||||
* rules: normally one, but two when the fix is on the line between two squares
|
||||
* and four when it is on the corner where four squares meet. A fix within
|
||||
* [toleranceMeters] of a boundary is considered to be on it (the 20 ft rule).
|
||||
*/
|
||||
fun vuccClaimableGrids(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
toleranceMeters: Double = VUCC_BOUNDARY_TOLERANCE_METERS
|
||||
): List<String> {
|
||||
val own = positionToGrid4(latitude, longitude) ?: return emptyList()
|
||||
val grids = mutableSetOf(own)
|
||||
|
||||
val latBoundary = round(latitude)
|
||||
val lonBoundary = round((longitude + 180.0) / 2.0) * 2.0 - 180.0
|
||||
val metersPerDegreeLon = METERS_PER_DEGREE_LATITUDE * cos(latitude * DEG_TO_RAD)
|
||||
|
||||
val onLatitude = abs(latitude - latBoundary) * METERS_PER_DEGREE_LATITUDE <= toleranceMeters
|
||||
val onLongitude = abs(longitude - lonBoundary) * metersPerDegreeLon <= toleranceMeters
|
||||
if (!onLatitude && !onLongitude) return grids.sorted()
|
||||
|
||||
// Step just past the boundary so the neighbour's locator is unambiguous.
|
||||
val otherLat = latBoundary - if (latitude >= latBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
|
||||
val otherLon = lonBoundary - if (longitude >= lonBoundary) CLAIM_NEIGHBOUR_NUDGE_DEGREES else -CLAIM_NEIGHBOUR_NUDGE_DEGREES
|
||||
|
||||
if (onLatitude) {
|
||||
positionToGrid4(otherLat, longitude)?.let(grids::add)
|
||||
}
|
||||
if (onLongitude) {
|
||||
positionToGrid4(latitude, otherLon)?.let(grids::add)
|
||||
}
|
||||
if (onLatitude && onLongitude) {
|
||||
positionToGrid4(otherLat, otherLon)?.let(grids::add)
|
||||
}
|
||||
return grids.sorted()
|
||||
}
|
||||
|
||||
/** VUCC standing implied by the number of squares a fix may claim. */
|
||||
fun gridFixStatus(claimableGrids: List<String>): GridFixStatus = when {
|
||||
claimableGrids.size >= 4 -> GridFixStatus.ON_GRID_CORNER
|
||||
claimableGrids.size >= 2 -> GridFixStatus.ON_GRID_LINE
|
||||
else -> GridFixStatus.INSIDE_GRID
|
||||
}
|
||||
|
||||
private fun bearingDegrees(dNorth: Double, dEast: Double): Double {
|
||||
val bearing = atan2(dEast, dNorth) * RAD_TO_DEG
|
||||
return if (bearing < 0.0) bearing + 360.0 else bearing
|
||||
}
|
||||
@@ -69,6 +69,45 @@ fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a position to its 4-char Maidenhead locator (field + square), the
|
||||
* square VUCC credits, upper-cased. Returns null outside the Maidenhead grid
|
||||
* (lat >= 90 is out of range: the top row is 80..90 N). The longitude is
|
||||
* wrapped into [-180, 180) first, so fixes either side of the antimeridian
|
||||
* resolve to the square they are actually in.
|
||||
*/
|
||||
fun positionToGrid4(latitude: Double, longitude: Double): String? {
|
||||
if (latitude < -90.0 || latitude >= 90.0) return null
|
||||
val normalizedLon = normalizeLongitude(longitude)
|
||||
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||
val fieldLon = ((normalizedLon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||
val subLat = ((latitude + 90.0) % 10.0).toInt()
|
||||
val subLon = ((normalizedLon + 180.0) % 20.0 / 2.0).toInt()
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a position to its 8-char Maidenhead locator: the 6-char locator plus
|
||||
* the numeric extended-square pair (the Maidenhead maximum). Returns null for
|
||||
* out-of-range positions.
|
||||
*/
|
||||
fun positionToGrid8(latitude: Double, longitude: Double): String? {
|
||||
val normalizedLon = normalizeLongitude(longitude)
|
||||
val base = positionToQth(latitude, normalizedLon) ?: return null
|
||||
val latitudeOffset = latitude.coerceIn(-90.0, 89.999999) + 90.0
|
||||
val longitudeOffset = normalizedLon.coerceIn(-180.0, 179.999999) + 180.0
|
||||
val subLon = 2.0 / 24.0
|
||||
val subLat = 1.0 / 24.0
|
||||
val lonDigit = minOf(9, ((longitudeOffset % subLon) / subLon * 10).toInt())
|
||||
val latDigit = minOf(9, ((latitudeOffset % subLat) / subLat * 10).toInt())
|
||||
return "$base$lonDigit$latDigit"
|
||||
}
|
||||
|
||||
/** Wrap any longitude into [-180, 180). */
|
||||
private fun normalizeLongitude(longitude: Double): Double {
|
||||
return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
}
|
||||
|
||||
private fun isValidPosition(lat: Double, lon: Double): Boolean {
|
||||
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
|
||||
}
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.utility
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Nominal uplink frequency: the middle of the passband when one is published. */
|
||||
fun SatRadio.uplinkHz(): Long? = uplinkLow?.let { low -> uplinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||
|
||||
/** Nominal downlink frequency: the middle of the passband when one is published. */
|
||||
fun SatRadio.downlinkHz(): Long? = downlinkLow?.let { low -> downlinkHigh?.let { high -> (low + high) / 2 } ?: low }
|
||||
|
||||
/**
|
||||
* The FM voice repeater of a satellite, if it has one.
|
||||
*
|
||||
* A voice repeater is the single-frequency FM transceiver the operator actually
|
||||
* talks through (SatNOGS type "Transceiver", e.g. SO-50 "Mode V/U FM Voice
|
||||
* CTCSS 67.0 Hz"). Beacons and telemetry carry no uplink, so requiring BOTH an
|
||||
* uplink and a downlink already filters them out; the FM mode check removes
|
||||
* AFSK/APRS and linear entries.
|
||||
*
|
||||
* Ranking when several FM duplex entries exist (ISS is the awkward case, it also
|
||||
* lists crew V/V channels):
|
||||
* 1. entries described as a repeater / voice channel,
|
||||
* 2. amateur service entries,
|
||||
* 3. cross-band pairs (a V/U repeater over a same-band V/V downlink).
|
||||
*/
|
||||
fun List<SatRadio>.voiceRepeater(): SatRadio? = asSequence()
|
||||
.filter { it.isAlive }
|
||||
.filter { it.uplinkHz() != null && it.downlinkHz() != null }
|
||||
.filter { it.uplinkMode.equals("FM", true) || it.downlinkMode.equals("FM", true) }
|
||||
.sortedWith(
|
||||
compareByDescending<SatRadio> { it.info.contains("repeater", true) || it.info.contains("voice", true) }
|
||||
.thenByDescending { it.service.equals("Amateur", true) }
|
||||
.thenByDescending { abs((it.downlinkHz() ?: 0L) - (it.uplinkHz() ?: 0L)) }
|
||||
)
|
||||
.firstOrNull()
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Alias table that maps tracker catalogue names onto the names ARRL's config.tq6 uses. */
|
||||
class LoTWSatelliteAliasesTest {
|
||||
|
||||
private val catalogue = listOf("SO-50", "ARISS", "PO-101", "BO-102", "AO-91", "RS-44", "IO-117")
|
||||
|
||||
@Test
|
||||
fun `lookup maps SatNOGS catalogue names to ARRL names`() {
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.lookup("SAUDISAT 1C"))
|
||||
assertEquals("ARISS", LoTWSatelliteAliases.lookup("ISS (ZARYA)"))
|
||||
assertEquals("PO-101", LoTWSatelliteAliases.lookup("DIWATA 2B"))
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.lookup("FOX-1B"))
|
||||
assertEquals("RS-44", LoTWSatelliteAliases.lookup("DOSAAF-85"))
|
||||
assertEquals("IO-117", LoTWSatelliteAliases.lookup("GREENCUBE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize strips separators and case`() {
|
||||
assertEquals("ISSZARYA", LoTWSatelliteAliases.normalize("iss (zarya)"))
|
||||
assertEquals("SAUDISAT1C", LoTWSatelliteAliases.normalize("SaudiSat-1C"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lookup ignores unknown and placeholder names`() {
|
||||
assertNull(LoTWSatelliteAliases.lookup("NOT A SATELLITE"))
|
||||
// Recycled temporary designations must never resolve: they would sign the wrong object.
|
||||
assertNull(LoTWSatelliteAliases.lookup("OBJECT AY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve accepts ARRL names, aliases and suffixed tracker names`() {
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SO-50", catalogue))
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("saudisat 1c", catalogue))
|
||||
assertEquals("SO-50", LoTWSatelliteAliases.resolve("SAUDISAT 1C (SO-50)", catalogue))
|
||||
assertEquals("ARISS", LoTWSatelliteAliases.resolve("ISS (ZARYA)", catalogue))
|
||||
assertEquals("AO-91", LoTWSatelliteAliases.resolve("AO-91 (RadFxSat)", catalogue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolve rejects names that are not ARRL satellites`() {
|
||||
assertNull(LoTWSatelliteAliases.resolve("", catalogue))
|
||||
assertNull(LoTWSatelliteAliases.resolve("MARINA", catalogue))
|
||||
assertNull(LoTWSatelliteAliases.resolve("OBJECT AY", catalogue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `suggestions find the ARRL name from a tracker spelling`() {
|
||||
assertEquals(listOf("SO-50"), LoTWSatelliteAliases.suggestions("saudisat 1c", catalogue))
|
||||
assertTrue(LoTWSatelliteAliases.suggestions("ao9", catalogue).contains("AO-91"))
|
||||
assertEquals(listOf("PO-101"), LoTWSatelliteAliases.suggestions("diwata", catalogue))
|
||||
assertTrue(LoTWSatelliteAliases.suggestions("", catalogue).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Fixtures are the real SatNOGS transceiver rows of the three hardcoded AMSAT Live FM
|
||||
* satellites (fetched 2026-09-27), so the selection rules are tested against live data. */
|
||||
class VoiceRepeaterTest {
|
||||
|
||||
private fun radio(
|
||||
uuid: String,
|
||||
info: String,
|
||||
up: Long? = null,
|
||||
upHigh: Long? = null,
|
||||
down: Long? = null,
|
||||
downHigh: Long? = null,
|
||||
upMode: String? = null,
|
||||
downMode: String? = null,
|
||||
alive: Boolean = true,
|
||||
service: String? = null,
|
||||
catnum: Int = 12345
|
||||
) = SatRadio(
|
||||
uuid = uuid, info = info, isAlive = alive,
|
||||
downlinkLow = down, downlinkHigh = downHigh, downlinkMode = downMode,
|
||||
uplinkLow = up, uplinkHigh = upHigh, uplinkMode = upMode,
|
||||
isInverted = false, catnum = catnum, service = service
|
||||
)
|
||||
|
||||
// SO-50 (27607)
|
||||
private val so50Tlm = radio("so50-tlm", "Mode V TLM", down = 149_025_000L, downMode = "FM")
|
||||
private val so50Voice = radio(
|
||||
"so50-voice", "Mode V/U FM Voice CTCSS 67.0 Hz",
|
||||
up = 145_850_000L, down = 436_795_000L, upMode = "FM", downMode = "FM", catnum = 27607
|
||||
)
|
||||
|
||||
// ISS (25544): a voice repeater plus two crew V/V channels and an APRS transceiver
|
||||
private val issCrew2 = radio(
|
||||
"iss-crew2", "Mode V/V FM (crew R2+3)",
|
||||
up = 144_490_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||
)
|
||||
private val issCrew1 = radio(
|
||||
"iss-crew1", "Mode V/V FM (crew R1)",
|
||||
up = 145_200_000L, down = 145_800_000L, upMode = "FM", downMode = "FM", catnum = 25544
|
||||
)
|
||||
private val issVoice = radio(
|
||||
"iss-voice", "Mode V/U FM - Voice Repeater CTCSS 67.0 Hz",
|
||||
up = 145_990_000L, down = 437_800_000L, upMode = "FM", downMode = "FM",
|
||||
service = "Amateur", catnum = 25544
|
||||
)
|
||||
private val issAprs = radio(
|
||||
"iss-aprs", "Mode V APRS",
|
||||
up = 145_825_000L, down = 145_825_000L, upMode = "AFSK", downMode = "AFSK", catnum = 25544
|
||||
)
|
||||
|
||||
// AO-123 / ASRTU-1 (61781)
|
||||
private val ao123Voice = radio(
|
||||
"ao123-voice", "Mode V/U - FM Transceiver",
|
||||
up = 145_850_000L, down = 435_400_000L, upMode = "FM", downMode = "FM",
|
||||
service = "Amateur", catnum = 61781
|
||||
)
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_picksSo50VoicePair() {
|
||||
val repeater = listOf(so50Tlm, so50Voice).voiceRepeater()
|
||||
assertEquals("so50-voice", repeater?.uuid)
|
||||
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||
assertEquals(436_795_000L, repeater?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_prefersVoiceChannelOverCrewChannels() {
|
||||
val transmitterList = listOf(issCrew2, issAprs, issVoice, issCrew1)
|
||||
// Order must not matter - ranking decides.
|
||||
assertEquals("iss-voice", transmitterList.voiceRepeater()?.uuid)
|
||||
assertEquals("iss-voice", transmitterList.reversed().voiceRepeater()?.uuid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_picksAo123Transceiver() {
|
||||
val repeater = listOf(ao123Voice).voiceRepeater()
|
||||
assertEquals(145_850_000L, repeater?.uplinkHz())
|
||||
assertEquals(435_400_000L, repeater?.downlinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresBeaconsWithoutUplink() {
|
||||
assertNull(listOf(so50Tlm).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresDeadEntries() {
|
||||
val dead = so50Voice.copy(uuid = "dead", isAlive = false)
|
||||
assertNull(listOf(dead).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRepeater_ignoresLinearTransponders() {
|
||||
val linear = radio(
|
||||
"lin", "Linear Transponder",
|
||||
up = 145_900_000L, upHigh = 145_950_000L, down = 435_600_000L, downHigh = 435_650_000L,
|
||||
upMode = "LSB", downMode = "USB"
|
||||
)
|
||||
assertNull(listOf(linear).voiceRepeater())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uplinkHz_usesPassbandMiddleWhenPresent() {
|
||||
val wide = so50Voice.copy(uuid = "wide", uplinkLow = 145_840_000L, uplinkHigh = 145_860_000L)
|
||||
assertEquals(145_850_000L, wide.uplinkHz())
|
||||
assertEquals(145_850_000L, so50Voice.uplinkHz())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downlinkHz_isNullWhenNoDownlink() {
|
||||
assertNull(so50Voice.copy(downlinkLow = null).downlinkHz())
|
||||
}
|
||||
}
|
||||
+137
@@ -12,6 +12,7 @@ package com.rtbishop.look4sat.core.domain.logbook
|
||||
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -133,4 +134,140 @@ class LogbookMergeTest {
|
||||
assertEquals("FM", staleFm.displayMode)
|
||||
assertEquals("CW", staleCw.displayMode)
|
||||
}
|
||||
|
||||
// region logbook split (tracker name / band direction, 2026-09-28)
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_mapsTrackerNamesOntoArlNames() {
|
||||
// Local records keep the tracking-source name, LoTW reports the ARRL name; both
|
||||
// must land on one identity or every confirmation becomes a second row.
|
||||
assertEquals("SO-50", satelliteIdentity("SAUDISAT 1C"))
|
||||
assertEquals("SO-50", satelliteIdentity("SO-50"))
|
||||
assertEquals("ARISS", satelliteIdentity("ISS (ZARYA)"))
|
||||
assertEquals("ARISS", satelliteIdentity("ARISS"))
|
||||
assertEquals("PO-101", satelliteIdentity("DIWATA 2B"))
|
||||
assertEquals("FO-29", satelliteIdentity("JAS 2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_isSymmetricForEveryAliasEntry() {
|
||||
// Guards the whole table: an entry whose target does not resolve back to the same
|
||||
// identity would split that satellite's contacts again.
|
||||
LoTWSatelliteAliases.table.forEach { (tracker, official) ->
|
||||
assertEquals(official, satelliteIdentity(tracker), satelliteIdentity(official))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun satelliteIdentity_keepsRecycledPlaceholdersUnmatched() {
|
||||
// "OBJECT xx" names are reused between objects and deliberately unmapped, so they
|
||||
// must never resolve onto a real ARRL satellite.
|
||||
assertEquals("OBJECT AY", satelliteIdentity("OBJECT AY"))
|
||||
assertNotEquals(satelliteIdentity("AO-123"), satelliteIdentity("OBJECT AY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_matchesTrackerNameAgainstArlName() {
|
||||
// The reported bug: SO-50 logged in the app (tracker name, 145.850 uplink) never
|
||||
// picked up the confirmation LoTW reported for it.
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||
val reported = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
assertTrue(sameConfirmedContact(uploaded, reported))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameConfirmedContact_stillRejectsOtherContactOrMinute() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", uplinkHz = 145_850_000L, downlinkHz = 436_795_000L)
|
||||
val otherOperator = reportedRecord("SO-50", band = "2M", rxBand = "70CM").copy(theirCallsign = "BG5JVM")
|
||||
val anotherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
.copy(startUtcMillis = uploaded.startUtcMillis + 90_000L)
|
||||
assertFalse(sameConfirmedContact(uploaded, otherOperator))
|
||||
assertFalse(sameConfirmedContact(uploaded, anotherMinute))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_foldsRowsSplitByTheOldMismatch() {
|
||||
// Both pre-fix symptoms in one pair: the imported row keeps the ARRL name and the
|
||||
// mirrored band direction the old parser produced (band=downlink).
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 1L, lotwUploaded = true)
|
||||
val imported = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 2L)
|
||||
val pairs = splitConfirmationPairs(listOf(uploaded, imported))
|
||||
assertEquals(1, pairs.size)
|
||||
assertEquals(0, pairs.first().localIndex)
|
||||
assertEquals(1, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 1L, lotwUploaded = true)
|
||||
// Another operator on another satellite in the same minute must not be folded in.
|
||||
val otherOperator = reportedRecord("SO-50", band = "70CM", rxBand = "2M")
|
||||
.copy(id = 2L, theirCallsign = "BG5JVM")
|
||||
// A contact on a satellite the local record does not name at all stays separate.
|
||||
val otherSatellite = reportedRecord("AO-91", band = "70CM", rxBand = "2M").copy(id = 3L)
|
||||
assertTrue(splitConfirmationPairs(listOf(uploaded, otherOperator, otherSatellite)).isEmpty())
|
||||
}
|
||||
|
||||
/** A record as the log page creates it: tracker name, uplink/downlink bands, no flags. */
|
||||
@Test
|
||||
fun officialSatelliteName_storesTheArlName() {
|
||||
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C"))
|
||||
assertEquals("ARISS", officialSatelliteName("ISS (ZARYA)"))
|
||||
assertEquals("PO-101", officialSatelliteName("DIWATA 2B"))
|
||||
assertEquals("FO-29", officialSatelliteName("JAS 2"))
|
||||
// Already-ARRL names are left alone (the rewrite is idempotent).
|
||||
assertEquals("SO-50", officialSatelliteName("SO-50"))
|
||||
assertEquals("AO-123", officialSatelliteName("AO-123"))
|
||||
// Nothing to map onto: a satellite ARRL does not list, a recycled placeholder, a blank.
|
||||
assertEquals("FOO-1", officialSatelliteName("FOO-1"))
|
||||
assertEquals("OBJECT AY", officialSatelliteName("OBJECT AY"))
|
||||
assertEquals("", officialSatelliteName(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun officialSatelliteName_usesTheCatalogueWhenItKnowsTheNameTheTrackerCarries() {
|
||||
val catalogue = listOf("BO-102", "SO-50")
|
||||
// "CAS-7B" resolves through the alias table, the description-only name through the catalogue.
|
||||
assertEquals("BO-102", officialSatelliteName("CAS-7B", catalogue))
|
||||
assertEquals("BO-102", officialSatelliteName("CAS-7B (BO-102)", catalogue))
|
||||
assertEquals("SO-50", officialSatelliteName("SAUDISAT 1C", catalogue))
|
||||
}
|
||||
|
||||
private fun loggedRecord(satelliteName: String, uplinkHz: Long, downlinkHz: Long) = QsoRecord(
|
||||
startUtcMillis = REPORTED_START,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
txFrequencyHz = uplinkHz,
|
||||
rxFrequencyHz = downlinkHz,
|
||||
band = frequencyBand(uplinkHz),
|
||||
rxBand = frequencyBand(downlinkHz),
|
||||
mode = "FM",
|
||||
satelliteName = satelliteName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwUploaded = true
|
||||
)
|
||||
|
||||
/** A record as a LoTW confirmation carries it: ARRL name, report bands, confirmed. */
|
||||
private fun reportedRecord(satelliteName: String, band: String, rxBand: String) = QsoRecord(
|
||||
startUtcMillis = REPORTED_START,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
band = band,
|
||||
rxBand = rxBand,
|
||||
mode = "FM",
|
||||
satelliteName = satelliteName,
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** 2026-09-16 07:45Z — an SO-50 pass of the user's real report. */
|
||||
const val REPORTED_START = 1_789_544_700_000L
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 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.utility
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Golden values were produced by a line-by-line port of the reference
|
||||
* implementation (OrbitDeck for iOS, MIT) covering four hemispheres/quadrant
|
||||
* cases plus the two awkward longitudes: the Greenwich meridian and the
|
||||
* antimeridian.
|
||||
*/
|
||||
class GridGeometryTest {
|
||||
|
||||
private val meterDelta = 0.5
|
||||
private val bearingDelta = 0.05
|
||||
|
||||
@Test
|
||||
fun `station at home square reports both boundaries and the corner`() {
|
||||
val geometry = requireNotNull(gridGeometry(22.3542, 113.6250))
|
||||
assertEquals("OL62", geometry.grid4)
|
||||
assertEquals("OL62TI", geometry.grid6)
|
||||
assertEquals("OL62TI55", geometry.grid8)
|
||||
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
|
||||
assertEquals(38607.9, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||
assertEquals(55183.8, geometry.cornerMeters, meterDelta)
|
||||
assertEquals(135.60, geometry.cornerBearingDegrees, bearingDelta)
|
||||
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
|
||||
assertEquals(38607.9, geometry.nearestLineMeters, meterDelta)
|
||||
assertEquals(90.0, geometry.nearestLineBearingDegrees, bearingDelta)
|
||||
assertEquals(false, geometry.nearestLineIsLatitude)
|
||||
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fix on the 114 east line sits on the boundary itself`() {
|
||||
val geometry = requireNotNull(gridGeometry(22.3542, 114.0000))
|
||||
assertEquals("OL72", geometry.grid4)
|
||||
assertEquals("OL72AI", geometry.grid6)
|
||||
assertEquals("OL72AI05", geometry.grid8)
|
||||
assertEquals(39429.5, geometry.latLineMeters, meterDelta)
|
||||
assertEquals(0.0, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(39429.5, geometry.cornerMeters, meterDelta)
|
||||
assertEquals(180.00, geometry.cornerBearingDegrees, bearingDelta)
|
||||
assertEquals(listOf("OL62", "OL72"), geometry.nearestLineGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fix on the 22 north 114 east corner names all four squares`() {
|
||||
val geometry = requireNotNull(gridGeometry(22.00002, 114.00003))
|
||||
assertEquals("OL72", geometry.grid4)
|
||||
assertEquals(2.2, geometry.latLineMeters, meterDelta)
|
||||
assertEquals(3.1, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(3.8, geometry.cornerMeters, meterDelta)
|
||||
assertEquals(234.28, geometry.cornerBearingDegrees, bearingDelta)
|
||||
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), geometry.cornerGrids)
|
||||
assertEquals("OL71", geometry.nearestLineGrids.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `greenwich meridian fix reports a zero distance latitude line to the north`() {
|
||||
val geometry = requireNotNull(gridGeometry(45.0000, -0.0004))
|
||||
assertEquals("IN95", geometry.grid4)
|
||||
assertEquals("IN95XA", geometry.grid6)
|
||||
assertEquals("IN95XA90", geometry.grid8)
|
||||
assertEquals(0.0, geometry.latLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.NORTH, geometry.latLineDirection)
|
||||
assertEquals(31.5, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(listOf("IN94", "IN95", "JN04", "JN05"), geometry.cornerGrids)
|
||||
assertEquals(true, geometry.nearestLineIsLatitude)
|
||||
assertEquals(listOf("IN94", "IN95"), geometry.nearestLineGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `antimeridian fix wraps into the eastern square`() {
|
||||
val geometry = requireNotNull(gridGeometry(0.05, 179.9998))
|
||||
assertEquals("RJ90", geometry.grid4)
|
||||
assertEquals("RJ90XB", geometry.grid6)
|
||||
assertEquals("RJ90XB92", geometry.grid8)
|
||||
assertEquals(22.3, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||
assertEquals(179.77, geometry.cornerBearingDegrees, bearingDelta)
|
||||
// The corner's four squares straddle the antimeridian, so the western
|
||||
// pair rolls over to the A-series fields.
|
||||
assertEquals(listOf("AI09", "AJ00", "RI99", "RJ90"), geometry.cornerGrids)
|
||||
assertEquals(listOf("AJ00", "RJ90"), geometry.nearestLineGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `southern hemisphere fix keeps its bearings right way round`() {
|
||||
val geometry = requireNotNull(gridGeometry(-33.86, 151.21))
|
||||
assertEquals("QF56", geometry.grid4)
|
||||
assertEquals("QF56OD", geometry.grid6)
|
||||
assertEquals(15584.8, geometry.latLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.SOUTH, geometry.latLineDirection)
|
||||
assertEquals(73027.8, geometry.lonLineMeters, meterDelta)
|
||||
assertEquals(GridBoundaryDirection.EAST, geometry.lonLineDirection)
|
||||
assertEquals(102.05, geometry.cornerBearingDegrees, bearingDelta)
|
||||
assertEquals(180.0, geometry.nearestLineBearingDegrees, bearingDelta)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of range positions yield no geometry`() {
|
||||
assertNull(gridGeometry(90.0, 0.0))
|
||||
assertNull(gridGeometry(-91.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vucc claims one square when the fix is inside`() {
|
||||
assertEquals(listOf("OL62"), vuccClaimableGrids(22.3542, 113.6250))
|
||||
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(vuccClaimableGrids(22.3542, 113.6250)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vucc claims both squares on a line`() {
|
||||
val claimed = vuccClaimableGrids(22.3542, 114.0000)
|
||||
assertEquals(listOf("OL62", "OL72"), claimed)
|
||||
assertEquals(GridFixStatus.ON_GRID_LINE, gridFixStatus(claimed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vucc claims all four squares on a corner`() {
|
||||
val claimed = vuccClaimableGrids(22.00002, 114.00003)
|
||||
assertEquals(listOf("OL61", "OL62", "OL71", "OL72"), claimed)
|
||||
assertEquals(GridFixStatus.ON_GRID_CORNER, gridFixStatus(claimed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vucc claims nothing extra just outside the 20 foot tolerance`() {
|
||||
// Same corner, but ~38 m away: outside the 20 ft (6.096 m) rule.
|
||||
val claimed = vuccClaimableGrids(22.0002, 114.0003)
|
||||
assertEquals(listOf("OL72"), claimed)
|
||||
assertEquals(GridFixStatus.INSIDE_GRID, gridFixStatus(claimed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vucc tolerance boundary is 20 feet`() {
|
||||
assertEquals(6.096, VUCC_BOUNDARY_TOLERANCE_METERS, 0.0001)
|
||||
// 5 m north of the 22 N line: inside the tolerance for a custom, tighter
|
||||
// call, outside it when the caller demands sub-metre precision.
|
||||
assertTrue(vuccClaimableGrids(22.00005, 113.62).size >= 2)
|
||||
assertEquals(1, vuccClaimableGrids(22.00005, 113.62, toleranceMeters = 1.0).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grid4 ignores longitudes beyond the antimeridian`() {
|
||||
assertNull(positionToGrid4(91.0, 0.0))
|
||||
// A longitude of 200 degrees is the same meridian as -160 degrees.
|
||||
assertEquals(positionToGrid4(30.0, -160.0), positionToGrid4(30.0, 200.0))
|
||||
}
|
||||
}
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.presentation
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
|
||||
+3
-2
@@ -61,8 +61,9 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
|
||||
SideEffect {
|
||||
val window = (view.context as ComponentActivity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
insetsController.isAppearanceLightNavigationBars = false
|
||||
// Light theme needs dark status/nav bar icons; the dark theme needs light ones.
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
||||
@Serializable
|
||||
data object RadarDestination : NavKey
|
||||
|
||||
/** Full-screen field tool: guides the operator onto a VUCC grid line or corner. */
|
||||
@Serializable
|
||||
data object GridFinderDestination : NavKey
|
||||
|
||||
/** LoTW upload certificate + station location page, reached from the Grid Finder. */
|
||||
@Serializable
|
||||
data object LoTWUploadDestination : NavKey
|
||||
|
||||
interface IDeeplinkMatcher {
|
||||
fun match(deeplink: String): NavKey?
|
||||
}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The fork's Project Wiki: a short overview of what Look4Sat-BA7OPF adds over upstream
|
||||
* and how to use those features. Shown in the What's-New card (first launch after an
|
||||
* update) and behind the Settings → Project Wiki entry. Text lives in string resources,
|
||||
* so it always renders even when the GitHub page is unreachable.
|
||||
*/
|
||||
@Composable
|
||||
fun ProjectWikiBody(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.project_wiki_intro),
|
||||
fontSize = 13.sp
|
||||
)
|
||||
WikiSection(stringResource(R.string.project_wiki_section_logbook))
|
||||
WikiEntry(stringResource(R.string.project_wiki_logbook_1))
|
||||
WikiEntry(stringResource(R.string.project_wiki_logbook_2))
|
||||
WikiEntry(stringResource(R.string.project_wiki_logbook_3))
|
||||
WikiHow(stringResource(R.string.project_wiki_logbook_how))
|
||||
WikiSection(stringResource(R.string.project_wiki_section_mutual))
|
||||
WikiEntry(stringResource(R.string.project_wiki_mutual_1))
|
||||
WikiHow(stringResource(R.string.project_wiki_mutual_how))
|
||||
WikiSection(stringResource(R.string.project_wiki_section_grids))
|
||||
WikiEntry(stringResource(R.string.project_wiki_grids_1))
|
||||
WikiHow(stringResource(R.string.project_wiki_grids_how))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WikiSection(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WikiEntry(text: String) {
|
||||
Text(text = text, fontSize = 12.sp)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WikiHow(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* 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.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.LoTWSatelliteAliases
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import java.math.BigDecimal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
private const val DATE_PATTERN = "yyyy-MM-dd"
|
||||
private const val TIME_PATTERN = "HH:mm:ss"
|
||||
|
||||
/**
|
||||
* Edit every field of a recorded QSO (the record list rows in the radar Log tab and
|
||||
* the logbook dialog both open this). Frequencies are entered in MHz and the band is
|
||||
* re-derived from them, because that derivation is what LoTW validates: a record with
|
||||
* no uplink frequency has no BAND and cannot be signed.
|
||||
*
|
||||
* The satellite name is restricted to ARRL's catalogue ([satelliteCandidates], from
|
||||
* config.tq6): typing shows fuzzy suggestions, the picker lists the whole catalogue, and
|
||||
* a name that resolves to no ARRL satellite cannot be saved. A tracker name such as
|
||||
* "SAUDISAT 1C" is accepted and stored as the ARRL name it resolves to ("SO-50").
|
||||
*/
|
||||
@Composable
|
||||
fun QsoEditDialog(
|
||||
record: QsoRecord,
|
||||
satelliteCandidates: List<String> = emptyList(),
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (QsoRecord) -> Unit
|
||||
) {
|
||||
val utc = remember { TimeZone.getTimeZone("UTC") }
|
||||
fun formatDate(millis: Long) = SimpleDateFormat(DATE_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
|
||||
fun formatTime(millis: Long) = SimpleDateFormat(TIME_PATTERN, Locale.US).apply { timeZone = utc }.format(Date(millis))
|
||||
|
||||
var callsign by remember(record.id) { mutableStateOf(record.theirCallsign.trim()) }
|
||||
var dateText by remember(record.id) { mutableStateOf(formatDate(record.startUtcMillis)) }
|
||||
var timeText by remember(record.id) { mutableStateOf(formatTime(record.startUtcMillis)) }
|
||||
var txText by remember(record.id) { mutableStateOf(formatMhz(record.txFrequencyHz)) }
|
||||
var rxText by remember(record.id) { mutableStateOf(formatMhz(record.rxFrequencyHz)) }
|
||||
var modeText by remember(record.id) { mutableStateOf(record.displayMode) }
|
||||
var satelliteText by remember(record.id) { mutableStateOf(record.satelliteName) }
|
||||
var pickerOpen by remember(record.id) { mutableStateOf(false) }
|
||||
var sentText by remember(record.id) { mutableStateOf(record.sentReport) }
|
||||
var receivedText by remember(record.id) { mutableStateOf(record.receivedReport) }
|
||||
var gridText by remember(record.id) { mutableStateOf(record.theirGrid) }
|
||||
var commentText by remember(record.id) { mutableStateOf(record.comment) }
|
||||
var error by remember(record.id) { mutableStateOf("") }
|
||||
|
||||
// ARRL name the typed tracker name resolves to ("SAUDISAT 1C" -> "SO-50"), if any.
|
||||
val officialSatellite = remember(satelliteText, satelliteCandidates) {
|
||||
LoTWSatelliteAliases.resolve(satelliteText, satelliteCandidates)
|
||||
}
|
||||
val satelliteSuggestions = remember(satelliteText, satelliteCandidates) {
|
||||
LoTWSatelliteAliases.suggestions(satelliteText, satelliteCandidates)
|
||||
}
|
||||
|
||||
val txHz = parseMhz(txText)
|
||||
val rxHz = parseMhz(rxText)
|
||||
val bandText = listOf(frequencyBand(txHz), frequencyBand(rxHz)).filter { it.isNotBlank() }.distinct()
|
||||
.joinToString(" / ")
|
||||
|
||||
val errCallsign = stringResource(R.string.qso_edit_err_callsign)
|
||||
val errDateTime = stringResource(R.string.qso_edit_err_datetime)
|
||||
val errFrequency = stringResource(R.string.qso_edit_err_frequency)
|
||||
val errSatellite = stringResource(R.string.qso_edit_err_satellite)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.qso_edit_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (record.lotwUploaded || record.lotwConfirmed) {
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_uploaded_notice),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_callsign), callsign, KeyboardCapitalization.Characters) {
|
||||
callsign = it.uppercase(Locale.US)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_date), dateText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
dateText = it
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_time), timeText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
timeText = it
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_tx), txText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
txText = it
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_rx), rxText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
rxText = it
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_band, bandText.ifBlank { "—" }),
|
||||
fontSize = 12.sp,
|
||||
color = if (txHz == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
EditField(stringResource(R.string.qso_edit_mode), modeText, KeyboardCapitalization.Characters) {
|
||||
modeText = it.uppercase(Locale.US)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(
|
||||
label = stringResource(R.string.qso_edit_satellite),
|
||||
value = satelliteText,
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
modifier = Modifier.weight(1f)
|
||||
) { satelliteText = it.uppercase(Locale.US) }
|
||||
TextButton(
|
||||
onClick = { pickerOpen = true },
|
||||
enabled = satelliteCandidates.isNotEmpty()
|
||||
) { Text(stringResource(R.string.qso_edit_satellite_pick), fontSize = 12.sp) }
|
||||
}
|
||||
when {
|
||||
// Resolves to an ARRL satellite under a different name: say so, the
|
||||
// ARRL name is what gets stored and signed.
|
||||
officialSatellite != null && !officialSatellite.equals(satelliteText.trim(), true) ->
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_satellite_official, officialSatellite),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Not an ARRL satellite at all: offer the closest catalogue names.
|
||||
satelliteText.isNotBlank() && officialSatellite == null -> {
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_err_satellite),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
if (satelliteSuggestions.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
satelliteSuggestions.take(4).forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = false,
|
||||
onClick = { satelliteText = candidate },
|
||||
label = { Text(candidate, fontSize = 11.sp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> Text(
|
||||
text = stringResource(R.string.qso_edit_hint_satellite),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_sent), sentText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
sentText = it
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_received), receivedText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
receivedText = it
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
EditField(stringResource(R.string.qso_edit_grid), gridText, KeyboardCapitalization.Characters, Modifier.weight(1f)) {
|
||||
gridText = it.uppercase(Locale.US)
|
||||
}
|
||||
EditField(stringResource(R.string.qso_edit_comment), commentText, KeyboardCapitalization.None, Modifier.weight(1f)) {
|
||||
commentText = it
|
||||
}
|
||||
}
|
||||
if (txHz == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.qso_edit_warn_no_freq),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
if (error.isNotBlank()) {
|
||||
Text(text = error, fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val millis = parseUtc(dateText, timeText, utc)
|
||||
val badFrequency = (txText.isNotBlank() && txHz == null) || (rxText.isNotBlank() && rxHz == null)
|
||||
when {
|
||||
callsign.isBlank() -> error = errCallsign
|
||||
millis == null -> error = errDateTime
|
||||
badFrequency -> error = errFrequency
|
||||
// Only ARRL satellite names may be stored: a record that cannot be
|
||||
// resolved here would be rejected at upload time anyway.
|
||||
satelliteCandidates.isNotEmpty() && officialSatellite == null ->
|
||||
error = errSatellite
|
||||
|
||||
else -> {
|
||||
val mode = modeText.trim().uppercase(Locale.US).ifBlank { "FM" }
|
||||
val satellite = officialSatellite ?: satelliteText.trim()
|
||||
onSave(
|
||||
record.copy(
|
||||
theirCallsign = callsign.trim().uppercase(Locale.US),
|
||||
startUtcMillis = millis,
|
||||
endUtcMillis = record.endUtcMillis?.let { millis },
|
||||
theirGrid = gridText.trim().uppercase(Locale.US),
|
||||
sentReport = sentText.trim(),
|
||||
receivedReport = receivedText.trim(),
|
||||
txFrequencyHz = txHz,
|
||||
rxFrequencyHz = rxHz,
|
||||
band = frequencyBand(txHz),
|
||||
rxBand = frequencyBand(rxHz),
|
||||
mode = if (mode == "FT4") "MFSK" else mode,
|
||||
submode = if (mode == "FT4") "FT4" else "",
|
||||
satelliteName = satellite,
|
||||
propagationMode = if (satellite.isBlank()) record.propagationMode else "SAT",
|
||||
comment = commentText.trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.qso_edit_save)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
|
||||
}
|
||||
)
|
||||
|
||||
if (pickerOpen) {
|
||||
SatellitePickerDialog(
|
||||
candidates = satelliteCandidates,
|
||||
onDismiss = { pickerOpen = false },
|
||||
onPick = {
|
||||
satelliteText = it
|
||||
pickerOpen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full ARRL satellite catalogue with a fuzzy filter: typing "saud" or "saudisat 1c" narrows the
|
||||
* list down to "SO-50", so a tracker name can always be turned into the name LoTW accepts.
|
||||
*/
|
||||
@Composable
|
||||
private fun SatellitePickerDialog(
|
||||
candidates: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (String) -> Unit
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val matches = remember(query, candidates) { filteredCandidates(query, candidates) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.qso_edit_satellite_pick_title)) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it.uppercase(Locale.US) },
|
||||
label = { Text(stringResource(R.string.qso_edit_satellite_search), fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(matches, key = { it }) { name ->
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onPick(name) }
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.qso_edit_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Catalogue filtered by the fuzzy query; the whole list when nothing matches or the query is blank. */
|
||||
internal fun filteredCandidates(query: String, candidates: List<String>): List<String> {
|
||||
if (query.isBlank()) return candidates
|
||||
val matches = LoTWSatelliteAliases.suggestions(query, candidates, limit = 500)
|
||||
return matches.ifEmpty { candidates }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditField(
|
||||
label: String,
|
||||
value: String,
|
||||
capitalization: KeyboardCapitalization,
|
||||
modifier: Modifier = Modifier.fillMaxWidth(),
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
keyboardOptions = KeyboardOptions(capitalization = capitalization),
|
||||
modifier = modifier.padding(vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/** "436.795" (MHz) -> 436_795_000 Hz. Null when blank or unparsable. */
|
||||
internal fun parseMhz(text: String): Long? = text.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.toDoubleOrNull()
|
||||
?.let { (it * 1_000_000.0).roundToLong() }
|
||||
|
||||
/** Hz -> "436.795" (MHz), trailing zeros trimmed. */
|
||||
internal fun formatMhz(hz: Long?): String = hz
|
||||
?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }
|
||||
.orEmpty()
|
||||
|
||||
internal fun parseUtc(date: String, time: String, zone: TimeZone): Long? = runCatching {
|
||||
SimpleDateFormat("$DATE_PATTERN $TIME_PATTERN", Locale.US).apply {
|
||||
timeZone = zone
|
||||
isLenient = false
|
||||
}.parse("${date.trim()} ${time.trim()}")?.time
|
||||
}.getOrNull()
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.presentation
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Styling of the app's sheet dialogs ([DialogShell] / [SharedDialog] / [InfoDialog], all built on
|
||||
* ModalBottomSheet). AlertDialog-based dialogs (logbook, QSO editor, upload preview) use these so
|
||||
* their container colour, corner radius and title look identical to the bottom sheets.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Container colour of the app's bottom sheets. Equal to `BottomSheetDefaults.ContainerColor`
|
||||
* (Material3 resolves that token to `ColorScheme.surfaceContainerLow`) — spelled out here so the
|
||||
* dialogs don't have to opt into the experimental bottom-sheet API.
|
||||
*/
|
||||
@Composable
|
||||
fun sheetDialogContainerColor(): Color = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
|
||||
/** Corner radius of the app's bottom sheets (`MaterialTheme.shapes.medium`, 12 dp). */
|
||||
@Composable
|
||||
fun sheetDialogShape(): Shape = MaterialTheme.shapes.medium
|
||||
|
||||
/** Title row of the app's sheet dialogs: 16 sp medium, primary colour, single line. */
|
||||
@Composable
|
||||
fun SheetDialogTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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.presentation
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
Grid Finder icon: a GPS crosshair.
|
||||
Ported from Material Design Icons (crosshairs-gps), Apache License 2.0.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M3.05,13H1V11H3.05C3.5,6.83 6.83,3.5 11,3.05V1H13V3.05C17.17,3.5 20.5,6.83 20.95,11H23V13H20.95C20.5,17.17 17.17,20.5 13,20.95V23H11V20.95C6.83,20.5 3.5,17.17 3.05,13M12,5A7,7 0 0,0 5,12A7,7 0 0,0 12,19A7,7 0 0,0 19,12A7,7 0 0,0 12,5Z" />
|
||||
</vector>
|
||||
@@ -83,7 +83,6 @@
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_donate_title">Soporte</string>
|
||||
<string name="prefs_privacy_title">Política de privacidad</string>
|
||||
|
||||
<string name="prefs_updated_title">Actualizado: %s</string>
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
<string name="map_visible">Видимый</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_donate_title">Поддержать</string>
|
||||
<string name="prefs_privacy_title">Политика конфиденциальности</string>
|
||||
|
||||
<string name="prefs_updated_title">Обновлено: %s</string>
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
<string name="map_visible">දෘශ්ය</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_donate_title">දායකත්ව</string>
|
||||
<string name="prefs_privacy_title">රහස්යතා ප්රතිපත්තිය</string>
|
||||
|
||||
<string name="prefs_updated_title">යාවත්කාලීන කරන ලදී: %s</string>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Commmon -->
|
||||
<string name="app_name" translatable="false">Look4Sat</string>
|
||||
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
|
||||
<string name="btn_accept">Kabul et</string>
|
||||
<string name="btn_cancel">İptal</string>
|
||||
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||
@@ -104,18 +104,16 @@
|
||||
<string name="map_visible">Görünür</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Bağış yap</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Gizlilik Politikası</string>
|
||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
|
||||
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
|
||||
|
||||
<string name="prefs_updated_title">Güncellendi: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
|
||||
@@ -83,7 +83,6 @@
|
||||
<string name="map_visible">Видимий</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_donate_title">Підтримати</string>
|
||||
<string name="prefs_privacy_title">Політика конфіденційності</string>
|
||||
|
||||
<string name="prefs_updated_title">Оновлено: %s</string>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- AMSAT Status screen -->
|
||||
<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>
|
||||
@@ -141,7 +140,6 @@
|
||||
<string name="map_visible">日照中</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_donate_title">支持</string>
|
||||
<string name="prefs_privacy_title">隐私政策</string>
|
||||
|
||||
<string name="prefs_updated_title">更新:%s</string>
|
||||
@@ -304,6 +302,7 @@
|
||||
<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_light_theme">浅色主题</string>
|
||||
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
|
||||
<string name="prefs_other_switch_map_grid">地图显示 Maidenhead 网格</string>
|
||||
|
||||
@@ -312,7 +311,20 @@
|
||||
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
|
||||
<string name="prefs_highlight_high">高 > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">我要感谢:</string>
|
||||
<string name="prefs_outro_title">致谢与声明</string>
|
||||
<string name="prefs_project_wiki">Project Wiki</string>
|
||||
<string name="project_wiki_intro">Look4Sat-BA7OPF 是基于 Look4Sat 的定制版,保留上游全部功能,并新增:</string>
|
||||
<string name="project_wiki_section_logbook">卫星日志本与 LoTW</string>
|
||||
<string name="project_wiki_logbook_1">卫星通联日志本——通联时一键记录呼号、时间、频率、模式、信号报告和网格。</string>
|
||||
<string name="project_wiki_logbook_2">LoTW 直传——导入 ARRL 证书后,直接在 App 内把日志上传到 LoTW,无需电脑。</string>
|
||||
<string name="project_wiki_logbook_3">确认同步——一键同步 LoTW 官方确认,日志自动标记“已上传 / 已确认”;修改已上传的记录会自动新建一条,原记录不受影响。</string>
|
||||
<string name="project_wiki_logbook_how">用法:通联后点“记录”→ 日志页点“上传”→ 设置里“同步”查看确认。</string>
|
||||
<string name="project_wiki_section_mutual">双站过境匹配</string>
|
||||
<string name="project_wiki_mutual_1">友台共视匹配——输入你和友台的位置,找出双方同时可见的卫星过境,点一下直接进雷达图。</string>
|
||||
<string name="project_wiki_mutual_how">用法:Match 页 → 设置两站位置 → 查询。</string>
|
||||
<string name="project_wiki_section_grids">通联网格地图</string>
|
||||
<string name="project_wiki_grids_1">已通联网格——LoTW/Wavelog 同步的网格在地图上标绿,支持奖状边界和首通呼号标签;点格子看该网格的通联记录,还能一键去匹配友台。</string>
|
||||
<string name="project_wiki_grids_how">用法:地图页开启网格模式 → 设置里同步 LoTW/Wavelog。</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
• Look4Sat 所有用户及贡献者!
|
||||
\n• David A. B. Johnson (predict4java)
|
||||
@@ -325,7 +337,9 @@
|
||||
\n• BH2VSQ BG5JSU(天地图渲染支持)
|
||||
\n• BG5JSU(LoTW上传支持)
|
||||
</string>
|
||||
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
|
||||
<string name="prefs_outro_notice">Look4Sat-BA7OPF 是基于 Look4Sat(GPL-3.0)的非官方修改版,由 BA7OPF 独立维护,与原项目无隶属关系,亦未获其背书。
|
||||
上游项目:https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="prefs_outro_license">基于 GNU GPL v3 许可 · 本程序不提供任何保修</string>
|
||||
|
||||
<!-- 匹配页 Mutual match page -->
|
||||
<string name="mutual_title">过境匹配</string>
|
||||
@@ -374,4 +388,89 @@
|
||||
<string name="update_check_download_error">下载失败,请重试。</string>
|
||||
<string name="update_check_no_apk">该版本未附带 APK 安装包。</string>
|
||||
|
||||
<!-- QSO edit dialog -->
|
||||
<string name="qso_edit_title">编辑通联记录</string>
|
||||
<string name="qso_edit_uploaded_notice">该通联已上传或已确认。保存将保留原记录不变,并新建一条记录。</string>
|
||||
<string name="qso_edit_callsign">呼号</string>
|
||||
<string name="qso_edit_date">日期 (UTC)</string>
|
||||
<string name="qso_edit_time">时间 (UTC)</string>
|
||||
<string name="qso_edit_tx">上行频率 (MHz)</string>
|
||||
<string name="qso_edit_rx">下行频率 (MHz)</string>
|
||||
<string name="qso_edit_band">频段:%1$s</string>
|
||||
<string name="qso_edit_mode">模式</string>
|
||||
<string name="qso_edit_satellite">卫星</string>
|
||||
<string name="qso_edit_hint_satellite">LoTW 只接受它的官方卫星名</string>
|
||||
<string name="qso_edit_sent">发送报告</string>
|
||||
<string name="qso_edit_received">接收报告</string>
|
||||
<string name="qso_edit_grid">对方网格</string>
|
||||
<string name="qso_edit_comment">备注</string>
|
||||
<string name="qso_edit_save">保存</string>
|
||||
<string name="qso_edit_cancel">取消</string>
|
||||
<string name="qso_edit_err_callsign">呼号不能为空</string>
|
||||
<string name="qso_edit_err_datetime">日期或时间格式不对 —— 应为 yyyy-MM-dd 与 HH:mm:ss(UTC)</string>
|
||||
<string name="qso_edit_err_frequency">频率格式不对 —— 请输入 MHz,例如 145.850</string>
|
||||
<string name="qso_edit_err_satellite">不是 LoTW 官方卫星名 —— 请从列表中选一个</string>
|
||||
<string name="qso_edit_satellite_pick">选择</string>
|
||||
<string name="qso_edit_satellite_pick_title">LoTW 官方卫星名</string>
|
||||
<string name="qso_edit_satellite_search">搜索卫星名或数据源别名</string>
|
||||
<string name="qso_edit_satellite_official">保存并签名时使用 %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传</string>
|
||||
|
||||
<!-- 网格寻线(VUCC 网格线/网格角实地导航) -->
|
||||
<string name="gridfinder_title">网格寻线</string>
|
||||
<string name="gridfinder_status_corner">位于网格点上</string>
|
||||
<string name="gridfinder_status_line">位于网格线上</string>
|
||||
<string name="gridfinder_status_inside">在网格内部</string>
|
||||
<string name="gridfinder_status_unknown">位置不可用</string>
|
||||
<string name="gridfinder_status_waiting">正在获取定位…</string>
|
||||
<string name="gridfinder_status_blocked">需要定位权限</string>
|
||||
<string name="gridfinder_status_disabled">本机定位服务未开启</string>
|
||||
<string name="gridfinder_status_confirmed">已由 %1$d 次连续满足 20 英尺规则的定位确认</string>
|
||||
<string name="gridfinder_status_unconfirmed">尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次</string>
|
||||
<string name="gridfinder_grant_permission">授予定位权限</string>
|
||||
<string name="gridfinder_card_location">位置</string>
|
||||
<string name="gridfinder_card_corner">最近网格点引导</string>
|
||||
<string name="gridfinder_card_line">最近网格线引导</string>
|
||||
<string name="gridfinder_card_lines">两条边界线</string>
|
||||
<string name="gridfinder_card_map">近距离网格图</string>
|
||||
<string name="gridfinder_card_fix">定位质量</string>
|
||||
<string name="gridfinder_card_compass">罗盘</string>
|
||||
<string name="gridfinder_label_claimable">此处可计分的网格</string>
|
||||
<string name="gridfinder_label_grid4">网格 (4)</string>
|
||||
<string name="gridfinder_label_grid6">网格 (6)</string>
|
||||
<string name="gridfinder_label_grid8">网格 (8)</string>
|
||||
<string name="gridfinder_label_latitude">纬度</string>
|
||||
<string name="gridfinder_label_longitude">经度</string>
|
||||
<string name="gridfinder_label_altitude">海拔</string>
|
||||
<string name="gridfinder_label_accuracy">水平精度</string>
|
||||
<string name="gridfinder_label_fix_age">定位时刻</string>
|
||||
<string name="gridfinder_label_provider">定位来源</string>
|
||||
<string name="gridfinder_value_provider_on">已开启</string>
|
||||
<string name="gridfinder_value_provider_off">未开启</string>
|
||||
<string name="gridfinder_label_distance">距离</string>
|
||||
<string name="gridfinder_label_bearing">方位角</string>
|
||||
<string name="gridfinder_label_direction">方向</string>
|
||||
<string name="gridfinder_label_grids">网格</string>
|
||||
<string name="gridfinder_label_lat_line">纬线</string>
|
||||
<string name="gridfinder_label_lon_line">经线</string>
|
||||
<string name="gridfinder_line_value">位于%2$s方向 %1$s</string>
|
||||
<string name="gridfinder_line_latitude">这是纬度边界,东西走向</string>
|
||||
<string name="gridfinder_line_longitude">这是经度边界,南北走向</string>
|
||||
<string name="gridfinder_label_heading">朝向</string>
|
||||
<string name="gridfinder_label_compass_accuracy">罗盘精度</string>
|
||||
<string name="gridfinder_action_set_station">设为我的台址</string>
|
||||
<string name="gridfinder_action_station_saved">台址已更新 —— 过境预报将使用这个位置</string>
|
||||
<string name="gridfinder_action_calibrate">校准罗盘</string>
|
||||
<string name="gridfinder_map_legend">中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向</string>
|
||||
<string name="gridfinder_compass_disabled_hint">设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">本机没有磁力传感器 —— 请按方位角数字导航</string>
|
||||
<string name="gridfinder_compass_hint">请平持手机,箭头指向应走的方向</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 英尺规则:%1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">北</string>
|
||||
<string name="gridfinder_direction_south">南</string>
|
||||
<string name="gridfinder_direction_east">东</string>
|
||||
<string name="gridfinder_direction_west">西</string>
|
||||
<string name="gridfinder_action_set_lotw_station">设为 LoTW 上传台址(%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Commmon -->
|
||||
<string name="app_name" translatable="false">Look4Sat</string>
|
||||
<string name="app_name" translatable="false">Look4Sat-BA7OPF</string>
|
||||
<string name="btn_accept">Accept</string>
|
||||
<string name="btn_cancel">Cancel</string>
|
||||
<string name="empty_list_message">No data to show</string>
|
||||
@@ -51,8 +51,6 @@
|
||||
<string name="pass_welcome_message">
|
||||
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">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>
|
||||
@@ -162,18 +160,16 @@
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_donate_title">Donate</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat-BA7OPF v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://github.com/atsunatsu/Look4Sat/releases</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/atsunatsu/Look4Sat</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||
<string name="prefs_privacy_title">Privacy Policy</string>
|
||||
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||
<string name="prefs_upstream_title" translatable="false">Upstream Project</string>
|
||||
<string name="prefs_upstream_url" translatable="false">https://github.com/rt-bishop/Look4Sat</string>
|
||||
|
||||
<string name="prefs_updated_title">Updated: %s</string>
|
||||
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||
@@ -334,6 +330,7 @@
|
||||
<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_light_theme">Light theme</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>
|
||||
|
||||
@@ -342,7 +339,20 @@
|
||||
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
|
||||
<string name="prefs_highlight_high">High > %1$d°</string>
|
||||
|
||||
<string name="prefs_outro_title">I would like to say thanks to</string>
|
||||
<string name="prefs_outro_title">Credits & Notices</string>
|
||||
<string name="prefs_project_wiki">Project Wiki</string>
|
||||
<string name="project_wiki_intro">Look4Sat-BA7OPF is a customized fork of Look4Sat. All upstream features, plus:</string>
|
||||
<string name="project_wiki_section_logbook">Satellite Logbook & LoTW</string>
|
||||
<string name="project_wiki_logbook_1">Satellite logbook — one-tap logging of callsign, time, frequency, mode, report and grid.</string>
|
||||
<string name="project_wiki_logbook_2">Direct LoTW upload — import your ARRL certificate and upload logs to LoTW right from the app, no PC needed.</string>
|
||||
<string name="project_wiki_logbook_3">Confirmation sync — one-tap sync of LoTW confirmations, marked as uploaded/confirmed. Editing an uploaded entry creates a new one, leaving the original untouched.</string>
|
||||
<string name="project_wiki_logbook_how">How: tap Record after a QSO → Upload on the log page → Sync in Settings.</string>
|
||||
<string name="project_wiki_section_mutual">Mutual Pass Matching</string>
|
||||
<string name="project_wiki_mutual_1">Enter your QTH and a friend\'s to find passes visible to both; tap a pass to open the radar.</string>
|
||||
<string name="project_wiki_mutual_how">How: Match tab → set both stations → Query.</string>
|
||||
<string name="project_wiki_section_grids">Worked-Grid Map</string>
|
||||
<string name="project_wiki_grids_1">Grids synced from LoTW/Wavelog shown in green, with award boundaries and first-QSO callsign labels; tap a grid to see its QSOs and match a friend.</string>
|
||||
<string name="project_wiki_grids_how">How: enable Grid mode on the map → sync LoTW/Wavelog in Settings.</string>
|
||||
<string name="prefs_outro_thanks" translatable="false">
|
||||
* Look4Sat users and contributors!
|
||||
\n* David A. B. Johnson (predict4java)
|
||||
@@ -355,7 +365,9 @@
|
||||
\n* BH2VSQ BG5JSU (TianDiTu rendering support)
|
||||
\n* BG5JSU (LoTW upload support)
|
||||
</string>
|
||||
<string name="prefs_outro_license">The app comes with no warranty</string>
|
||||
<string name="prefs_outro_notice">Look4Sat-BA7OPF is an unofficial modified build based on Look4Sat (GPL-3.0), independently maintained by BA7OPF. It is not affiliated with or endorsed by the original Look4Sat project.
|
||||
Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="prefs_outro_license">Licensed under the GNU General Public License v3 · The app comes with no warranty</string>
|
||||
|
||||
<!-- Mutual match page -->
|
||||
<string name="mutual_title">Pass Match</string>
|
||||
@@ -404,4 +416,89 @@
|
||||
<string name="update_check_download_error">Download failed. Try again.</string>
|
||||
<string name="update_check_no_apk">No APK attached to this release.</string>
|
||||
|
||||
<!-- QSO edit dialog -->
|
||||
<string name="qso_edit_title">Edit QSO</string>
|
||||
<string name="qso_edit_uploaded_notice">This QSO was already uploaded or confirmed. Saving keeps the original record unchanged and creates a new one.</string>
|
||||
<string name="qso_edit_callsign">Callsign</string>
|
||||
<string name="qso_edit_date">Date (UTC)</string>
|
||||
<string name="qso_edit_time">Time (UTC)</string>
|
||||
<string name="qso_edit_tx">Uplink (MHz)</string>
|
||||
<string name="qso_edit_rx">Downlink (MHz)</string>
|
||||
<string name="qso_edit_band">Band: %1$s</string>
|
||||
<string name="qso_edit_mode">Mode</string>
|
||||
<string name="qso_edit_satellite">Satellite</string>
|
||||
<string name="qso_edit_hint_satellite">LoTW only accepts its official satellite name</string>
|
||||
<string name="qso_edit_sent">Sent RST</string>
|
||||
<string name="qso_edit_received">Received RST</string>
|
||||
<string name="qso_edit_grid">Opposite grid</string>
|
||||
<string name="qso_edit_comment">Comment</string>
|
||||
<string name="qso_edit_save">Save</string>
|
||||
<string name="qso_edit_cancel">Cancel</string>
|
||||
<string name="qso_edit_err_callsign">Callsign is required</string>
|
||||
<string name="qso_edit_err_datetime">Invalid date/time — use yyyy-MM-dd and HH:mm:ss (UTC)</string>
|
||||
<string name="qso_edit_err_frequency">Invalid frequency — enter MHz, e.g. 145.850</string>
|
||||
<string name="qso_edit_err_satellite">Not an ARRL satellite name — pick one from the list</string>
|
||||
<string name="qso_edit_satellite_pick">Pick</string>
|
||||
<string name="qso_edit_satellite_pick_title">ARRL satellite name</string>
|
||||
<string name="qso_edit_satellite_search">Search name or catalogue alias</string>
|
||||
<string name="qso_edit_satellite_official">Saved and signed as %1$s</string>
|
||||
<string name="qso_edit_warn_no_freq">No uplink frequency: LoTW requires a band, so this QSO cannot be uploaded yet</string>
|
||||
|
||||
<!-- Grid Finder (VUCC grid line / corner field guide) -->
|
||||
<string name="gridfinder_title">Grid Finder</string>
|
||||
<string name="gridfinder_status_corner">ON GRID POINT</string>
|
||||
<string name="gridfinder_status_line">ON GRID LINE</string>
|
||||
<string name="gridfinder_status_inside">INSIDE GRID</string>
|
||||
<string name="gridfinder_status_unknown">Position unavailable</string>
|
||||
<string name="gridfinder_status_waiting">Waiting for a fix…</string>
|
||||
<string name="gridfinder_status_blocked">Location permission required</string>
|
||||
<string name="gridfinder_status_disabled">Location services are off on this device</string>
|
||||
<string name="gridfinder_status_confirmed">Confirmed by %1$d consecutive fixes inside the 20 ft rule</string>
|
||||
<string name="gridfinder_status_unconfirmed">Not confirmed yet: %1$d of %2$d consecutive fixes good to 6.1 m</string>
|
||||
<string name="gridfinder_grant_permission">Grant location access</string>
|
||||
<string name="gridfinder_card_location">Location</string>
|
||||
<string name="gridfinder_card_corner">Guide to the nearest grid point</string>
|
||||
<string name="gridfinder_card_line">Guide to the nearest grid line</string>
|
||||
<string name="gridfinder_card_lines">Both boundary lines</string>
|
||||
<string name="gridfinder_card_map">Close-up grid map</string>
|
||||
<string name="gridfinder_card_fix">Fix quality</string>
|
||||
<string name="gridfinder_card_compass">Compass</string>
|
||||
<string name="gridfinder_label_claimable">VUCC grids here</string>
|
||||
<string name="gridfinder_label_grid4">Grid (4)</string>
|
||||
<string name="gridfinder_label_grid6">Grid (6)</string>
|
||||
<string name="gridfinder_label_grid8">Grid (8)</string>
|
||||
<string name="gridfinder_label_latitude">Latitude</string>
|
||||
<string name="gridfinder_label_longitude">Longitude</string>
|
||||
<string name="gridfinder_label_altitude">Altitude</string>
|
||||
<string name="gridfinder_label_accuracy">Horizontal accuracy</string>
|
||||
<string name="gridfinder_label_fix_age">Fix age</string>
|
||||
<string name="gridfinder_label_provider">Location provider</string>
|
||||
<string name="gridfinder_value_provider_on">On</string>
|
||||
<string name="gridfinder_value_provider_off">Off</string>
|
||||
<string name="gridfinder_label_distance">Distance</string>
|
||||
<string name="gridfinder_label_bearing">Bearing</string>
|
||||
<string name="gridfinder_label_direction">Direction</string>
|
||||
<string name="gridfinder_label_grids">Grids</string>
|
||||
<string name="gridfinder_label_lat_line">Latitude line</string>
|
||||
<string name="gridfinder_label_lon_line">Longitude line</string>
|
||||
<string name="gridfinder_line_value">%1$s to the %2$s</string>
|
||||
<string name="gridfinder_line_latitude">This is the latitude boundary, which runs east to west</string>
|
||||
<string name="gridfinder_line_longitude">This is the longitude boundary, which runs north to south</string>
|
||||
<string name="gridfinder_label_heading">Heading</string>
|
||||
<string name="gridfinder_label_compass_accuracy">Compass accuracy</string>
|
||||
<string name="gridfinder_action_set_station">Set as my station</string>
|
||||
<string name="gridfinder_action_station_saved">Station position updated — pass predictions now use this fix</string>
|
||||
<string name="gridfinder_action_calibrate">Calibrate compass</string>
|
||||
<string name="gridfinder_map_legend">The centre dot is you, dashed axes are north and east, the straight lines are the two grid boundaries, the green dot is the nearest grid point, the ring is the closest point of the nearest line and the red needle is your heading</string>
|
||||
<string name="gridfinder_compass_disabled_hint">The compass is switched off in Settings, so the dials stay north-up. The bearing numbers still guide you</string>
|
||||
<string name="gridfinder_compass_unavailable_hint">This device has no magnetic sensor — navigate with the bearing numbers</string>
|
||||
<string name="gridfinder_compass_hint">Hold the phone flat; the arrow points the way to walk</string>
|
||||
<string name="gridfinder_vucc_rule_note">VUCC 20 ft rule: %1$.1f m</string>
|
||||
<string name="gridfinder_direction_north">north</string>
|
||||
<string name="gridfinder_direction_south">south</string>
|
||||
<string name="gridfinder_direction_east">east</string>
|
||||
<string name="gridfinder_direction_west">west</string>
|
||||
<string name="gridfinder_action_set_lotw_station">Set as LoTW station (%1$s)</string>
|
||||
<string name="gridfinder_lotw_station_hint">Prefills the upload station grid — LoTW then records these squares (2 on a line, 4 on a corner). The certificate callsign stays unchanged.</string>
|
||||
|
||||
</resources>
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by 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/>.
|
||||
*/
|
||||
/*
|
||||
* CwDecodeScreen.kt — Look4Sat 的 CW 解码页面(Compose 外壳)。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.featurePlugin)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.rtbishop.look4sat.feature.gridfinder"
|
||||
testOptions {
|
||||
unitTests {
|
||||
// Robolectric needs real Android resources for Compose UI tests.
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.test.core)
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.navigation3)
|
||||
debugImplementation(libs.compose.debug.manifest)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Compass rose with a needle pointing along the true-north [bearingDegrees].
|
||||
* When [headingDegrees] is available the rose turns with the device, so the
|
||||
* needle is read against the top edge of the phone ("walk the way the arrow
|
||||
* points"); without a magnetometer the rose stays north-up.
|
||||
*/
|
||||
@Composable
|
||||
fun GridCompassDial(
|
||||
bearingDegrees: Double,
|
||||
headingDegrees: Float?,
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dialColor = MaterialTheme.colorScheme.outline
|
||||
// North is a longer, brighter tick; red is reserved for the heading needle
|
||||
// in the proximity map, so the legend stays unambiguous.
|
||||
val northColor = MaterialTheme.colorScheme.onSurface
|
||||
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val measurer = rememberTextMeasurer()
|
||||
Canvas(modifier = modifier) {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
|
||||
if (radius <= 0f) return@Canvas
|
||||
val roseRotation = headingDegrees?.let { -it } ?: 0f
|
||||
|
||||
drawCircle(color = dialColor, radius = radius, center = center, style = Stroke(1.5.dp.toPx()))
|
||||
drawCircle(
|
||||
color = dialColor.copy(alpha = 0.35f),
|
||||
radius = radius * 0.62f,
|
||||
center = center,
|
||||
style = Stroke(1.dp.toPx())
|
||||
)
|
||||
|
||||
for (degrees in 0 until 360 step 30) {
|
||||
val angle = Math.toRadians((degrees + roseRotation).toDouble())
|
||||
val isNorth = degrees == 0
|
||||
val innerRadius = radius * if (isNorth) 0.70f else 0.86f
|
||||
val start = center.at(innerRadius, angle)
|
||||
val end = center.at(radius, angle)
|
||||
drawLine(
|
||||
color = if (isNorth) northColor else dialColor,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = if (isNorth) 3.dp.toPx() else 1.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
listOf(0 to "N", 90 to "E", 180 to "S", 270 to "W").forEach { (degrees, label) ->
|
||||
val angle = Math.toRadians((degrees + roseRotation).toDouble())
|
||||
val position = center.at(radius * 0.50f, angle)
|
||||
val layout = measurer.measure(
|
||||
AnnotatedString(label),
|
||||
TextStyle(fontSize = 12.sp, color = if (degrees == 0) northColor else labelColor)
|
||||
)
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
topLeft = Offset(
|
||||
position.x - layout.size.width / 2f,
|
||||
position.y - layout.size.height / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val needleAngle = Math.toRadians(bearingDegrees - (headingDegrees ?: 0f))
|
||||
val tip = center.at(radius * 0.88f, needleAngle)
|
||||
drawLine(
|
||||
color = tint,
|
||||
start = center,
|
||||
end = tip,
|
||||
strokeWidth = 3.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
drawCircle(color = tint, radius = 4.dp.toPx(), center = tip)
|
||||
drawCircle(color = tint, radius = 2.5.dp.toPx(), center = center)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* North-up proximity sketch around the fix: dashed lines are the nearest grid
|
||||
* boundaries, the green dot is the grid corner, the ring marks the closest
|
||||
* point of the nearest line, and the amber needle is the device heading.
|
||||
*/
|
||||
@Composable
|
||||
fun GridProximityMap(
|
||||
geometry: GridGeometry,
|
||||
headingDegrees: Float?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val outline = MaterialTheme.colorScheme.outline
|
||||
val gridColor = MaterialTheme.colorScheme.primary
|
||||
val lineColor = MaterialTheme.colorScheme.tertiary
|
||||
val cornerColor = MaterialTheme.colorScheme.secondary
|
||||
val headingColor = MaterialTheme.colorScheme.error
|
||||
val youColor = MaterialTheme.colorScheme.onSurface
|
||||
val dash = PathEffect.dashPathEffect(floatArrayOf(14f, 12f))
|
||||
Canvas(modifier = modifier) {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
|
||||
if (radius <= 0f) return@Canvas
|
||||
val dNorth = when (geometry.latLineDirection) {
|
||||
GridBoundaryDirection.NORTH -> geometry.latLineMeters
|
||||
else -> -geometry.latLineMeters
|
||||
}
|
||||
val dEast = when (geometry.lonLineDirection) {
|
||||
GridBoundaryDirection.EAST -> geometry.lonLineMeters
|
||||
else -> -geometry.lonLineMeters
|
||||
}
|
||||
val span = max(max(abs(dNorth), abs(dEast)), geometry.cornerMeters)
|
||||
val scale = radius / max(span * 1.15, 15.0).toFloat()
|
||||
|
||||
drawCircle(color = outline.copy(alpha = 0.35f), radius = radius, center = center, style = Stroke(1.dp.toPx()))
|
||||
drawLine(outline, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.dp.toPx(), pathEffect = dash)
|
||||
drawLine(outline, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.dp.toPx(), pathEffect = dash)
|
||||
|
||||
val latY = center.y - (dNorth * scale).toFloat()
|
||||
val lonX = center.x + (dEast * scale).toFloat()
|
||||
drawLine(lineColor, Offset(center.x - radius, latY), Offset(center.x + radius, latY), 2.dp.toPx())
|
||||
drawLine(lineColor, Offset(lonX, center.y - radius), Offset(lonX, center.y + radius), 2.dp.toPx())
|
||||
|
||||
val corner = Offset(lonX, latY)
|
||||
drawCircle(color = cornerColor, radius = 5.dp.toPx(), center = corner)
|
||||
|
||||
val nearestPoint = if (geometry.nearestLineIsLatitude) {
|
||||
Offset(center.x, latY)
|
||||
} else {
|
||||
Offset(lonX, center.y)
|
||||
}
|
||||
drawCircle(color = gridColor, radius = 5.dp.toPx(), center = nearestPoint, style = Stroke(2.dp.toPx()))
|
||||
|
||||
headingDegrees?.let { heading ->
|
||||
val angle = Math.toRadians(heading.toDouble())
|
||||
val tip = center.at(radius * 0.92f, angle)
|
||||
drawLine(headingColor, center, tip, 2.dp.toPx(), cap = StrokeCap.Round)
|
||||
drawCircle(headingColor, 3.dp.toPx(), tip)
|
||||
}
|
||||
|
||||
drawCircle(color = youColor, radius = 3.dp.toPx(), center = center)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.at(radius: Float, angleRadians: Double): Offset {
|
||||
return Offset(
|
||||
x + (radius * sin(angleRadians)).toFloat(),
|
||||
y - (radius * cos(angleRadians)).toFloat()
|
||||
)
|
||||
}
|
||||
+676
@@ -0,0 +1,676 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
import com.rtbishop.look4sat.core.domain.utility.VUCC_BOUNDARY_TOLERANCE_METERS
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
|
||||
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Full-screen field tool: walks the operator onto a VUCC grid line or corner.
|
||||
* Reached from the map's grid view and from Settings; the live fix it uses
|
||||
* never moves the station unless the operator taps "Set as my station".
|
||||
*/
|
||||
@Composable
|
||||
fun GridFinderDestination(
|
||||
navigateUp: () -> Unit,
|
||||
onOpenLoTWStation: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: GridFinderViewModel = viewModel(factory = GridFinderViewModel.factory(container))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> viewModel.onAction(GridFinderAction.LocationPermissionResult(granted)) }
|
||||
val requestLocationPermission = { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
|
||||
var hasAskedPermission by rememberSaveable { mutableStateOf(false) }
|
||||
// "Set as LoTW station": hand the grids the operator currently stands on
|
||||
// (1 inside, 2 on a line, 4 on a corner) to the upload station page.
|
||||
val openLoTWStation: () -> Unit = {
|
||||
val grids = uiState.claimableGrids
|
||||
if (grids.isNotEmpty()) {
|
||||
container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||
onOpenLoTWStation()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(viewModel) {
|
||||
viewModel.onAction(GridFinderAction.StartUpdates)
|
||||
onDispose { viewModel.onAction(GridFinderAction.StopUpdates) }
|
||||
}
|
||||
// Ask once automatically on the first visit; after a refusal the banner
|
||||
// offers the request again, so returning to the screen is not a prompt loop.
|
||||
LaunchedEffect(uiState.permissionGranted) {
|
||||
if (!uiState.permissionGranted && !hasAskedPermission) {
|
||||
hasAskedPermission = true
|
||||
requestLocationPermission()
|
||||
}
|
||||
}
|
||||
|
||||
GridFinderScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
navigateUp = navigateUp,
|
||||
openLoTWStation = openLoTWStation,
|
||||
requestLocationPermission = requestLocationPermission
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridFinderScreen(
|
||||
uiState: GridFinderState,
|
||||
onAction: (GridFinderAction) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
openLoTWStation: () -> Unit,
|
||||
requestLocationPermission: () -> Unit
|
||||
) {
|
||||
// Same top-bar → content rhythm as the settings page: ScreenColumn puts a
|
||||
// small gap between the header row and the rounded surfaceContainer panel.
|
||||
ScreenColumn(
|
||||
topBar = {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_title),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(top = 0.dp, bottom = 24.dp)
|
||||
) {
|
||||
item { StandingCard(uiState, requestLocationPermission) }
|
||||
uiState.geometry?.let { geometry ->
|
||||
item { LocationCard(uiState, onAction, openLoTWStation) }
|
||||
item {
|
||||
GuideCard(
|
||||
title = stringResource(R.string.gridfinder_card_corner),
|
||||
bearing = geometry.cornerBearingDegrees,
|
||||
distanceMeters = geometry.cornerMeters,
|
||||
direction = null,
|
||||
grids = geometry.cornerGrids,
|
||||
tint = ElevationHighColor,
|
||||
heading = uiState.headingDegrees
|
||||
)
|
||||
}
|
||||
item {
|
||||
GuideCard(
|
||||
title = stringResource(R.string.gridfinder_card_line),
|
||||
bearing = geometry.nearestLineBearingDegrees,
|
||||
distanceMeters = geometry.nearestLineMeters,
|
||||
direction = null,
|
||||
grids = geometry.nearestLineGrids,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
heading = uiState.headingDegrees,
|
||||
extra = stringResource(
|
||||
if (geometry.nearestLineIsLatitude) {
|
||||
R.string.gridfinder_line_latitude
|
||||
} else {
|
||||
R.string.gridfinder_line_longitude
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
item { BoundaryLinesCard(geometry) }
|
||||
item { ProximityCard(geometry, uiState.headingDegrees) }
|
||||
}
|
||||
item { FixQualityCard(uiState) }
|
||||
item { CompassCard(uiState, onAction) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StandingCard(uiState: GridFinderState, requestLocationPermission: () -> Unit) {
|
||||
val grids = uiState.claimableGrids.joinToString(" · ")
|
||||
val (headline, tint) = when {
|
||||
!uiState.permissionGranted -> stringResource(R.string.gridfinder_status_blocked) to ElevationLowColor
|
||||
!uiState.providerEnabled -> stringResource(R.string.gridfinder_status_disabled) to ElevationLowColor
|
||||
uiState.fix == null -> stringResource(R.string.gridfinder_status_waiting) to MaterialTheme.colorScheme.onSurface
|
||||
uiState.status == null -> stringResource(R.string.gridfinder_status_unknown) to ElevationLowColor
|
||||
uiState.status == GridFixStatus.ON_GRID_CORNER -> stringResource(R.string.gridfinder_status_corner) to ElevationHighColor
|
||||
uiState.status == GridFixStatus.ON_GRID_LINE -> stringResource(R.string.gridfinder_status_line) to ElevationHighColor
|
||||
else -> stringResource(R.string.gridfinder_status_inside) to MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = headline,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = tint,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (grids.isNotEmpty()) {
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_claimable),
|
||||
value = grids,
|
||||
valueColor = when (uiState.status) {
|
||||
GridFixStatus.ON_GRID_CORNER, GridFixStatus.ON_GRID_LINE -> ElevationHighColor
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
when {
|
||||
uiState.fix == null -> Unit
|
||||
uiState.status == GridFixStatus.INSIDE_GRID -> Text(
|
||||
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
uiState.isStandingConfirmed -> Text(
|
||||
text = stringResource(R.string.gridfinder_status_confirmed, PRECISE_FIXES_REQUIRED),
|
||||
fontSize = 12.sp,
|
||||
color = ElevationHighColor
|
||||
)
|
||||
else -> Text(
|
||||
text = stringResource(
|
||||
R.string.gridfinder_status_unconfirmed,
|
||||
uiState.preciseFixesInARow,
|
||||
PRECISE_FIXES_REQUIRED
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (!uiState.permissionGranted) {
|
||||
Button(onClick = requestLocationPermission) {
|
||||
Text(text = stringResource(R.string.gridfinder_grant_permission))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationCard(
|
||||
uiState: GridFinderState,
|
||||
onAction: (GridFinderAction) -> Unit,
|
||||
openLoTWStation: () -> Unit
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_location),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
val geometry = uiState.geometry
|
||||
val fix = uiState.fix
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid4), geometry?.grid4 ?: "-")
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid6), geometry?.grid6 ?: "-")
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grid8), geometry?.grid8 ?: "-")
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_latitude),
|
||||
value = fix?.let { String.format("%+.5f°", it.latitude) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_longitude),
|
||||
value = fix?.let { String.format("%+.5f°", it.longitude) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_altitude),
|
||||
value = fix?.let { String.format("%.0f m", it.altitudeMeters) } ?: "-"
|
||||
)
|
||||
if (uiState.fix != null) {
|
||||
Button(
|
||||
onClick = { onAction(GridFinderAction.SetAsStation) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(R.string.gridfinder_action_set_station))
|
||||
}
|
||||
if (uiState.isStationSaved) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_action_station_saved),
|
||||
fontSize = 12.sp,
|
||||
color = ElevationHighColor
|
||||
)
|
||||
}
|
||||
val claimable = uiState.claimableGrids
|
||||
if (claimable.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Button(
|
||||
onClick = openLoTWStation,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.gridfinder_action_set_lotw_station,
|
||||
claimable.joinToString(",")
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_lotw_station_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuideCard(
|
||||
title: String,
|
||||
bearing: Double,
|
||||
distanceMeters: Double,
|
||||
direction: GridBoundaryDirection?,
|
||||
grids: List<String>,
|
||||
tint: Color,
|
||||
heading: Float?,
|
||||
extra: String? = null
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
GridCompassDial(
|
||||
bearingDegrees = bearing,
|
||||
headingDegrees = heading,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(108.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_distance),
|
||||
value = formatDistance(distanceMeters),
|
||||
valueColor = tint
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_bearing),
|
||||
value = String.format("%.0f° %s", bearing, cardinal(bearing))
|
||||
)
|
||||
if (direction != null) {
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_direction), directionLabel(direction))
|
||||
}
|
||||
if (grids.isNotEmpty()) {
|
||||
GridMetricRow(stringResource(R.string.gridfinder_label_grids), grids.joinToString(" / "))
|
||||
}
|
||||
extra?.let {
|
||||
Text(text = it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoundaryLinesCard(geometry: GridGeometry) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_lines),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_lat_line),
|
||||
value = stringResource(
|
||||
R.string.gridfinder_line_value,
|
||||
formatDistance(geometry.latLineMeters),
|
||||
directionLabel(geometry.latLineDirection)
|
||||
)
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_lon_line),
|
||||
value = stringResource(
|
||||
R.string.gridfinder_line_value,
|
||||
formatDistance(geometry.lonLineMeters),
|
||||
directionLabel(geometry.lonLineDirection)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProximityCard(
|
||||
geometry: GridGeometry,
|
||||
heading: Float?
|
||||
) {
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_map),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridProximityMap(
|
||||
geometry = geometry,
|
||||
headingDegrees = heading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_map_legend),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FixQualityCard(uiState: GridFinderState) {
|
||||
val fix = uiState.fix
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_fix),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_accuracy),
|
||||
value = fix?.let { String.format("± %.1f m", it.accuracyMeters) } ?: "-",
|
||||
valueColor = fix?.let {
|
||||
if (it.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) ElevationHighColor else ElevationLowColor
|
||||
}
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_fix_age),
|
||||
value = fix?.let { formatAge(it.epochMs) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_provider),
|
||||
value = stringResource(
|
||||
if (uiState.providerEnabled) {
|
||||
R.string.gridfinder_value_provider_on
|
||||
} else {
|
||||
R.string.gridfinder_value_provider_off
|
||||
}
|
||||
)
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { uiState.preciseFixesInARow.toFloat() / PRECISE_FIXES_REQUIRED },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompassCard(uiState: GridFinderState, onAction: (GridFinderAction) -> Unit) {
|
||||
var showCalibration by rememberSaveable { mutableStateOf(false) }
|
||||
GridCard(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_card_compass),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_heading),
|
||||
value = uiState.headingDegrees?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-"
|
||||
)
|
||||
GridMetricRow(
|
||||
label = stringResource(R.string.gridfinder_label_compass_accuracy),
|
||||
value = stringResource(compassAccuracyRes(uiState.compassAccuracy))
|
||||
)
|
||||
if (uiState.isCompassEnabled) {
|
||||
Button(
|
||||
onClick = { showCalibration = true },
|
||||
enabled = uiState.compassAccuracy != CompassAccuracy.UNAVAILABLE,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(R.string.gridfinder_action_calibrate))
|
||||
}
|
||||
}
|
||||
if (!uiState.isCompassEnabled) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_disabled_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else if (uiState.compassAccuracy == CompassAccuracy.UNAVAILABLE) {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_unavailable_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.gridfinder_compass_hint),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showCalibration) {
|
||||
GridCompassCalibrationDialog(
|
||||
accuracy = uiState.compassAccuracy,
|
||||
heading = uiState.headingDegrees,
|
||||
onSaveOffset = { offset -> onAction(GridFinderAction.SetCompassOffset(offset)) },
|
||||
onDismiss = { showCalibration = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact figure-eight calibration dialog. It mirrors the settings screen's
|
||||
* compass calibration but is self-contained in this module; the trim is saved
|
||||
* through the same setting the radar page uses, so both stay in sync.
|
||||
*/
|
||||
@Composable
|
||||
private fun GridCompassCalibrationDialog(
|
||||
accuracy: CompassAccuracy,
|
||||
heading: Float?,
|
||||
onSaveOffset: (Float) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var offsetText by rememberSaveable { mutableStateOf("") }
|
||||
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
|
||||
val progress = when (accuracy) {
|
||||
CompassAccuracy.HIGH -> 1f
|
||||
CompassAccuracy.MEDIUM -> 0.66f
|
||||
CompassAccuracy.LOW -> 0.33f
|
||||
else -> 0f
|
||||
}
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_compass_calibration_title),
|
||||
onDismissRequest = onDismiss,
|
||||
onCancel = onDismiss,
|
||||
acceptEnabled = parsedOffset != null,
|
||||
onAccept = {
|
||||
parsedOffset?.let(onSaveOffset)
|
||||
onDismiss()
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = padding)
|
||||
) {
|
||||
Text(
|
||||
text = "∞",
|
||||
fontSize = 84.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
lineHeight = 84.sp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_calibration_instruction),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_accuracy, stringResource(compassAccuracyRes(accuracy))),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = heading?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-",
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_compass_offset_support),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = offsetText,
|
||||
onValueChange = { offsetText = it },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
label = { Text(text = stringResource(R.string.prefs_compass_offset)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridMetricRow(label: String, value: String, valueColor: Color? = null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = valueColor ?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun directionLabel(direction: GridBoundaryDirection): String = stringResource(
|
||||
when (direction) {
|
||||
GridBoundaryDirection.NORTH -> R.string.gridfinder_direction_north
|
||||
GridBoundaryDirection.SOUTH -> R.string.gridfinder_direction_south
|
||||
GridBoundaryDirection.EAST -> R.string.gridfinder_direction_east
|
||||
GridBoundaryDirection.WEST -> R.string.gridfinder_direction_west
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun compassAccuracyRes(accuracy: CompassAccuracy): Int = 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
|
||||
}
|
||||
|
||||
private fun formatDistance(meters: Double): String {
|
||||
return if (abs(meters) < 1000.0) {
|
||||
String.format("%.1f m", meters)
|
||||
} else {
|
||||
String.format("%.2f km", meters / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatAge(epochMs: Long): String {
|
||||
val seconds = ((System.currentTimeMillis() - epochMs) / 1000L).coerceAtLeast(0L)
|
||||
return if (seconds < 60L) "${seconds}s" else "${seconds / 60L}min"
|
||||
}
|
||||
|
||||
private fun cardinal(bearingDegrees: Double): String {
|
||||
val normalized = (bearingDegrees % 360.0 + 360.0) % 360.0
|
||||
val names = listOf("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N")
|
||||
return names[((normalized + 22.5) / 45.0).toInt().coerceIn(0, 8)]
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
|
||||
|
||||
/**
|
||||
* How many consecutive precise fixes are required before a grid-line / corner
|
||||
* claim is presented as confirmed. A single fix is not trustworthy enough to
|
||||
* tell a walker that they are standing on a 6 m boundary.
|
||||
*/
|
||||
const val PRECISE_FIXES_REQUIRED = 3
|
||||
|
||||
/** A fix must be at least this good before its grid line/corner claim counts. */
|
||||
const val VUCC_FIX_ACCURACY_LIMIT_METERS = 6.1f
|
||||
|
||||
/** Outcome of pushing one fix through the 20 ft precision gate. */
|
||||
data class FixGateResult(val streak: Int, val isConfirmed: Boolean)
|
||||
|
||||
/**
|
||||
* Counts consecutive fixes that are both good to the 20 ft rule and inside a
|
||||
* grid-square boundary. Any one bad fix resets the counter, so a claim is only
|
||||
* confirmed once the walker has held it for several readings.
|
||||
*/
|
||||
fun nextFixGate(previousStreak: Int, fix: LocationFix): FixGateResult {
|
||||
val streak = if (fix.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) {
|
||||
(previousStreak + 1).coerceAtMost(PRECISE_FIXES_REQUIRED)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return FixGateResult(streak = streak, isConfirmed = streak >= PRECISE_FIXES_REQUIRED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid Finder screen state. [status] and [claimableGrids] are always derived
|
||||
* from the latest fix's distance to the boundary; [isStandingConfirmed] says
|
||||
* whether that reading is backed by [PRECISE_FIXES_REQUIRED] consecutive fixes
|
||||
* that are themselves good to the 20 ft rule.
|
||||
*/
|
||||
data class GridFinderState(
|
||||
val permissionGranted: Boolean = false,
|
||||
val providerEnabled: Boolean = true,
|
||||
val providerReady: Boolean = true,
|
||||
val fix: LocationFix? = null,
|
||||
val geometry: GridGeometry? = null,
|
||||
val claimableGrids: List<String> = emptyList(),
|
||||
val status: GridFixStatus? = null,
|
||||
val preciseFixesInARow: Int = 0,
|
||||
val isStandingConfirmed: Boolean = false,
|
||||
val headingDegrees: Float? = null,
|
||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNAVAILABLE,
|
||||
val isCompassEnabled: Boolean = false,
|
||||
val isStationSaved: Boolean = false
|
||||
) {
|
||||
val isWaitingForFix: Boolean get() = fix == null
|
||||
}
|
||||
|
||||
sealed interface GridFinderAction {
|
||||
/** Called when the screen appears: starts location and (if enabled) compass updates. */
|
||||
data object StartUpdates : GridFinderAction
|
||||
|
||||
/** Called when the screen goes away: releases both listeners. */
|
||||
data object StopUpdates : GridFinderAction
|
||||
|
||||
data class LocationPermissionResult(val granted: Boolean) : GridFinderAction
|
||||
|
||||
/** Copies the current fix into the station position that drives pass predictions. */
|
||||
data object SetAsStation : GridFinderAction
|
||||
|
||||
data object StationSavedShown : GridFinderAction
|
||||
|
||||
/** Saves the compass trim, shared with the radar page's calibration. */
|
||||
data class SetCompassOffset(val offsetDegrees: Float) : GridFinderAction
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
|
||||
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.LocationFix
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationState
|
||||
import com.rtbishop.look4sat.core.domain.utility.gridFixStatus
|
||||
import com.rtbishop.look4sat.core.domain.utility.gridGeometry
|
||||
import com.rtbishop.look4sat.core.domain.utility.vuccClaimableGrids
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Declination is only recomputed once the fix has moved this far (degrees). */
|
||||
private const val DECLINATION_REFRESH_DEGREES = 0.01
|
||||
|
||||
/**
|
||||
* Drives the Grid Finder: streams the live fix, turns it into grid-line /
|
||||
* grid-corner geometry, and gates the VUCC claim on [PRECISE_FIXES_REQUIRED]
|
||||
* consecutive fixes good to the 20 ft rule.
|
||||
*
|
||||
* The live fix never touches the station position - [GridFinderAction.SetAsStation]
|
||||
* is the only thing that writes it, and only when the operator asks.
|
||||
*/
|
||||
class GridFinderViewModel(
|
||||
private val settingsRepo: ISettingsRepo,
|
||||
private val locationRepo: ILocationRepo,
|
||||
private val sensorsRepo: ISensorsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GridFinderState())
|
||||
val uiState: StateFlow<GridFinderState> = _uiState.asStateFlow()
|
||||
|
||||
private var locationJob: Job? = null
|
||||
private var compassJob: Job? = null
|
||||
private var accuracyJob: Job? = null
|
||||
private var sensorsEnabledByUs = false
|
||||
private var declinationCache: Pair<GeoPos, Float>? = null
|
||||
private var lastSensorAzimuth: Float? = null
|
||||
|
||||
fun onAction(action: GridFinderAction) {
|
||||
when (action) {
|
||||
GridFinderAction.StartUpdates -> startUpdates()
|
||||
GridFinderAction.StopUpdates -> stopUpdates()
|
||||
is GridFinderAction.LocationPermissionResult -> startUpdates()
|
||||
GridFinderAction.SetAsStation -> setAsStation()
|
||||
GridFinderAction.StationSavedShown -> _uiState.update { it.copy(isStationSaved = false) }
|
||||
is GridFinderAction.SetCompassOffset -> setCompassOffset(action.offsetDegrees)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the compass trim shared with the radar page, then recomputes the
|
||||
* heading from the last raw sensor sample so the dial reacts immediately.
|
||||
*/
|
||||
private fun setCompassOffset(offsetDegrees: Float) {
|
||||
settingsRepo.updateOtherSettings { it.copy(compassOffsetDegrees = offsetDegrees) }
|
||||
lastSensorAzimuth?.let { azimuth ->
|
||||
_uiState.update { it.copy(headingDegrees = correctedHeading(azimuth)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdates() {
|
||||
val permissionGranted = locationRepo.hasPermission()
|
||||
_uiState.update { it.copy(permissionGranted = permissionGranted) }
|
||||
if (!permissionGranted) return
|
||||
locationRepo.startUpdates()
|
||||
collectLocation()
|
||||
collectCompass()
|
||||
}
|
||||
|
||||
private fun collectLocation() {
|
||||
if (locationJob != null) return
|
||||
locationJob = viewModelScope.launch {
|
||||
locationRepo.state.collect { state ->
|
||||
when (state) {
|
||||
is LocationState.Fix -> onFix(state.location)
|
||||
is LocationState.Waiting -> _uiState.update {
|
||||
it.copy(
|
||||
permissionGranted = state.permissionGranted,
|
||||
providerEnabled = state.providerEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectCompass() {
|
||||
val compassEnabled = settingsRepo.otherSettings.value.stateOfSensors
|
||||
_uiState.update {
|
||||
it.copy(isCompassEnabled = compassEnabled, compassAccuracy = sensorsRepo.compassAccuracy.value)
|
||||
}
|
||||
if (!compassEnabled || compassJob != null) return
|
||||
sensorsRepo.enableSensor()
|
||||
sensorsEnabledByUs = true
|
||||
accuracyJob = viewModelScope.launch {
|
||||
sensorsRepo.compassAccuracy.collect { accuracy ->
|
||||
_uiState.update { it.copy(compassAccuracy = accuracy) }
|
||||
}
|
||||
}
|
||||
compassJob = viewModelScope.launch {
|
||||
sensorsRepo.sensorData.collect { data ->
|
||||
lastSensorAzimuth = data.first
|
||||
_uiState.update { it.copy(headingDegrees = correctedHeading(data.first)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same convention as the radar page: sensor azimuth + magnetic declination + manual trim. */
|
||||
private fun correctedHeading(sensorAzimuth: Float): Float {
|
||||
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
|
||||
val azimuth = sensorAzimuth + magDeclination() + offset
|
||||
return (azimuth % 360f + 360f) % 360f
|
||||
}
|
||||
|
||||
private fun magDeclination(): Float {
|
||||
val position = _uiState.value.fix?.let { GeoPos(it.latitude, it.longitude, it.altitudeMeters) }
|
||||
?: settingsRepo.stationPosition.value
|
||||
val cached = declinationCache
|
||||
if (cached != null &&
|
||||
abs(cached.first.latitude - position.latitude) < DECLINATION_REFRESH_DEGREES &&
|
||||
abs(cached.first.longitude - position.longitude) < DECLINATION_REFRESH_DEGREES
|
||||
) {
|
||||
return cached.second
|
||||
}
|
||||
val declination = sensorsRepo.getMagDeclination(position)
|
||||
declinationCache = position to declination
|
||||
return declination
|
||||
}
|
||||
|
||||
private fun onFix(fix: LocationFix) {
|
||||
val geometry = gridGeometry(fix.latitude, fix.longitude)
|
||||
val claimable = vuccClaimableGrids(fix.latitude, fix.longitude)
|
||||
val gate = nextFixGate(_uiState.value.preciseFixesInARow, fix)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
permissionGranted = true,
|
||||
providerReady = geometry != null,
|
||||
fix = fix,
|
||||
geometry = geometry,
|
||||
claimableGrids = claimable,
|
||||
status = if (claimable.isEmpty()) null else gridFixStatus(claimable),
|
||||
preciseFixesInARow = gate.streak,
|
||||
isStandingConfirmed = gate.isConfirmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAsStation() {
|
||||
val fix = _uiState.value.fix ?: return
|
||||
val saved = settingsRepo.setStationPosition(fix.latitude, fix.longitude, fix.altitudeMeters)
|
||||
if (saved) _uiState.update { it.copy(isStationSaved = true) }
|
||||
}
|
||||
|
||||
private fun stopUpdates() {
|
||||
locationRepo.stopUpdates()
|
||||
locationJob?.cancel()
|
||||
locationJob = null
|
||||
compassJob?.cancel()
|
||||
compassJob = null
|
||||
accuracyJob?.cancel()
|
||||
accuracyJob = null
|
||||
if (sensorsEnabledByUs) {
|
||||
sensorsRepo.disableSensor()
|
||||
sensorsEnabledByUs = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
stopUpdates()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(container: IMainContainer) = viewModelFactory {
|
||||
initializer {
|
||||
GridFinderViewModel(
|
||||
settingsRepo = container.settingsRepo,
|
||||
locationRepo = container.locationRepo,
|
||||
sensorsRepo = container.provideSensorsRepo()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.gridfinder
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.LocationFix
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GridFinderGateTest {
|
||||
|
||||
@Test
|
||||
fun `fix within the 20 ft rule is precise enough`() {
|
||||
assertTrue(fix(accuracy = 6.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fix worse than the 20 ft rule is not precise enough`() {
|
||||
assertFalse(fix(accuracy = 12.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streak grows on good fixes and confirms on the third`() {
|
||||
val first = nextFixGate(0, fix(accuracy = 4.0f))
|
||||
assertEquals(1, first.streak)
|
||||
assertFalse(first.isConfirmed)
|
||||
|
||||
val second = nextFixGate(first.streak, fix(accuracy = 4.0f))
|
||||
assertEquals(2, second.streak)
|
||||
assertFalse(second.isConfirmed)
|
||||
|
||||
val third = nextFixGate(second.streak, fix(accuracy = 4.0f))
|
||||
assertEquals(PRECISE_FIXES_REQUIRED, third.streak)
|
||||
assertTrue(third.isConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `streak stays capped once confirmed`() {
|
||||
val capped = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 1.0f))
|
||||
assertEquals(PRECISE_FIXES_REQUIRED, capped.streak)
|
||||
assertTrue(capped.isConfirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one coarse fix resets a confirmed streak`() {
|
||||
val reset = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 25.0f))
|
||||
assertEquals(0, reset.streak)
|
||||
assertFalse(reset.isConfirmed)
|
||||
}
|
||||
|
||||
private fun fix(accuracy: Float) = LocationFix(
|
||||
latitude = 22.0,
|
||||
longitude = 114.0,
|
||||
altitudeMeters = 30.0,
|
||||
accuracyMeters = accuracy,
|
||||
epochMs = 0L
|
||||
)
|
||||
}
|
||||
@@ -139,6 +139,18 @@ class AwardBoundaryOverlay : Overlay() {
|
||||
color = Color.argb(90, 76, 217, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recolor the amber boundary/label paints for the active map theme (dark amber + white halo on
|
||||
* the light map, light amber + black halo on the dark map). The worked fill reads on both.
|
||||
*/
|
||||
fun applyTheme(isLightUi: Boolean) {
|
||||
val line = if (isLightUi) Color.argb(200, 113, 92, 12) else Color.argb(160, 255, 224, 130)
|
||||
val label = if (isLightUi) Color.argb(235, 30, 25, 5) else Color.argb(220, 255, 224, 130)
|
||||
linePaint.color = line
|
||||
labelPaint.color = label
|
||||
labelPaint.setShadowLayer(3f, 2f, 2f, if (isLightUi) Color.WHITE else Color.BLACK)
|
||||
}
|
||||
|
||||
/** Boundary regions to render; assigning recomputes culling bounds. */
|
||||
var regions: List<AwardRegion> = emptyList()
|
||||
set(value) {
|
||||
|
||||
@@ -89,6 +89,24 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
color = Color.argb(255, 76, 217, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recolor the amber line/label paints for the active map theme: dark amber with a white halo
|
||||
* on the light map, light amber with a black halo on the dark map. The worked / marked / roam /
|
||||
* selected colors stay as they are — they read on both backgrounds.
|
||||
*/
|
||||
fun applyTheme(isLightUi: Boolean) {
|
||||
val line = if (isLightUi) Color.argb(200, 113, 92, 12) else Color.argb(160, 255, 224, 130)
|
||||
val label = if (isLightUi) Color.argb(235, 30, 25, 5) else Color.argb(220, 255, 224, 130)
|
||||
val halo = if (isLightUi) Color.WHITE else Color.BLACK
|
||||
linePaint.color = line
|
||||
// ownLine stays the same color as the normal grid lines (only thicker).
|
||||
ownLinePaint.color = line
|
||||
labelPaint.color = label
|
||||
labelPaint.setShadowLayer(3f, 2f, 2f, halo)
|
||||
firstCallPaint.color = label
|
||||
firstCallPaint.setShadowLayer(3f, 2f, 2f, halo)
|
||||
}
|
||||
|
||||
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
@@ -116,8 +116,9 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
else -> {
|
||||
// Terminator crosses this column — find the crossing pixel by binary search
|
||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
||||
if (dotTop < 0) {
|
||||
val isNightAtTop = dotTop < 0
|
||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff, isNightAtTop)
|
||||
if (isNightAtTop) {
|
||||
// Night at top, day at bottom
|
||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
||||
canvas.drawRect(rect, nightPaint)
|
||||
@@ -134,7 +135,11 @@ class MapNightOverlay : Overlay() {
|
||||
|
||||
/**
|
||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
||||
*
|
||||
* [isNightAtTop] states which side [yTop] is on. Both polarities occur: the pole nearest
|
||||
* the sub-solar point is lit, so the terminator runs day-over-night while the sub-solar
|
||||
* latitude is positive and night-over-day once it turns negative after the September
|
||||
* equinox. Anchoring lo to the top pixel's side keeps the search valid either way.
|
||||
*/
|
||||
private fun findCrossingY(
|
||||
proj: org.osmdroid.views.Projection,
|
||||
@@ -143,7 +148,8 @@ class MapNightOverlay : Overlay() {
|
||||
yBot: Int,
|
||||
sinSunLat: Double,
|
||||
cosSunLat: Double,
|
||||
cosLonDiff: Double
|
||||
cosLonDiff: Double,
|
||||
isNightAtTop: Boolean
|
||||
): Int {
|
||||
var lo = yTop
|
||||
var hi = yBot
|
||||
@@ -152,7 +158,8 @@ class MapNightOverlay : Overlay() {
|
||||
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
|
||||
// Keep lo on the top pixel's side of the terminator and hi on the opposite side
|
||||
if ((dot < 0) == isNightAtTop) lo = mid else hi = mid
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
|
||||
@@ -159,6 +159,71 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accent used for the station marker, footprint outline and map labels. It is recolored per
|
||||
* theme (dark amber on light tiles, light amber on dark tiles) so every map element stays
|
||||
* readable; [applyMapColors] keeps it in sync with the shared paints.
|
||||
*/
|
||||
private var mapAccentColor = "#FFE082".toColorInt()
|
||||
|
||||
/** Grayscale + invert filter that turns the offline OSM map dark; unused (and off) in light theme. */
|
||||
private val darkTileFilter by lazy { createColorFilter() }
|
||||
|
||||
/** Theme the shared map paints were last colored for; null = not applied yet this process. */
|
||||
private var mapIsLightUi: Boolean? = null
|
||||
|
||||
/** Theme the tile filter / icon caches were last applied for (drives the per-frame theme check). */
|
||||
private var appliedLightUi: Boolean? = null
|
||||
|
||||
/** True while the offline OSM source is active — satellite imagery (Tianditu) is never filtered. */
|
||||
private var offlineTilesActive = true
|
||||
|
||||
/**
|
||||
* Recolor the shared overlay paints and custom overlays for the current theme. Must run before the
|
||||
* markers are (re)built each frame so freshly created icons use the new colors; [applyThemeToOverlays]
|
||||
* and the icon-cache eviction in the update block handle the already created ones.
|
||||
*/
|
||||
private fun applyMapColors(isLightUi: Boolean) {
|
||||
mapIsLightUi = isLightUi
|
||||
if (isLightUi) {
|
||||
// Dark amber (lightScheme primary) reads on light tiles; labels get a white halo.
|
||||
mapAccentColor = "#715C0C".toColorInt()
|
||||
footprintPaint.color = mapAccentColor
|
||||
textPaint.color = "#1E1B13".toColorInt()
|
||||
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
|
||||
sunIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
moonIconPaint.colorFilter = android.graphics.PorterDuffColorFilter(
|
||||
"#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN
|
||||
)
|
||||
} else {
|
||||
mapAccentColor = "#FFE082".toColorInt()
|
||||
footprintPaint.color = mapAccentColor
|
||||
textPaint.color = mapAccentColor
|
||||
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
|
||||
sunIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
moonIconPaint.colorFilter =
|
||||
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
applyThemeToOverlays()
|
||||
}
|
||||
|
||||
/** Push the current theme into the grid / award overlays, which own their own paints. */
|
||||
private fun applyThemeToOverlays() {
|
||||
val isLight = mapIsLightUi == true
|
||||
currentMapView?.overlays?.forEach { overlay ->
|
||||
when (overlay) {
|
||||
is MaidenheadGridOverlay -> overlay.applyTheme(isLight)
|
||||
is AwardBoundaryOverlay -> overlay.applyTheme(isLight)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The MapView currently attached to the map page; used to recolor overlays on a theme change. */
|
||||
private var currentMapView: MapView? = null
|
||||
|
||||
@Composable
|
||||
fun MapDestination(
|
||||
mapFilterViewModel: MapFilterViewModel,
|
||||
@@ -204,7 +269,14 @@ fun MapDestination(
|
||||
viewModel.onAction(MapAction.SetVisible(false))
|
||||
}
|
||||
}
|
||||
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating)
|
||||
MapScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
mapView = mapView,
|
||||
mapFilterViewModel = mapFilterViewModel,
|
||||
onMatchGrid = onMatchGrid,
|
||||
matchCalculating = matchCalculating
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -320,8 +392,8 @@ private fun MapScreen(
|
||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||
}
|
||||
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
|
||||
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
|
||||
LaunchedEffect(uiState.mapSource, uiState.tiandituKey, uiState.isLightUi) {
|
||||
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey, uiState.isLightUi)
|
||||
}
|
||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val isVertical = isVerticalLayout()
|
||||
@@ -359,6 +431,18 @@ private fun MapScreen(
|
||||
mutableStateOf(restoredViewport && uiState.isGridMode)
|
||||
}
|
||||
AndroidView({ mapView }) { view ->
|
||||
// Theme change: recolor the paints/tile filter/overlays and drop the icon
|
||||
// caches, which baked the previous theme's colors into their bitmaps.
|
||||
if (appliedLightUi != uiState.isLightUi) {
|
||||
appliedLightUi = uiState.isLightUi
|
||||
applyMapColors(uiState.isLightUi)
|
||||
iconCache.evictAll()
|
||||
dotIcon = null
|
||||
view.overlayManager.tilesOverlay.setColorFilter(
|
||||
if (!uiState.isLightUi && offlineTilesActive) darkTileFilter else null
|
||||
)
|
||||
requestTileRefresh(view)
|
||||
}
|
||||
// Award filter mode: show the selected award's regions with
|
||||
// worked ones filled green; hide satellite layers like grid mode.
|
||||
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
|
||||
@@ -393,13 +477,13 @@ private fun MapScreen(
|
||||
}
|
||||
}
|
||||
if (!uiState.isGridMode) {
|
||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||
uiState.stationPosition?.let { setStationPosition(it, view, uiState.isLightUi) }
|
||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||
uiState.footprint?.let { setFootprint(it, view) }
|
||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view, uiState.isLightUi)
|
||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view, uiState.isLightUi)
|
||||
}
|
||||
view.invalidate()
|
||||
}
|
||||
@@ -634,7 +718,7 @@ private fun MarkedStationRow(
|
||||
text = marked.call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = ComposeColor(0xFFFFE082),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -651,7 +735,7 @@ private fun MarkedStationRow(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_pin),
|
||||
contentDescription = stringResource(R.string.grid_mark_pin),
|
||||
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082),
|
||||
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clickable(onClick = onPinMark)
|
||||
@@ -706,7 +790,7 @@ private fun WorkedGridCallRow(
|
||||
text = call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = ComposeColor(0xFFFFE082),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -1092,22 +1176,29 @@ private fun FirstCallToggle(
|
||||
// region Tile sources
|
||||
private var configuredTileMapView: MapView? = null
|
||||
private var configuredTileSource: String? = null
|
||||
private var configuredTileIsLightUi: Boolean? = null
|
||||
|
||||
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
|
||||
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String, isLightUi: Boolean) {
|
||||
val key = tiandituKey.trim()
|
||||
val layers = tiandituLayers(mapSource)
|
||||
val sourceKey = if (layers == null || key.isBlank()) MapSource.OSM else "${layers.base}:$key"
|
||||
if (configuredTileMapView === mapView && configuredTileSource == sourceKey) return
|
||||
val offlineSource = layers == null || key.isBlank()
|
||||
offlineTilesActive = offlineSource
|
||||
val sourceKey = if (offlineSource) MapSource.OSM else "${layers.base}:$key"
|
||||
if (configuredTileMapView === mapView && configuredTileSource == sourceKey &&
|
||||
configuredTileIsLightUi == isLightUi
|
||||
) return
|
||||
|
||||
configuredTileMapView = mapView
|
||||
configuredTileSource = sourceKey
|
||||
configuredTileIsLightUi = isLightUi
|
||||
(mapView.overlays.getOrNull(OVERLAY_TDT_LABELS) as? TilesOverlay)?.onDetach(mapView)
|
||||
|
||||
if (layers == null || key.isBlank()) {
|
||||
if (offlineSource) {
|
||||
mapView.setUseDataConnection(false)
|
||||
mapView.setTileSource(offlineTileSource)
|
||||
mapView.maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(createColorFilter())
|
||||
// The dark-map filter belongs to the dark theme only; the light theme shows plain tiles.
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults(if (isLightUi) null else darkTileFilter)
|
||||
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
|
||||
requestTileRefresh(mapView)
|
||||
return
|
||||
@@ -1210,6 +1301,7 @@ private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: Ma
|
||||
isEnabled = true
|
||||
regions = AwardBoundaryData.load(mapView.context, asset)
|
||||
this.workedCodes = workedCodes
|
||||
applyTheme(mapIsLightUi == true)
|
||||
}
|
||||
}
|
||||
for (index in OVERLAY_STATION..OVERLAY_MOON) {
|
||||
@@ -1252,6 +1344,7 @@ private fun setGridMode(
|
||||
this.firstCallsByGrid = firstCallsByGrid
|
||||
this.markedGrids = markedGrids
|
||||
this.ownGrid = ownGridOf(stationPosition)
|
||||
applyTheme(mapIsLightUi == true)
|
||||
}
|
||||
}
|
||||
// Satellite-related layers are hidden in grid mode; the grid overlay
|
||||
@@ -1306,17 +1399,28 @@ private fun gridOfPoint(latitude: Double, longitude: Double): String? {
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView, isLightUi: Boolean) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||
val stationPoint = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
if (overlay is Marker) {
|
||||
overlay.position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
overlay.position = stationPoint
|
||||
// Icon tint is baked into the drawable, so re-tint after a theme change.
|
||||
if ((overlay.relatedObject as? Boolean) != isLightUi) {
|
||||
overlay.icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
|
||||
setTint(mapAccentColor)
|
||||
}
|
||||
overlay.relatedObject = isLightUi
|
||||
}
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)
|
||||
position = GeoPoint(stationPos.latitude, stationPos.longitude)
|
||||
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
|
||||
setTint(mapAccentColor)
|
||||
}
|
||||
position = stationPoint
|
||||
relatedObject = isLightUi
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1539,26 +1643,37 @@ private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the 48dp sun/moon marker icon, tinted with the theme-dependent paint. */
|
||||
private fun buildMarkerIcon(mapView: MapView, resId: Int, paint: Paint): Drawable {
|
||||
val iconSize = 48
|
||||
val bmp = createBitmap(iconSize, iconSize)
|
||||
ContextCompat.getDrawable(mapView.context, resId)?.apply {
|
||||
setBounds(0, 0, iconSize, iconSize)
|
||||
colorFilter = paint.colorFilter
|
||||
draw(Canvas(bmp))
|
||||
}
|
||||
return bmp.toDrawable(mapView.context.resources)
|
||||
}
|
||||
|
||||
/** Place an ic_sun icon marker at the sub-solar point. */
|
||||
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
||||
private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView, isLightUi: Boolean) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_SUN]
|
||||
val sunPos = GeoPoint(sunLatDeg, sunLonDeg)
|
||||
if (overlay is Marker) {
|
||||
overlay.position = sunPos
|
||||
} else {
|
||||
val iconSize = 48
|
||||
val bmp = createBitmap(iconSize, iconSize)
|
||||
ContextCompat.getDrawable(mapView.context, R.drawable.ic_sun)?.apply {
|
||||
setBounds(0, 0, iconSize, iconSize)
|
||||
colorFilter = sunIconPaint.colorFilter
|
||||
draw(Canvas(bmp))
|
||||
// The icon bitmap bakes in the theme tint, so rebuild it after a theme change.
|
||||
if ((overlay.relatedObject as? Boolean) != isLightUi) {
|
||||
overlay.icon = buildMarkerIcon(mapView, R.drawable.ic_sun, sunIconPaint)
|
||||
overlay.relatedObject = isLightUi
|
||||
}
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_SUN] = Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = bmp.toDrawable(mapView.context.resources)
|
||||
icon = buildMarkerIcon(mapView, R.drawable.ic_sun, sunIconPaint)
|
||||
position = sunPos
|
||||
relatedObject = isLightUi
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1567,26 +1682,23 @@ private fun setSubSolarPoint(sunLatDeg: Double, sunLonDeg: Double, mapView: MapV
|
||||
}
|
||||
|
||||
/** Place an ic_moon icon marker at the sub-lunar point. */
|
||||
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView) {
|
||||
private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: MapView, isLightUi: Boolean) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_MOON]
|
||||
val moonPos = GeoPoint(moonLatDeg, moonLonDeg)
|
||||
if (overlay is Marker) {
|
||||
overlay.position = moonPos
|
||||
} else {
|
||||
val iconSize = 48
|
||||
val bmp = createBitmap(iconSize, iconSize)
|
||||
val c = Canvas(bmp)
|
||||
ContextCompat.getDrawable(mapView.context, R.drawable.ic_moon)?.apply {
|
||||
setBounds(0, 0, iconSize, iconSize)
|
||||
colorFilter = moonIconPaint.colorFilter
|
||||
draw(c)
|
||||
if ((overlay.relatedObject as? Boolean) != isLightUi) {
|
||||
overlay.icon = buildMarkerIcon(mapView, R.drawable.ic_moon, moonIconPaint)
|
||||
overlay.relatedObject = isLightUi
|
||||
}
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_MOON] = Marker(mapView).apply {
|
||||
setInfoWindow(null)
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||
icon = bmp.toDrawable(mapView.context.resources)
|
||||
icon = buildMarkerIcon(mapView, R.drawable.ic_moon, moonIconPaint)
|
||||
position = moonPos
|
||||
relatedObject = isLightUi
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1631,10 +1743,12 @@ private fun rememberMapViewWithLifecycle(
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||
// The dark-map tile filter (or no filter in the light theme) is applied per theme from
|
||||
// the AndroidView update block, so it also follows a theme switch at runtime.
|
||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||
overlays[OVERLAY_GRID] = MaidenheadGridOverlay()
|
||||
overlays[OVERLAY_GRID] = MaidenheadGridOverlay().apply { applyTheme(mapIsLightUi == true) }
|
||||
currentMapView = this
|
||||
}
|
||||
}
|
||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||
@@ -1647,7 +1761,10 @@ private fun rememberMapViewWithLifecycle(
|
||||
// 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() }
|
||||
onDispose {
|
||||
clearMapCaches()
|
||||
if (currentMapView === mapView) currentMapView = null
|
||||
}
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ class MapViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
isLightUi = settings.stateOfLightTheme,
|
||||
mapSource = settings.mapSource,
|
||||
tiandituKey = settings.tiandituKey,
|
||||
isGridMode = settings.stateOfMapGrid,
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
@@ -111,6 +129,8 @@ class FakeSettingsRepo(
|
||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||
override fun getSatelliteMode(catnum: Int): String = ""
|
||||
override fun setSatelliteMode(catnum: Int, mode: String) = TODO()
|
||||
override fun getAmSatCallsign(): String = ""
|
||||
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.mutual
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.ProjectWikiBody
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
@@ -143,17 +144,14 @@ private fun PassesScreen(
|
||||
if (uiState.shouldSeeWhatsNew) {
|
||||
val dismiss = { onAction(PassesAction.DismissWhatsNew) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.pass_whatsnew_title),
|
||||
title = stringResource(R.string.prefs_project_wiki),
|
||||
onDismissRequest = dismiss,
|
||||
onAccept = dismiss,
|
||||
titleFontSize = 18
|
||||
) { padding ->
|
||||
// The changelog is long; keep the dialog frame fixed and let the
|
||||
// The wiki is long; keep the dialog frame fixed and let the
|
||||
// text scroll inside it (matches the update-checker page pattern).
|
||||
Text(
|
||||
text = stringResource(R.string.pass_whatsnew_message),
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
ProjectWikiBody(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = padding)
|
||||
.heightIn(max = 360.dp)
|
||||
|
||||
@@ -10,4 +10,6 @@ dependencies {
|
||||
implementation(project(":feature:mutual"))
|
||||
implementation(project(":feature:cw"))
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||
}
|
||||
testImplementation(libs.test.junit4)
|
||||
testImplementation(libs.test.coroutines)
|
||||
}
|
||||
@@ -31,7 +31,9 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -46,13 +48,21 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeRevealRow
|
||||
import com.rtbishop.look4sat.core.presentation.rememberSwipeController
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||
import com.rtbishop.look4sat.core.domain.utility.downlinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
|
||||
private const val LOG_WINDOW_MS = 24 * 3_600_000L
|
||||
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
@@ -63,6 +73,8 @@ fun LogPage(
|
||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val records by logViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val swipeController = rememberSwipeController()
|
||||
// Record currently open in the edit dialog (null when closed).
|
||||
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
||||
|
||||
val selectedRadio = remember(uiState.transceivers.transmitters, uiState.transceivers.selectedUuid) {
|
||||
uiState.transceivers.transmitters.firstOrNull { it.uuid == uiState.transceivers.selectedUuid }
|
||||
@@ -73,25 +85,44 @@ fun LogPage(
|
||||
val isLinear = uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
val catnum = uiState.currentPass?.catNum ?: selectedRadio?.catnum ?: 0
|
||||
val satName = uiState.currentPass?.name?.trim().orEmpty()
|
||||
// The satellite's FM voice repeater — the single-frequency transceiver the
|
||||
// contact is actually made through. SatNOGS publishes its nominal pair, so a
|
||||
// contact can never be logged without a frequency (which LoTW rejects as a
|
||||
// missing BAND).
|
||||
val voiceRepeater = remember(uiState.transceivers.transmitters) {
|
||||
uiState.transceivers.transmitters.voiceRepeater()
|
||||
}
|
||||
// AMSAT Live FM satellites are always logged on that fixed nominal pair,
|
||||
// even when the calculator happens to be tuned to something else.
|
||||
val useRepeater = catnum in Sources.amSatFmCatnums
|
||||
|
||||
LaunchedEffect(catnum) {
|
||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
||||
}
|
||||
|
||||
val txHz = uiState.calculatorTxHz ?: remember(selectedRadio) {
|
||||
selectedRadio?.uplinkLow?.let { low ->
|
||||
selectedRadio.uplinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
|
||||
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
|
||||
val repeaterTxHz = voiceRepeater?.uplinkHz()
|
||||
val repeaterRxHz = voiceRepeater?.downlinkHz()
|
||||
val txHz = if (useRepeater) {
|
||||
repeaterTxHz ?: uiState.calculatorTxHz ?: selectedTxHz
|
||||
} else {
|
||||
uiState.calculatorTxHz ?: repeaterTxHz ?: selectedTxHz
|
||||
}
|
||||
val rxHz = uiState.calculatorRxHz ?: remember(selectedRadio) {
|
||||
selectedRadio?.downlinkLow?.let { low ->
|
||||
selectedRadio.downlinkHigh?.let { high -> (low + high) / 2 } ?: low
|
||||
}
|
||||
val rxHz = if (useRepeater) {
|
||||
repeaterRxHz ?: uiState.calculatorRxHz ?: selectedRxHz
|
||||
} else {
|
||||
uiState.calculatorRxHz ?: repeaterRxHz ?: selectedRxHz
|
||||
}
|
||||
val mode = if (isLinear) logUiState.selectedMode.ifBlank { "CW" } else "FM"
|
||||
val now = System.currentTimeMillis()
|
||||
val satIdentity = remember(satName) { satelliteIdentity(satName) }
|
||||
val passWindow = remember(uiState.currentPass) {
|
||||
uiState.currentPass?.let { it.aosTime..it.losTime }
|
||||
}
|
||||
val recent = records
|
||||
.filter { it.satelliteName.trim().equals(satName, true) && it.startUtcMillis > now - LOG_WINDOW_MS }
|
||||
.filter { satelliteIdentity(it.satelliteName) == satIdentity }
|
||||
// Only contacts inside the current pass window: the log page is the "this pass" sheet.
|
||||
.filter { passWindow?.contains(it.startUtcMillis) == true }
|
||||
.sortedByDescending { it.startUtcMillis }
|
||||
val maxElev = uiState.currentPass?.maxElevation ?: 0.0
|
||||
|
||||
@@ -135,7 +166,12 @@ fun LogPage(
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(recent, key = { it.id }) { record ->
|
||||
LogRecordRow(record, swipeController, onDelete = { logViewModel.delete(record.id) })
|
||||
LogRecordRow(
|
||||
record = record,
|
||||
swipeController = swipeController,
|
||||
onDelete = { logViewModel.delete(record.id) },
|
||||
onClick = { editTarget = record }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,17 +229,36 @@ fun LogPage(
|
||||
if (logUiState.message.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
title = { Text("LoTW Upload") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
text = { Text(logUiState.message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::clearMessage) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { target ->
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
satelliteCandidates = logUiState.satelliteCatalog,
|
||||
onDismiss = { editTarget = null },
|
||||
onSave = { updated ->
|
||||
logViewModel.updateRecord(updated)
|
||||
editTarget = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
|
||||
private fun LogRecordRow(
|
||||
record: QsoRecord,
|
||||
swipeController: SwipeController,
|
||||
onDelete: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val time = remember(record.startUtcMillis) {
|
||||
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
@@ -217,7 +272,10 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -227,14 +285,14 @@ private fun LogRecordRow(record: QsoRecord, swipeController: SwipeController, on
|
||||
text = record.theirCallsign,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFFFE082),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
|
||||
}
|
||||
when {
|
||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
record.lotwUploaded -> Text("UP", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
else -> Text("", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
@@ -250,7 +308,9 @@ private fun PostDialog(
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("通联记录") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("通联记录") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text, fontSize = 13.sp)
|
||||
@@ -273,7 +333,9 @@ private fun UploadPreviewDialog(
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("上传到 LoTW") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("上传到 LoTW") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
@@ -294,6 +356,26 @@ private fun UploadPreviewDialog(
|
||||
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
|
||||
}))
|
||||
}Z", fontSize = 13.sp)
|
||||
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||
val parts = buildList {
|
||||
// skipped counts every record left out of this batch: previously-uploaded
|
||||
// ones, batch duplicates and unknown-outcome ones. Show them separately so
|
||||
// "10 already uploaded" (historical records) is not read as this upload
|
||||
// being rejected as a duplicate.
|
||||
val alreadyUploaded = preview.skipped - preview.duplicateSkipped - preview.unknownSkipped
|
||||
if (alreadyUploaded > 0) add("$alreadyUploaded already uploaded")
|
||||
if (preview.duplicateSkipped > 0) add("${preview.duplicateSkipped} duplicate")
|
||||
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
|
||||
if (preview.unavailableSkipped > 0) add(
|
||||
unavailableUploadSummary(
|
||||
preview.unavailableSkipped,
|
||||
preview.unavailableReasons,
|
||||
duplicates = preview.duplicateSkipped
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,9 @@ import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.officialSatelliteName
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
@@ -43,6 +46,8 @@ data class LogUiState(
|
||||
val certificateCallsign: String = "",
|
||||
/** Primary grid of the LoTW upload station location ("" when none). */
|
||||
val stationGrid: String = "",
|
||||
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
||||
val satelliteCatalog: List<String> = emptyList(),
|
||||
val preview: LoTWUploadPreview? = null,
|
||||
/** User-facing upload / record message ("" when none). */
|
||||
val message: String = "",
|
||||
@@ -56,6 +61,8 @@ class LogViewModel(
|
||||
private val settingsRepo: ISettingsRepo
|
||||
) : ViewModel() {
|
||||
|
||||
/** Records shown on the radar log page: all records of the current pass window, including
|
||||
* already-uploaded (UP) and confirmed (QSL) ones. Window filtering happens in LogPage. */
|
||||
val records: Flow<List<QsoRecord>> = qsoRepository.records
|
||||
|
||||
private val _uiState = MutableStateFlow(LogUiState())
|
||||
@@ -69,10 +76,12 @@ class LogViewModel(
|
||||
viewModelScope.launch {
|
||||
val cert = lotwUploadRepository.certificate()
|
||||
val station = lotwUploadRepository.station()
|
||||
val catalog = runCatching { lotwUploadRepository.satelliteCatalog() }.getOrDefault(emptyList())
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
certificateCallsign = cert?.callsign.orEmpty(),
|
||||
stationGrid = station?.grid.orEmpty()
|
||||
stationGrid = station?.grid.orEmpty(),
|
||||
satelliteCatalog = catalog
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,20 +105,27 @@ class LogViewModel(
|
||||
val call = _uiState.value.callsignInput.trim()
|
||||
if (call.isEmpty()) return
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
val now = System.currentTimeMillis()
|
||||
// Whole minutes (seconds zeroed): matches the edit dialog's HH:mm granularity and the
|
||||
// minute-resolution of LoTW reports, so re-saving an unchanged record never looks edited.
|
||||
val now = System.currentTimeMillis() / 60_000L * 60_000L
|
||||
// kHz granularity (3 decimals in MHz) — enough for operating and for the ADIF export.
|
||||
val tx = txHz?.let { (it + 500L) / 1000L * 1000L }
|
||||
val rx = rxHz?.let { (it + 500L) / 1000L * 1000L }
|
||||
val record = QsoRecord(
|
||||
startUtcMillis = now,
|
||||
endUtcMillis = now,
|
||||
theirCallsign = call,
|
||||
myCallsign = _uiState.value.certificateCallsign,
|
||||
myGrid = myGrid.take(6).uppercase(Locale.US),
|
||||
txFrequencyHz = txHz,
|
||||
rxFrequencyHz = rxHz,
|
||||
band = frequencyBand(txHz),
|
||||
rxBand = frequencyBand(rxHz),
|
||||
txFrequencyHz = tx,
|
||||
rxFrequencyHz = rx,
|
||||
band = frequencyBand(tx),
|
||||
rxBand = frequencyBand(rx),
|
||||
mode = if (normalizedMode == "FT4") "MFSK" else normalizedMode,
|
||||
submode = normalizedMode.takeIf { it == "FT4" }.orEmpty(),
|
||||
satelliteName = satName,
|
||||
// Stored under the ARRL name (the tracker's "SAUDISAT 1C" is logged as "SO-50"):
|
||||
// one name for the logbook, the ADIF export and the signed record.
|
||||
satelliteName = officialSatelliteName(satName, _uiState.value.satelliteCatalog),
|
||||
satelliteMode = normalizedMode,
|
||||
status = QsoStatus.COMPLETE,
|
||||
propagationMode = "SAT"
|
||||
@@ -120,16 +136,25 @@ class LogViewModel(
|
||||
|
||||
fun delete(id: Long) = viewModelScope.launch { qsoRepository.delete(id) }
|
||||
|
||||
/**
|
||||
* Persist an edited record (frequency/callsign/time/…). The repository keeps
|
||||
* the LoTW confirmation state consistent when the contact identity changes.
|
||||
*/
|
||||
fun updateRecord(record: QsoRecord) = viewModelScope.launch { qsoRepository.save(record) }
|
||||
|
||||
/** English social post from the recorded QSOs of the current satellite (last 24h). */
|
||||
fun generatePost(satName: String, maxElev: Double) {
|
||||
viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
// Records carry the ARRL name while the pass carries the tracker's name, so the
|
||||
// operator's own contacts are collected by identity, not by the raw name.
|
||||
val identity = satelliteIdentity(satName)
|
||||
val list = qsoRepository.records.first()
|
||||
.filter { it.satelliteName.trim().equals(satName.trim(), true) }
|
||||
.filter { satelliteIdentity(it.satelliteName) == identity }
|
||||
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
|
||||
.sortedBy { it.startUtcMillis }
|
||||
if (list.isEmpty()) return@launch
|
||||
val shortName = satName.substringBefore('(').trim().uppercase(Locale.US)
|
||||
val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
|
||||
val utc = SimpleDateFormat("yyyyMMdd|HH:mm'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
@@ -140,7 +165,7 @@ class LogViewModel(
|
||||
.joinToString("\n")
|
||||
val currentGrid = settingsRepo.stationPosition.value.qthLocator.take(6)
|
||||
val logLine = "📍 Log: ${_uiState.value.stationGrid.take(4)} | Current: $currentGrid"
|
||||
val viaLine = "via Look4Sat — TNX de ${_uiState.value.certificateCallsign}"
|
||||
val viaLine = "via Look4Sat-BA7OPF — TNX de ${_uiState.value.certificateCallsign}"
|
||||
val tag = "#HamRadio #SatelliteQSO #" + shortName.filter { it.isLetterOrDigit() }
|
||||
val text = listOf(firstLine, elLine, modeLines, "", logLine, viaLine, tag).joinToString("\n")
|
||||
_uiState.update { it.copy(postText = text) }
|
||||
@@ -157,14 +182,24 @@ class LogViewModel(
|
||||
val all = qsoRepository.records.first()
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> unavailableUploadSummary(
|
||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||
)
|
||||
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(busy = false, message = msg) }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
lastUploadedIds = pending.map { it.id }
|
||||
// Only the records that actually made it into the TQ8 may be
|
||||
// marked uploaded later — never the whole candidate list.
|
||||
lastUploadedIds = preview.submittedIds
|
||||
_uiState.update { it.copy(busy = false, preview = preview) }
|
||||
} catch (e: LoTWOperationException) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||
|
||||
@@ -28,14 +28,18 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@@ -43,19 +47,24 @@ import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -74,7 +83,6 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -87,6 +95,85 @@ private enum class RadarPage(val title: String) {
|
||||
Sstv("SSTV")
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-visible translucent pager tab strip drawn over the radar in small windows.
|
||||
*/
|
||||
private val COMPACT_TAB_STRIP_HEIGHT = 48.dp
|
||||
|
||||
/**
|
||||
* Smallest pager block worth reserving below the radar before falling back to the
|
||||
* overlaid tab strip + panel. Below this the pager would be unusable anyway.
|
||||
*/
|
||||
private val COMPACT_MIN_PAGER_HEIGHT = 96.dp
|
||||
|
||||
/**
|
||||
* Vertical chrome the classic layout spends outside the radar: two 48dp top bar rows
|
||||
* plus three 6dp row gaps (bar/bar/radar/pager).
|
||||
*/
|
||||
private val CLASSIC_VERTICAL_CHROME = 114.dp
|
||||
|
||||
/**
|
||||
* The radar circle is 0.95 x min(cardWidth, cardHeight), so a card that is wider than it
|
||||
* is tall always shows a small circle with empty bands on the sides. Full screen the radar
|
||||
* card ends up ~0.88 x cardWidth tall (circle ~84% of the page width). Once the classic
|
||||
* 1:1 split on a vertical window would give the radar less than this share of its own
|
||||
* width, the fill layout takes over so the circle keeps the full-screen width proportion
|
||||
* instead of shrinking with the window height.
|
||||
*/
|
||||
private const val FILL_RADAR_SPLIT_RATIO = 0.75f
|
||||
|
||||
/**
|
||||
* Smallest radar side (as a share of the page width) worth keeping a reserved pager block for.
|
||||
* 0.88 x 0.95 = ~0.84 = the circle's share of the page width full screen, so the pager is only
|
||||
* preserved while reserving it costs the circle nothing against the full-screen proportion.
|
||||
*/
|
||||
private const val MIN_RADAR_SHARE_WITH_PAGER = 0.88f
|
||||
|
||||
/** Vertical gap kept between the stacked cards. */
|
||||
private val ROW_GAP = 6.dp
|
||||
|
||||
/**
|
||||
* True when a vertical window is too short for the classic 1:1 split to keep the radar circle
|
||||
* at its full-screen share of the page width. The split gives the radar
|
||||
* `(height - chrome) / 2`, so a short window caps the circle by that height instead of by the
|
||||
* page width.
|
||||
*/
|
||||
internal fun useFillRadarLayout(isVertical: Boolean, maxWidth: Dp, maxHeight: Dp): Boolean =
|
||||
isVertical && (maxHeight - CLASSIC_VERTICAL_CHROME) / 2 < maxWidth * FILL_RADAR_SPLIT_RATIO
|
||||
|
||||
/**
|
||||
* Geometry of the fill layout: the square the radar keeps for itself (largest square that fits,
|
||||
* so the circle spans the page width like it does full screen) and the space left for the pager.
|
||||
* When that space is too small to be usable the pager is overlaid on the plot instead.
|
||||
*/
|
||||
internal data class RadarFillSizes(
|
||||
val radarSide: Dp,
|
||||
val pagerSpace: Dp,
|
||||
val pagerOverlaid: Boolean
|
||||
)
|
||||
|
||||
internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
|
||||
// Preferred: keep a usable pager block under the radar square. That is only worth it while
|
||||
// the circle still reaches its full-screen share of the page width.
|
||||
val radarWithPager = minOf(maxWidth, maxHeight - COMPACT_MIN_PAGER_HEIGHT - ROW_GAP)
|
||||
if (radarWithPager >= maxWidth * MIN_RADAR_SHARE_WITH_PAGER) {
|
||||
return RadarFillSizes(
|
||||
radarSide = radarWithPager,
|
||||
pagerSpace = maxHeight - radarWithPager - ROW_GAP,
|
||||
pagerOverlaid = false
|
||||
)
|
||||
}
|
||||
// Window too short for both: the radar takes the largest square it can and the pager is
|
||||
// folded into the overlaid tab strip.
|
||||
val radarSide = minOf(maxWidth, maxHeight)
|
||||
val pagerSpace = maxHeight - radarSide - ROW_GAP
|
||||
return RadarFillSizes(
|
||||
radarSide = radarSide,
|
||||
pagerSpace = pagerSpace,
|
||||
pagerOverlaid = pagerSpace < COMPACT_MIN_PAGER_HEIGHT
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
@@ -172,37 +259,188 @@ private fun RadarScreen(
|
||||
time = it.time
|
||||
)
|
||||
}
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.layoutPadding()
|
||||
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
|
||||
.keepScreenOn(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
.keepScreenOn()
|
||||
) {
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
// Measure the ACTUAL window constraints instead of the window size
|
||||
// class: currentWindowAdaptiveInfo() often reports the full-screen
|
||||
// size in split-screen / multi-window, so the compact branch never
|
||||
// triggered there. maxHeight/maxWidth are the real window bounds.
|
||||
val isVertical = maxWidth < 600.dp
|
||||
// The classic vertical layout gives the radar a 1:1 share of the height while also
|
||||
// paying for two top bar rows. In split-screen / small windows that leaves a card that
|
||||
// is wider than it is tall, so the circle is capped by the height and stops filling the
|
||||
// page width. Below the full-screen proportion the fill layout takes over: one top bar
|
||||
// row, the radar keeps the largest square it can, the pager yields (reserved block when
|
||||
// the window is tall enough, overlaid tab strip + panel when it is not).
|
||||
val fillRadar = useFillRadarLayout(isVertical, maxWidth, maxHeight)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isVertical) {
|
||||
// Normal vertical top area: the timer card keeps its own row and the pass card
|
||||
// gets a full-width row of its own. Never merge them into one row — both cards
|
||||
// carry weight(1f), so side by side they split the width into two halves.
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
}
|
||||
}
|
||||
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
|
||||
} else {
|
||||
TopBar {
|
||||
IconCard(action = navigateUp, resId = R.drawable.ic_back)
|
||||
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
|
||||
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
|
||||
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
|
||||
if (isVertical) {
|
||||
if (fillRadar) {
|
||||
RadarFillArea(
|
||||
uiState = uiState,
|
||||
trackB = trackB,
|
||||
trackBPosition = trackBPosition,
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isVertical) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radar area for split-screen / small windows. The radar takes the largest square the area
|
||||
* can hold so the circle spans the same share of the width it does full screen, and the
|
||||
* pager yields: a real block below the square when there is room, otherwise a translucent
|
||||
* tab strip overlaid on the plot that opens the pager panel on top of it.
|
||||
*/
|
||||
@Composable
|
||||
private fun RadarFillArea(
|
||||
uiState: RadarState,
|
||||
trackB: List<OrbitalPos>,
|
||||
trackBPosition: OrbitalPos?,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val sizes = radarFillSizes(maxWidth, maxHeight)
|
||||
// Hoisted out of the nested Box scope: the BoxWithConstraints receiver is not
|
||||
// implicitly reachable inside it.
|
||||
val pagerPanelHeight = maxHeight * 0.72f
|
||||
if (!sizes.pagerOverlaid) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
|
||||
) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.fillMaxSize(), overlayStrip = true)
|
||||
CompactPagerOverlay(
|
||||
uiState = uiState,
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
panelHeight = pagerPanelHeight,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed pager for short windows: a translucent row of tab names over the bottom of the
|
||||
* radar plot; picking one opens the full pager panel on top of the plot, collapsible again
|
||||
* with the chevron in its tab row.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompactPagerOverlay(
|
||||
uiState: RadarState,
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
panelHeight: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val pages = rememberRadarPages(uiState)
|
||||
var openPage by remember { mutableStateOf<RadarPage?>(null) }
|
||||
val currentPage = openPage
|
||||
if (currentPage == null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(COMPACT_TAB_STRIP_HEIGHT)
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
pages.forEach { page ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clickable { openPage = page }
|
||||
) {
|
||||
Text(
|
||||
text = page.title,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PagerCard(
|
||||
uiState = uiState,
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(panelHeight),
|
||||
startPage = currentPage,
|
||||
onCollapse = { openPage = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Page list of the pager card, shared by the inline pager and the compact overlay panel. */
|
||||
@Composable
|
||||
private fun rememberRadarPages(uiState: RadarState): List<RadarPage> {
|
||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
}
|
||||
return remember(hasCalculatorPage) {
|
||||
buildList {
|
||||
add(RadarPage.Transceivers)
|
||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||
add(RadarPage.Log)
|
||||
add(RadarPage.Sstv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,20 +451,15 @@ private fun PagerCard(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
startPage: RadarPage? = null,
|
||||
onCollapse: (() -> Unit)? = null
|
||||
) {
|
||||
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||
}
|
||||
val pages = remember(hasCalculatorPage) {
|
||||
buildList {
|
||||
add(RadarPage.Transceivers)
|
||||
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||
add(RadarPage.Log)
|
||||
add(RadarPage.Sstv)
|
||||
}
|
||||
}
|
||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||
val pages = rememberRadarPages(uiState)
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = startPage?.let { pages.indexOf(it) }?.coerceAtLeast(0) ?: 0,
|
||||
pageCount = { pages.size }
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(pages.size) {
|
||||
@@ -237,12 +470,26 @@ private fun PagerCard(
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier = if (onCollapse != null) Modifier.weight(1f) else Modifier
|
||||
) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
Tab(
|
||||
selected = selectedTabIndex == index,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
)
|
||||
}
|
||||
}
|
||||
onCollapse?.let { collapse ->
|
||||
Text(
|
||||
text = "▼",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable { collapse() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +534,8 @@ private fun RadarCard(
|
||||
uiState: RadarState,
|
||||
trackB: List<OrbitalPos> = emptyList(),
|
||||
trackBPosition: OrbitalPos? = null,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
overlayStrip: Boolean = false
|
||||
) {
|
||||
val satellitePos = uiState.orbitalPos
|
||||
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
|
||||
@@ -330,19 +578,25 @@ private fun RadarCard(
|
||||
sunPosition = uiState.sunPosition,
|
||||
moonPosition = uiState.moonPosition,
|
||||
)
|
||||
PositionOverlay(position)
|
||||
PositionOverlay(position, overlayStrip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PositionOverlay(position: OrbitalPos) {
|
||||
private fun PositionOverlay(position: OrbitalPos, overlayStrip: Boolean = false) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
// Keep the bottom labels clear of the overlaid pager tab strip in small windows.
|
||||
.padding(
|
||||
start = 6.dp,
|
||||
end = 6.dp,
|
||||
top = 4.dp,
|
||||
bottom = if (overlayStrip) COMPACT_TAB_STRIP_HEIGHT + 4.dp else 4.dp
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.radar
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Small-window radar sizing.
|
||||
*
|
||||
* The plotted circle is `0.95 x min(cardWidth, cardHeight)`, so a radar card that is wider than
|
||||
* it is tall caps the circle by its height and shows empty bands on the sides. Full screen the
|
||||
* classic 1:1 split leaves the radar card ~0.88 x cardWidth tall, i.e. the circle spans ~84% of
|
||||
* the page width. These tests pin the split-screen / small-window layout to the same width share
|
||||
* instead of letting the circle shrink with the window height.
|
||||
*/
|
||||
class RadarFillLayoutTest {
|
||||
|
||||
/** Circle diameter as a share of the page width (radar canvas rule: 0.95 * min(width, height)). */
|
||||
private fun circleShare(width: Dp, height: Dp, sizes: RadarFillSizes): Float {
|
||||
val availableHeight = if (sizes.pagerOverlaid) height else sizes.radarSide
|
||||
return 0.95f * min(width.value, availableHeight.value) / width.value
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullScreenVerticalKeepsClassicSplit() {
|
||||
// 411 x 867dp full screen: the 1:1 split already gives the circle ~87% of the width,
|
||||
// so the full-screen layout must not change.
|
||||
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 411.dp, maxHeight = 867.dp))
|
||||
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 393.dp, maxHeight = 804.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitScreenAndSmallWindowsUseFillLayout() {
|
||||
assertTrue(useFillRadarLayout(true, 411.dp, 426.dp)) // portrait split-screen half
|
||||
assertTrue(useFillRadarLayout(true, 411.dp, 500.dp)) // floating small window
|
||||
assertTrue(useFillRadarLayout(true, 411.dp, 676.dp)) // 70/30 split
|
||||
assertTrue(useFillRadarLayout(true, 393.dp, 560.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wideWindowsKeepSideBySideLayout() {
|
||||
assertFalse(useFillRadarLayout(isVertical = false, maxWidth = 700.dp, maxHeight = 400.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fillLayoutKeepsCircleAtFullScreenWidthShare() {
|
||||
// Previously these rendered the circle at ~36% of the page width.
|
||||
val cases = listOf(
|
||||
411.dp to 426.dp,
|
||||
411.dp to 500.dp,
|
||||
393.dp to 560.dp,
|
||||
411.dp to 676.dp
|
||||
)
|
||||
cases.forEach { (width, height) ->
|
||||
val share = circleShare(width, height, radarFillSizes(width, height))
|
||||
assertTrue("circle is only ${share * 100}% of the page width at ${width.value}x${height.value}",
|
||||
share >= 0.84f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classicSplitWouldHaveSqueezedTheCircle() {
|
||||
// Regression guard documenting why the fill layout exists: the old compact branch left
|
||||
// a 411 x 156dp card, i.e. a circle at 36% of the page width, in the same split window.
|
||||
val classicCompactCardHeight = 426.dp - 96.dp - 132.dp - 18.dp
|
||||
val classicCompactShare = 0.95f * classicCompactCardHeight.value / 411f
|
||||
assertTrue(classicCompactShare < 0.45f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagerIsOverlaidOnlyWhenAReservedBlockWouldBeUseless() {
|
||||
val shortWindow = radarFillSizes(411.dp, 426.dp)
|
||||
assertTrue(shortWindow.pagerOverlaid)
|
||||
assertTrue(shortWindow.radarSide == 411.dp)
|
||||
|
||||
val tallWindow = radarFillSizes(411.dp, 676.dp)
|
||||
assertFalse(tallWindow.pagerOverlaid)
|
||||
assertEquals(411f, tallWindow.radarSide.value, 0.01f)
|
||||
assertEquals(259f, tallWindow.pagerSpace.value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pagerBlockIsKeptWhileTheCircleStaysAtTheFullScreenShare() {
|
||||
// 411 x 468dp: reserving 96dp for the pager still leaves the circle at ~85% of the page
|
||||
// width, so the pager stays as a real block instead of folding into the overlay strip.
|
||||
val sizes = radarFillSizes(411.dp, 468.dp)
|
||||
assertFalse(sizes.pagerOverlaid)
|
||||
assertEquals(366f, sizes.radarSide.value, 0.01f)
|
||||
assertEquals(96f, sizes.pagerSpace.value, 0.01f)
|
||||
val share = circleShare(411.dp, 468.dp, sizes)
|
||||
assertTrue("circle is only ${share * 100}% of the page width", share >= 0.84f)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -93,6 +93,7 @@ fun LoTWUploadConfigDialog(
|
||||
busy: Boolean,
|
||||
error: LoTWUploadError?,
|
||||
errorDetail: String = "",
|
||||
initialGrid: String = "",
|
||||
onDismiss: () -> Unit,
|
||||
onImport: (ByteArray, CharArray) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
@@ -100,7 +101,9 @@ fun LoTWUploadConfigDialog(
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var selectedFile by remember { mutableStateOf<Uri?>(null) }
|
||||
var grid by remember { mutableStateOf(station?.grid.orEmpty()) }
|
||||
// A grid Finder prefill (e.g. "OL62,OL72" for a line, four grids for a
|
||||
// corner) wins over the saved station grid; otherwise keep what TQSL had.
|
||||
var grid by remember { mutableStateOf(initialGrid.ifBlank { station?.grid.orEmpty() }) }
|
||||
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
|
||||
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
|
||||
var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
|
||||
/**
|
||||
* Full-screen LoTW upload certificate + station location page.
|
||||
*
|
||||
* Reached from the Grid Finder's "Set as LoTW station location" button: the
|
||||
* grids the operator currently stands on (1 inside, 2 on a line, 4 on a corner)
|
||||
* are dropped into [container.pendingLoTWStationGrid] and consumed here as the
|
||||
* station grid prefill, so the next upload stamps those VUCC squares instead of
|
||||
* the certificate's home grid.
|
||||
*/
|
||||
@Composable
|
||||
fun LoTWUploadDestination(navigateUp: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
// Consume the prefill once; re-entering the page later keeps the saved grid.
|
||||
val initialGrid = container.pendingLoTWStationGrid.value.orEmpty()
|
||||
LaunchedEffect(Unit) {
|
||||
container.setPendingLoTWStationGrid(null)
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
LoTWUploadConfigDialog(
|
||||
certificate = uiState.lotwCertificate,
|
||||
station = uiState.lotwStation,
|
||||
stationMeta = uiState.lotwStationMeta,
|
||||
busy = uiState.lotwUploadBusy,
|
||||
error = uiState.lotwUploadError,
|
||||
errorDetail = uiState.lotwUploadErrorDetail,
|
||||
initialGrid = initialGrid,
|
||||
onDismiss = navigateUp,
|
||||
onImport = { bytes, password ->
|
||||
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
|
||||
},
|
||||
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
-7
@@ -26,7 +26,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -38,7 +41,12 @@ import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.frequencyBand
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
@@ -70,17 +78,21 @@ fun LogbookCard(recordCount: Int, showLogbookDialog: () -> Unit) {
|
||||
@Composable
|
||||
fun LogbookDialog(
|
||||
records: List<QsoRecord>,
|
||||
satelliteCandidates: List<String>,
|
||||
uploadBusy: Boolean,
|
||||
uploadMessage: String,
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onEdit: (QsoRecord) -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onConfirmUpload: () -> Unit,
|
||||
onDismissPreview: () -> Unit,
|
||||
onDismissMessage: () -> Unit
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Record currently open in the edit dialog (null when closed).
|
||||
var editTarget by remember { mutableStateOf<QsoRecord?>(null) }
|
||||
SharedDialog(
|
||||
title = stringResource(R.string.prefs_logbook_title),
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -101,7 +113,12 @@ fun LogbookDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(records, key = { it.id }) { record ->
|
||||
LogbookRow(record, swipeController, onDelete = { onDelete(record.id) })
|
||||
LogbookRow(
|
||||
record = record,
|
||||
swipeController = swipeController,
|
||||
onDelete = { onDelete(record.id) },
|
||||
onClick = { editTarget = record }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,13 +136,27 @@ fun LogbookDialog(
|
||||
if (uploadMessage.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissMessage,
|
||||
title = { Text("LoTW Upload") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("LoTW Upload") },
|
||||
text = { Text(uploadMessage) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissMessage) { Text("OK") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { target ->
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
satelliteCandidates = satelliteCandidates,
|
||||
onDismiss = { editTarget = null },
|
||||
onSave = { updated ->
|
||||
onEdit(updated)
|
||||
editTarget = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -137,7 +168,9 @@ private fun LogbookUploadPreviewDialog(
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.prefs_logbook_upload_title)) },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.prefs_logbook_upload_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("${preview.callsign} DXCC ${preview.dxcc} Grid ${preview.grid}", fontSize = 13.sp)
|
||||
@@ -145,6 +178,22 @@ private fun LogbookUploadPreviewDialog(
|
||||
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
|
||||
fontSize = 13.sp
|
||||
)
|
||||
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
|
||||
val parts = buildList {
|
||||
val alreadyUploaded = preview.skipped - preview.duplicateSkipped - preview.unknownSkipped
|
||||
if (alreadyUploaded > 0) add("$alreadyUploaded already uploaded")
|
||||
if (preview.duplicateSkipped > 0) add("${preview.duplicateSkipped} duplicate")
|
||||
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
|
||||
if (preview.unavailableSkipped > 0) add(
|
||||
unavailableUploadSummary(
|
||||
preview.unavailableSkipped,
|
||||
preview.unavailableReasons,
|
||||
duplicates = preview.duplicateSkipped
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
@@ -160,7 +209,12 @@ private fun LogbookUploadPreviewDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDelete: () -> Unit) {
|
||||
private fun LogbookRow(
|
||||
record: QsoRecord,
|
||||
swipeController: SwipeController,
|
||||
onDelete: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val time = remember(record.startUtcMillis) {
|
||||
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
@@ -180,6 +234,7 @@ private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDe
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -187,15 +242,15 @@ private fun LogbookRow(record: QsoRecord, swipeController: SwipeController, onDe
|
||||
text = record.theirCallsign,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFFFFE082),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (record.lotwConfirmed) {
|
||||
Text(text = "QSL", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
} else if (record.lotwUploaded) {
|
||||
Text(text = "UP", fontSize = 13.sp, color = Color(0xFFFFE082), fontFamily = FontFamily.Monospace)
|
||||
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.rtbishop.look4sat.core.presentation.ProjectWikiBody
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
|
||||
/** Settings → Project Wiki: the same content as the What's-New card, in a dialog. */
|
||||
@Composable
|
||||
fun ProjectWikiDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.prefs_project_wiki)) },
|
||||
text = {
|
||||
ProjectWikiBody(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+85
-33
@@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -88,18 +89,26 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SettingsDestination() {
|
||||
fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
SettingsScreen(uiState, viewModel::onAction)
|
||||
SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
private fun SettingsScreen(
|
||||
uiState: SettingsState,
|
||||
onAction: (SettingsAction) -> Unit,
|
||||
onOpenGridFinder: () -> Unit
|
||||
) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
var showMapSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var showProjectWiki by rememberSaveable { mutableStateOf(false) }
|
||||
if (showProjectWiki) {
|
||||
ProjectWikiDialog(onDismiss = { showProjectWiki = false })
|
||||
}
|
||||
if (showUpdateChecker) {
|
||||
UpdateCheckerScreen(
|
||||
currentVersion = uiState.appVersionName,
|
||||
@@ -264,11 +273,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
if (dialogs.logbook) {
|
||||
LogbookDialog(
|
||||
records = uiState.logbookRecords,
|
||||
satelliteCandidates = uiState.satelliteCatalog,
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
preview = uiState.logbookPreview,
|
||||
onDismiss = { dialogs.logbook = false },
|
||||
onDelete = { onAction(SettingsAction.DeleteLogbookRecord(it)) },
|
||||
onEdit = { onAction(SettingsAction.UpdateLogbookRecord(it)) },
|
||||
onUpload = { onAction(SettingsAction.PrepareLogbookUpload) },
|
||||
onConfirmUpload = { onAction(SettingsAction.ConfirmLogbookUpload) },
|
||||
onDismissPreview = { onAction(SettingsAction.DismissLogbookPreview) },
|
||||
@@ -292,10 +303,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
|
||||
// URLs for top bar
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val appUrl = stringResource(R.string.prefs_app_url)
|
||||
val donateUrl = stringResource(R.string.prefs_donate_url)
|
||||
val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
|
||||
val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
|
||||
val upstreamTitle = stringResource(R.string.prefs_upstream_title)
|
||||
val upstreamUrl = stringResource(R.string.prefs_upstream_url)
|
||||
val gitHubTitle = stringResource(R.string.prefs_github_title)
|
||||
val gitHubUrl = stringResource(R.string.prefs_github_url)
|
||||
val licenseUrl = stringResource(R.string.prefs_license_url)
|
||||
@@ -309,18 +318,17 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
if (isVerticalLayout) {
|
||||
TopBar {
|
||||
TopCard(
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
onClick = { showUpdateChecker = true },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
}
|
||||
TopBar {
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(fdroidUrl) },
|
||||
resId = R.drawable.ic_fdroid,
|
||||
text = fdroidTitle,
|
||||
onClick = { safeOpenUri(upstreamUrl) },
|
||||
resId = R.drawable.ic_github,
|
||||
text = upstreamTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
BotCard(
|
||||
@@ -335,17 +343,16 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
}
|
||||
} else {
|
||||
TopBar {
|
||||
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
|
||||
TopCard(
|
||||
onClick = { safeOpenUri(appUrl) },
|
||||
onClick = { showUpdateChecker = true },
|
||||
version = uiState.appVersionName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
BotCard(
|
||||
onClick = { safeOpenUri(fdroidUrl) },
|
||||
resId = R.drawable.ic_fdroid,
|
||||
text = fdroidTitle,
|
||||
onClick = { safeOpenUri(upstreamUrl) },
|
||||
resId = R.drawable.ic_github,
|
||||
text = upstreamTitle,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
BotCard(
|
||||
@@ -375,6 +382,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
showPosDialog = { dialogs.position = true },
|
||||
showLocDialog = { dialogs.locator = true },
|
||||
dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) },
|
||||
openGridFinder = onOpenGridFinder,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
@@ -422,6 +430,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
)
|
||||
}
|
||||
item { MapSettingsCard(onClick = { showMapSettings = true }) }
|
||||
item { ProjectWikiCard(onClick = { showProjectWiki = true }) }
|
||||
item { CardCredits() }
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
CardButton(
|
||||
@@ -439,7 +448,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
private fun LocationCardPreview() = MainTheme {
|
||||
val stationPos = GeoPos(0.0, 0.0, 0.0, "IO91vl", 0L)
|
||||
val settings = PositionSettings(true, stationPos, 0)
|
||||
LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}) {}
|
||||
LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}, {}) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -449,6 +458,7 @@ private fun LocationCard(
|
||||
showPosDialog: () -> Unit,
|
||||
showLocDialog: () -> Unit,
|
||||
dismissPosMessage: () -> Unit,
|
||||
openGridFinder: () -> Unit,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -491,6 +501,12 @@ private fun LocationCard(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CardButton(
|
||||
onClick = openGridFinder,
|
||||
text = stringResource(id = R.string.gridfinder_title),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
if (settings.messageResId != 0) {
|
||||
@@ -639,7 +655,7 @@ private fun OtherCard(
|
||||
ElevatedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(370.dp)
|
||||
.height(420.dp) // 370dp + one switch row (light theme)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
@@ -666,7 +682,16 @@ private fun OtherCard(
|
||||
text = stringResource(R.string.prefs_compass_calibration_button),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
SwitchRow(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) {
|
||||
// Appearance: light theme + the red night filter (mutually exclusive —
|
||||
// the night filter is disabled while the light theme is on).
|
||||
SwitchRow(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
|
||||
onAction(SettingsAction.ToggleLightTheme(it))
|
||||
}
|
||||
SwitchRow(
|
||||
R.string.prefs_other_switch_night_mode,
|
||||
settings.stateOfNightMode,
|
||||
enabled = !settings.stateOfLightTheme
|
||||
) {
|
||||
onAction(SettingsAction.ToggleNightMode(it))
|
||||
}
|
||||
}
|
||||
@@ -762,14 +787,19 @@ private fun CompassCalibrationDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
private fun SwitchRow(
|
||||
labelResId: Int,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(id = labelResId))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,33 +869,55 @@ private fun UpdateIndicator(isUpdating: Boolean, modifier: Modifier = Modifier)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectWikiCard(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clickable { onClick() }) {
|
||||
Spacer(Modifier)
|
||||
Icon(painter = painterResource(id = R.drawable.ic_policy), contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_project_wiki),
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CardCreditsPreview() = MainTheme { CardCredits() }
|
||||
|
||||
@Composable
|
||||
private fun CardCredits(modifier: Modifier = Modifier) {
|
||||
ElevatedCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(268.dp)
|
||||
) {
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.fillMaxHeight()
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_thanks)
|
||||
text = stringResource(id = R.string.prefs_outro_notice),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
HorizontalDivider()
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_thanks),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_outro_license),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ data class SettingsState(
|
||||
val lotwLastSyncEpochMs: Long = 0L,
|
||||
/** Logbook (QSO records + LoTW confirmations), newest first. */
|
||||
val logbookRecords: List<com.rtbishop.look4sat.core.domain.logbook.QsoRecord> = emptyList(),
|
||||
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
||||
val satelliteCatalog: List<String> = emptyList(),
|
||||
/** Imported LoTW upload certificate (null when none). */
|
||||
val lotwCertificate: com.rtbishop.look4sat.core.domain.repository.LoTWCertificate? = null,
|
||||
/** LoTW upload station location (null when unset). */
|
||||
@@ -153,6 +155,8 @@ sealed interface SettingsAction {
|
||||
// Logbook (QSO records + LoTW confirmations)
|
||||
data object RefreshLogbook : SettingsAction
|
||||
data class DeleteLogbookRecord(val id: Long) : SettingsAction
|
||||
/** Persist an edited record (frequency/callsign/time/…) from the logbook dialog. */
|
||||
data class UpdateLogbookRecord(val record: com.rtbishop.look4sat.core.domain.logbook.QsoRecord) : SettingsAction
|
||||
data object PrepareLogbookUpload : SettingsAction
|
||||
data object ConfirmLogbookUpload : SettingsAction
|
||||
data object DismissLogbookPreview : SettingsAction
|
||||
|
||||
+32
-4
@@ -163,6 +163,15 @@ class SettingsViewModel(
|
||||
_uiState.update { it.copy(logbookRecords = records) }
|
||||
}
|
||||
}
|
||||
// ARRL satellite names for the logbook edit dialog's satellite picker.
|
||||
viewModelScope.launch {
|
||||
val catalog = runCatching { lotwUploadRepository.satelliteCatalog() }.getOrDefault(emptyList())
|
||||
_uiState.update { it.copy(satelliteCatalog = catalog) }
|
||||
}
|
||||
// Load the LoTW upload certificate + station once at startup so the
|
||||
// settings card reflects the real state on first frame (previously it
|
||||
// stayed "not imported" until the config dialog was opened).
|
||||
loadLoTWUploadStatus()
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +202,15 @@ class SettingsViewModel(
|
||||
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.ToggleLightTheme -> settingsRepo.updateOtherSettings {
|
||||
// The red night filter is a dark-screen aid; turning the light theme on
|
||||
// switches it off so the UI isn't red-on-white. Turning the light theme
|
||||
// off leaves the filter as the user set it (off).
|
||||
it.copy(
|
||||
stateOfLightTheme = action.value,
|
||||
stateOfNightMode = if (action.value) false else it.stateOfNightMode
|
||||
)
|
||||
}
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.UpdateMapSettings -> settingsRepo.updateOtherSettings {
|
||||
it.copy(mapSource = action.mapSource, tiandituKey = action.tiandituKey)
|
||||
@@ -212,6 +229,7 @@ class SettingsViewModel(
|
||||
// Logbook
|
||||
SettingsAction.RefreshLogbook -> refreshLogbook()
|
||||
is SettingsAction.DeleteLogbookRecord -> viewModelScope.launch { qsoRepository.delete(action.id) }
|
||||
is SettingsAction.UpdateLogbookRecord -> viewModelScope.launch { qsoRepository.save(action.record) }
|
||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||
@@ -360,14 +378,24 @@ class SettingsViewModel(
|
||||
val all = qsoRepository.records.first()
|
||||
// Only local (non-confirmed) records are candidates for upload;
|
||||
// LoTW-imported confirmations are the feedback side.
|
||||
val pending = all.filter { !it.lotwConfirmed && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
val pending = all.filter { !it.lotwConfirmed && !it.lotwUploaded && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
|
||||
val audit = lotwUploadRepository.audit(pending)
|
||||
if (audit.pending == 0) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
|
||||
val msg = when {
|
||||
audit.unavailable > 0 -> com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary(
|
||||
audit.unavailable, audit.reasons, audit.details, audit.duplicates, audit.incomplete
|
||||
)
|
||||
|
||||
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
|
||||
else -> "No pending QSOs to upload"
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(pending, false)
|
||||
lastLogbookUploadIds = pending.map { it.id }
|
||||
// Only the records that actually made it into the TQ8 may be
|
||||
// marked uploaded later — never the whole candidate list.
|
||||
lastLogbookUploadIds = preview.submittedIds
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
||||
+18
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* Look4Sat-BA7OPF. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2026 BA7OPF.
|
||||
* Based on Look4Sat by Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.status
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user