mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd1459ddf | ||
|
|
56a788138b | ||
|
|
2e704ee1d3 | ||
|
|
5b7c7d6df5 | ||
|
|
d13af978af | ||
|
|
b48e23804f | ||
|
|
a2b3ce5d69 | ||
|
|
6ef994e4cd | ||
|
|
4c3fdeaa9c |
No files matched your search
@@ -0,0 +1,46 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Look4Sat-BA7OPF (this fork) bundles or adapts the following third-party
|
||||
components:
|
||||
|
||||
## OrbitDeckiOS
|
||||
|
||||
- Source: https://github.com/prstoetzer/OrbitDeckiOS
|
||||
- Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||
- License: MIT
|
||||
- Used for: the Grid Finder feature — the VUCC grid line / grid point
|
||||
geometry in `core/domain/.../utility/GridGeometry.kt` is ported from
|
||||
OrbitDeckiOS' GridGeometry.
|
||||
|
||||
### MIT License
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Paul Stoetzer, N8HM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Material Design Icons
|
||||
|
||||
- Source: https://materialdesignicons.com (Pictogrammers)
|
||||
- License: Apache-2.0
|
||||
- Used for: `core/presentation/src/main/res/drawable/ic_grid_finder.xml`
|
||||
(crosshairs-gps icon). Attribution is also noted in the icon file header.
|
||||
@@ -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> {
|
||||
@@ -140,7 +147,26 @@ fun NavRoot(deeplink: String? = null) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
RadarDestination(navigateUp = navigateBack)
|
||||
RadarDestination(navigateUp = navigateBack, onOpenLoTWStation = navigateToLoTWStation)
|
||||
}
|
||||
}
|
||||
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"))
|
||||
|
||||
@@ -20,7 +20,7 @@ import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||
* never need a migration of the satellite database.
|
||||
*/
|
||||
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||
@Database(entities = [QsoEntity::class], version = 3, exportSchema = false)
|
||||
abstract class QsoDatabase : RoomDatabase() {
|
||||
abstract fun qsoDao(): QsoDao
|
||||
}
|
||||
@@ -31,3 +31,10 @@ val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
/** v2 → v3: keep the OPPOSITE station's grid set (multi-grid confirmations as LoTW reports them). */
|
||||
val MIGRATION_QSO_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE qso_records ADD COLUMN theirVuccGrids TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ data class QsoEntity(
|
||||
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||
@ColumnInfo(defaultValue = "''") val theirVuccGrids: String = "",
|
||||
val dxcc: Int? = null,
|
||||
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||
val cqZone: Int? = null,
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
*/
|
||||
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
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_2_3
|
||||
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
||||
@@ -43,6 +47,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 +64,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
|
||||
@@ -98,7 +104,7 @@ class MainContainer(private val context: Context) : IMainContainer {
|
||||
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||
override val qsoRepository: IQsoRepository by lazy {
|
||||
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||
.addMigrations(MIGRATION_QSO_1_2)
|
||||
.addMigrations(MIGRATION_QSO_1_2, MIGRATION_QSO_2_3)
|
||||
.build()
|
||||
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||
}
|
||||
@@ -107,13 +113,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)
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ internal class LoTWSigner(private val config: LoTWConfig) {
|
||||
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||
// A record dated after "now" gets its own message: it happens when the operator
|
||||
// logged against a pass that had not started yet (out-of-window flow), and "fix the
|
||||
// time" is the actionable advice. Reporting it as "outside the certificate" sent one
|
||||
// operator hunting the certificate while the culprit was the record's future time.
|
||||
if (record.startUtcMillis > now) {
|
||||
fail(LoTWProblem.QSO_FUTURE, "$call @ ${utc(record.startUtcMillis, "MM-dd HH:mm'Z'")}")
|
||||
}
|
||||
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate)) {
|
||||
fail(LoTWProblem.QSO_DATE, call)
|
||||
}
|
||||
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||
|
||||
+10
-1
@@ -279,6 +279,13 @@ class LoTWUploadRepository internal constructor(
|
||||
else -> contact
|
||||
}
|
||||
}
|
||||
// The station-location grids this batch is signed with: the primary grid plus the
|
||||
// MY_VUCC_GRIDS set (1 inside a grid, 2 on a line, 4 on a corner — see [LoTWConfig]).
|
||||
val locationGrids = buildList {
|
||||
signing.location["GRIDSQUARE"]?.takeIf(String::isNotBlank)?.let(::add)
|
||||
signing.location["MY_VUCC_GRIDS"]?.split(',')
|
||||
?.map(String::trim)?.filter(String::isNotBlank)?.let(::addAll)
|
||||
}.distinct()
|
||||
val preview = LoTWUploadPreview(
|
||||
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||
contacts.size, skipped,
|
||||
@@ -289,7 +296,9 @@ class LoTWUploadRepository internal constructor(
|
||||
unavailable,
|
||||
reasons.toMap(),
|
||||
duplicates,
|
||||
contacts.map { it.record.id }
|
||||
contacts.map { it.record.id },
|
||||
locationGrids,
|
||||
resubmit
|
||||
)
|
||||
if (contacts.isNotEmpty()) pending = Pending(
|
||||
preview,
|
||||
|
||||
+3
-1
@@ -285,7 +285,9 @@ class LoTWRepository : ILoTWRepository {
|
||||
myGrid = myGrids.firstOrNull(),
|
||||
myGrids = myGrids,
|
||||
myCallsign = myCallsign,
|
||||
stationKey = stationKey
|
||||
stationKey = stationKey,
|
||||
// Copy, never the live buffer — resetRecord() clears it.
|
||||
theirGrids = gridsInRecord.distinct().toList()
|
||||
)
|
||||
for (grid in gridsInRecord) {
|
||||
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
+44
-21
@@ -16,8 +16,11 @@ 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.bandsMatchOrMirror
|
||||
import com.rtbishop.look4sat.core.domain.logbook.contentScore
|
||||
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||
import com.rtbishop.look4sat.core.domain.logbook.sameContactIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.satelliteIdentity
|
||||
import com.rtbishop.look4sat.core.domain.logbook.splitConfirmationPairs
|
||||
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||
@@ -54,14 +57,14 @@ class QsoRepository(
|
||||
dao.save(record.copy(
|
||||
id = 0L,
|
||||
lotwUploaded = false, lotwConfirmed = false, lotwReceived = false,
|
||||
lotwQslDate = "", vuccGrids = emptyList(),
|
||||
lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = 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(),
|
||||
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(), theirVuccGrids = emptyList(),
|
||||
dxcc = null, country = "", cqZone = null, region = ""
|
||||
) else record
|
||||
val received = if (previous != null && (
|
||||
@@ -76,9 +79,18 @@ class QsoRepository(
|
||||
|
||||
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||
|
||||
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||
override suspend fun markUploaded(ids: List<Long>, grids: List<String>) = withContext(dispatcher) {
|
||||
if (ids.isEmpty()) return@withContext
|
||||
dao.markUploaded(ids)
|
||||
// Stamp the station grid set this batch went out under (a line/corner prefill from
|
||||
// the grid finder carries 2–4 grids). The logbook row shows it after the timestamp.
|
||||
val normalized = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (normalized.isEmpty()) return@withContext
|
||||
val stamped = ids.mapNotNull { id ->
|
||||
dao.find(id)?.toDomain()?.takeIf { it.vuccGrids != normalized }?.copy(vuccGrids = normalized)
|
||||
}
|
||||
if (stamped.isNotEmpty()) dao.saveBatch(stamped.map { it.toEntity() })
|
||||
}
|
||||
|
||||
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||
@@ -132,9 +144,7 @@ class QsoRepository(
|
||||
val renames = rewriteOfficialNames(working)
|
||||
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val keyIndex = working.indices.groupBy { stableQsoKey(working[it]) }
|
||||
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||
val knownKeys = keyIndex.keys.toMutableSet()
|
||||
val knownKeys = working.map { stableQsoKey(it) }.toMutableSet()
|
||||
val changes = linkedMapOf<Int, QsoRecord>().apply {
|
||||
putAll(consolidation.merged)
|
||||
putAll(renames)
|
||||
@@ -149,27 +159,38 @@ 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++
|
||||
// A confirmation downloaded by an early release sits as its own row with the
|
||||
// band direction read mirrored (the downlink was stored as the uplink). The
|
||||
// strict lookup above cannot see it: its bands fail the comparison, and its
|
||||
// dedupe key can differ too (fields written by older releases — timestamp
|
||||
// precision, FT4 sub-mode spelling, missing callsign — so it is not treated
|
||||
// as known either). Fold the report into the richest mirrored row instead of
|
||||
// inserting a second row next to it; left as two rows, neither consolidation
|
||||
// nor a later sync could reliably heal them.
|
||||
val mirroredRows = if (fromLoTW) lookup[remote.confirmationLookupKey()].orEmpty().filter { index ->
|
||||
working[index].band.isNotBlank() && remote.band.isNotBlank() &&
|
||||
!working[index].band.equals(remote.band, true) &&
|
||||
sameContactIdentity(working[index], remote) &&
|
||||
bandsMatchOrMirror(working[index], remote)
|
||||
} else emptyList()
|
||||
if (mirroredRows.isNotEmpty()) {
|
||||
val target = mirroredRows.maxBy { contentScore(working[it]) }
|
||||
mirroredRows.forEach { index ->
|
||||
val healed = working[index].copy(
|
||||
band = remote.band.ifBlank { working[index].band },
|
||||
rxBand = remote.rxBand.ifBlank { working[index].rxBand }
|
||||
)
|
||||
val folded = if (index == target) healed.withConfirmation(remote) else healed
|
||||
working[index] = folded
|
||||
changes[index] = folded
|
||||
}
|
||||
updated += mirroredRows.size
|
||||
return@forEach
|
||||
}
|
||||
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||
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++
|
||||
@@ -268,6 +289,7 @@ private fun QsoEntity.toDomain() = QsoRecord(
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||
theirVuccGrids = theirVuccGrids.split(',').filter(String::isNotBlank),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
@@ -304,6 +326,7 @@ private fun QsoRecord.toEntity() = QsoEntity(
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
|
||||
@@ -468,6 +468,34 @@ class SettingsRepo(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getCurrentGrid(): String? {
|
||||
val station = _stationPosition.value
|
||||
val now = System.currentTimeMillis()
|
||||
// Only trust a location fix that is both recent (≤ 24 h) and newer than the stored
|
||||
// station, so a stale fix can never override a position the operator just set.
|
||||
val fix = lastKnownGridFix()?.takeIf { now - it.second <= 24 * 3_600_000L }
|
||||
val chosen = if (fix != null && fix.second >= station.timestamp) fix.first else station.qthLocator
|
||||
return chosen.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
/** Last known GPS/NETWORK fix converted to a locator; read-only, null without permission. */
|
||||
private fun lastKnownGridFix(): Pair<String, Long>? {
|
||||
return try {
|
||||
val provider = when {
|
||||
LocationManagerCompat.hasProvider(locationManager, providerGps) -> providerGps
|
||||
LocationManagerCompat.hasProvider(locationManager, providerNet) -> providerNet
|
||||
else -> return null
|
||||
}
|
||||
val location = locationManager.getLastKnownLocation(provider) ?: return null
|
||||
val locator = positionToQth(location.latitude, location.longitude) ?: return null
|
||||
locator to location.time
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStationPosition(): GeoPos {
|
||||
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
|
||||
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
|
||||
|
||||
@@ -271,6 +271,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
||||
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
|
||||
override fun getCurrentGrid(): String? = null
|
||||
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
|
||||
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||
|
||||
+23
@@ -167,6 +167,29 @@ class LoTWRepositoryTest {
|
||||
result.values.forEach { assertEquals(1, it.size) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosCapturesOppositeGridSet() {
|
||||
// The opposite station's grids (GRIDSQUARE + VUCC_GRIDS) must ride on the
|
||||
// GridQso so the logbook can show multi-grid confirmations in its QSL slot.
|
||||
val single = "<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("NL47"),
|
||||
repo.parseConfirmedGridQsos(report(single))!!["NL47"]!!.first().theirGrids
|
||||
)
|
||||
val pair = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(pair))!!["EN52"]!!.first().theirGrids
|
||||
)
|
||||
// A 6-char GRIDSQUARE plus a VUCC pair: every field, truncated to 4 chars, in order.
|
||||
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||
"<GRIDSQUARE:6>OM60IL\n<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||
assertEquals(
|
||||
listOf("OM60", "EN52", "EN53"),
|
||||
repo.parseConfirmedGridQsos(report(six))!!["OM60"]!!.first().theirGrids
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseQsosRejectsBodyWithoutEoh() {
|
||||
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||
|
||||
+106
-1
@@ -52,6 +52,9 @@ class QsoRepositoryTest {
|
||||
// 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)
|
||||
// The opposite station's grid set from the report lands on the row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
assertEquals("EN52,EN53", rows.first().theirVuccGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,6 +127,106 @@ class QsoRepositoryTest {
|
||||
assertEquals(setOf("SO-50", "FOO-1"), dao.getAll().map { it.satelliteName }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consolidateConfirmations_foldsADoubledConfirmedPairKeepingTheRicherRow() = runBlocking {
|
||||
// The mirrored import the old parser created (no frequencies, no report-side grids —
|
||||
// the pre-fix parser filled neither — band direction reversed)... seeded FIRST so it
|
||||
// carries the lower id.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
.toEntity()
|
||||
)
|
||||
// ...plus the app row that was confirmed on its own later (frequencies, upload state).
|
||||
val localId = dao.save(
|
||||
loggedInApp("SO-50", lotwUploaded = true).copy(lotwConfirmed = true).toEntity()
|
||||
)
|
||||
|
||||
assertEquals(1, repository.consolidateConfirmations())
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(localId, rows.first().id)
|
||||
// The surviving row keeps the correct uplink-first bands and gains the report fills.
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertEquals("OL62", rows.first().myGrid)
|
||||
assertEquals(318, rows.first().dxcc)
|
||||
assertEquals("China", rows.first().country)
|
||||
assertTrue(rows.first().lotwUploaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsADoubledMirroredContact() = runBlocking {
|
||||
// Two mirrored copies of one confirmation (as the pre-fix parser imported on separate
|
||||
// syncs — no frequencies, no report-side grids): the full sync must consolidate them
|
||||
// into one row with the report's direction.
|
||||
val stale = reportConfirmation(satName = "SO-50")
|
||||
.copy(band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList())
|
||||
dao.save(stale.toEntity())
|
||||
dao.save(stale.copy(id = 0L).toEntity())
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
// The report's fills land on the healed row.
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_healsAMirroredRowWhoseDedupeKeyDivergedFromTheReport() = runBlocking {
|
||||
// The doubled contacts of the reported bug: a mirrored row written by an older release
|
||||
// (millisecond-precision timestamp here — enough to make the dedupe key, which holds
|
||||
// the exact time, differ from the report's) used to gain a second row next to it
|
||||
// instead of being healed, because every net relied on the key or the strict bands.
|
||||
val staleId = dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", theirGrid = "", theirVuccGrids = emptyList(),
|
||||
startUtcMillis = qsoStart + 456L
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(listOf(reportConfirmation(satName = "SO-50")))
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(staleId, rows.first().id)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_foldsAMirroredRowSpelledAsPlainMfskIntoItsFt4Report() = runBlocking {
|
||||
// Releases that persisted no sub-mode stored FT4 rows as plain MFSK while the report
|
||||
// carries MFSK + FT4. The spelling difference must not block the heal either.
|
||||
dao.save(
|
||||
reportConfirmation(satName = "SO-50")
|
||||
.copy(
|
||||
band = "70CM", rxBand = "2M", mode = "MFSK", submode = "",
|
||||
theirGrid = "", theirVuccGrids = emptyList()
|
||||
)
|
||||
.toEntity()
|
||||
)
|
||||
|
||||
repository.mergeLoTW(
|
||||
listOf(reportConfirmation(satName = "SO-50").copy(mode = "MFSK", submode = "FT4"))
|
||||
)
|
||||
|
||||
val rows = dao.getAll()
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals("2M", rows.first().band)
|
||||
assertEquals("70CM", rows.first().rxBand)
|
||||
assertTrue(rows.first().lotwConfirmed)
|
||||
assertEquals("EN52", rows.first().theirGrid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeLoTW_fixesBandDirectionOfAStaleConfirmationRow() = runBlocking {
|
||||
// A confirmation downloaded by the pre-fix parser: ARRL name but the band direction
|
||||
@@ -221,7 +324,8 @@ class QsoRepositoryTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OL62",
|
||||
myGrids = setOf("OL62", "OL63")
|
||||
myGrids = setOf("OL62", "OL63"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
).toConfirmedRecord("BA7OPF")
|
||||
|
||||
private fun QsoRecord.toEntity() = QsoEntity(
|
||||
@@ -254,6 +358,7 @@ class QsoRepositoryTest {
|
||||
lotwReceived = lotwReceived,
|
||||
lotwQslDate = lotwQslDate,
|
||||
vuccGrids = vuccGrids.joinToString(","),
|
||||
theirVuccGrids = theirVuccGrids.joinToString(","),
|
||||
dxcc = dxcc,
|
||||
country = country,
|
||||
cqZone = cqZone,
|
||||
|
||||
+1
@@ -330,6 +330,7 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||
override fun setStationPosition(): Boolean = true
|
||||
override fun setStationPosition(locator: String): Boolean = true
|
||||
override fun getCurrentGrid(): String? = null
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
||||
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
||||
|
||||
|
||||
+6
-1
@@ -17,7 +17,12 @@ interface IQsoRepository {
|
||||
suspend fun find(id: Long): QsoRecord?
|
||||
suspend fun save(record: QsoRecord): Long
|
||||
suspend fun delete(id: Long)
|
||||
suspend fun markUploaded(ids: List<Long>)
|
||||
/**
|
||||
* Marks the batch as uploaded and stamps the station-location grids the batch was
|
||||
* signed with (1–4 gridsquares: inside a grid / on a line / on a corner), so the
|
||||
* logbook row can show which grids the QSO went out under.
|
||||
*/
|
||||
suspend fun markUploaded(ids: List<Long>, grids: List<String> = emptyList())
|
||||
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||
suspend fun importAdi(content: String): AdifImportResult
|
||||
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
||||
startUtcMillis = epochMs,
|
||||
theirCallsign = call,
|
||||
myCallsign = accountCallsign.trim().uppercase(),
|
||||
theirGrid = theirGrids.firstOrNull().orEmpty(),
|
||||
theirVuccGrids = theirGrids,
|
||||
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||
vuccGrids = myGrids.toList(),
|
||||
band = bandUp,
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ fun LoTWProblem.label(): String = when (this) {
|
||||
LoTWProblem.SATELLITE -> "satellite name not in ARRL's list"
|
||||
LoTWProblem.MODE -> "mode not accepted by LoTW"
|
||||
LoTWProblem.QSO_DATE -> "date outside the certificate"
|
||||
LoTWProblem.QSO_FUTURE -> "record time is in the future"
|
||||
LoTWProblem.CALLSIGN_MISMATCH -> "MY callsign does not match the certificate"
|
||||
LoTWProblem.INVALID_CONTACT -> "invalid callsign/record"
|
||||
LoTWProblem.STATION_GRID -> "station grid missing"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.domain.logbook
|
||||
|
||||
/**
|
||||
* The records a resubmit actually re-signs: whatever the operator checked, complete rows only.
|
||||
*
|
||||
* Unlike a normal upload this deliberately keeps already-uploaded and confirmed records —
|
||||
* the point is to send them again under a corrected station location, and LoTW treats an
|
||||
* identical contact (call/band/mode/time/satellite) as an update of the existing record.
|
||||
*/
|
||||
fun resubmitCandidates(records: List<QsoRecord>, selectedIds: Set<Long>): List<QsoRecord> =
|
||||
records.filter { it.id in selectedIds && it.status == QsoStatus.COMPLETE }
|
||||
+77
-16
@@ -80,7 +80,7 @@ fun sameContactIdentity(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)) &&
|
||||
local.isSatellite == remote.isSatellite &&
|
||||
local.displayMode == remote.displayMode &&
|
||||
local.matchableMode == remote.matchableMode &&
|
||||
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||
}
|
||||
|
||||
@@ -93,45 +93,100 @@ fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||
sameContactIdentity(local, remote) && sameBandOrBlank(local.band, remote.band)
|
||||
|
||||
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), displayMode
|
||||
theirCallsign.trim().uppercase(Locale.US), satelliteIdentity(satelliteName), matchableMode
|
||||
).joinToString("|")
|
||||
|
||||
/** A local row and the confirmation row that belongs to it, as indices into one list. */
|
||||
/** A row to keep and the duplicate row to fold into it (then delete), 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.
|
||||
* Rows that should have been one row but were 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.
|
||||
* Two known holes leave a contact stored twice:
|
||||
*
|
||||
* 1. 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 taken as the uplink) — so an unconfirmed local row and its confirmation stayed
|
||||
* apart. Pass 1 folds the confirmation into the local row.
|
||||
* 2. A contact can also end up with TWO confirmed rows: a mirrored import the old parser
|
||||
* created plus the row that was confirmed on its own later, or a duplicate import added
|
||||
* while ambiguous candidates made the match fail. Pass 1 cannot fold those — both sides
|
||||
* are confirmed — so pass 2 pairs confirmed rows with each other.
|
||||
*
|
||||
* Both passes accept a pair only when the contact identity matches and the bands are either
|
||||
* identical or exactly mirrored — the mirror being the fingerprint of the pre-fix parser —
|
||||
* which keeps unrelated QSOs of the same operator and minute apart. In pass 2 the row with
|
||||
* more content is kept (frequencies/grids/reports beat an empty mirrored import) and the
|
||||
* other one is folded into it, so no data is lost.
|
||||
*/
|
||||
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 pairs = mutableListOf<SplitConfirmationPair>()
|
||||
// Pass 1: fold a downloaded confirmation into the unconfirmed local row it belongs to.
|
||||
val pendingIndices = records.indices.filter { !records[it].lotwConfirmed }
|
||||
records.indices.filter { records[it].lotwConfirmed }.forEach { confirmationIndex ->
|
||||
val confirmation = records[confirmationIndex]
|
||||
val localIndex = pendingIndices.firstOrNull { index ->
|
||||
index !in taken && sameContactIdentity(records[index], confirmation) &&
|
||||
bandsMatchOrMirror(records[index], confirmation)
|
||||
} ?: return@mapNotNull null
|
||||
} ?: return@forEach
|
||||
taken += localIndex
|
||||
SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
taken += confirmationIndex
|
||||
pairs += SplitConfirmationPair(localIndex, confirmationIndex)
|
||||
}
|
||||
// Pass 2: two CONFIRMED rows describing the same contact (mirrored import + the confirmed
|
||||
// row it never folded into, or a duplicate import). Keep the richer row, drop the other.
|
||||
val confirmedLeft = records.indices.filter { records[it].lotwConfirmed && it !in taken }
|
||||
confirmedLeft.forEach { index ->
|
||||
if (index in taken) return@forEach
|
||||
val cluster = mutableListOf(index)
|
||||
confirmedLeft.forEach { candidate ->
|
||||
if (candidate != index && candidate !in taken &&
|
||||
cluster.all {
|
||||
sameContactIdentity(records[it], records[candidate]) &&
|
||||
bandsMatchOrMirror(records[it], records[candidate])
|
||||
}
|
||||
) {
|
||||
cluster += candidate
|
||||
}
|
||||
}
|
||||
if (cluster.size < 2) return@forEach
|
||||
cluster.forEach { taken += it }
|
||||
val keep = cluster.maxBy { contentScore(records[it]) }
|
||||
cluster.filter { it != keep }.forEach { pairs += SplitConfirmationPair(keep, it) }
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** More operator/report detail first — decides which row survives a pass-2 fold. */
|
||||
fun contentScore(record: QsoRecord): Int {
|
||||
var score = 0
|
||||
// Frequencies are the strongest operator-entered signal; a band label consistent with its
|
||||
// frequency (frequencyBand(tx) == band) beats the mirrored parser output, whose labels
|
||||
// contradict the side they describe.
|
||||
record.txFrequencyHz?.let { hz -> score += 2; if (record.band == frequencyBand(hz)) score += 1 }
|
||||
record.rxFrequencyHz?.let { hz -> score += 2; if (record.rxBand == frequencyBand(hz)) score += 1 }
|
||||
score += listOf(record.sentReport, record.receivedReport, record.myGrid, record.theirGrid, record.comment)
|
||||
.count { it.isNotBlank() }
|
||||
score += record.vuccGrids.size
|
||||
score += record.theirVuccGrids.size
|
||||
if (record.lotwUploaded) score += 1
|
||||
if (record.lotwReceived) score += 1
|
||||
if (record.dxcc != null) score += 1
|
||||
if (record.country.isNotBlank()) score += 1
|
||||
if (record.lotwQslDate.isNotBlank()) score += 1
|
||||
return score
|
||||
}
|
||||
|
||||
/** Same band on both sides, or the mirrored pair the pre-fix parser produced. */
|
||||
private fun bandsMatchOrMirror(local: QsoRecord, confirmation: QsoRecord): Boolean =
|
||||
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 },
|
||||
theirVuccGrids = confirmed.theirVuccGrids.ifEmpty { theirVuccGrids },
|
||||
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||
@@ -159,6 +214,12 @@ val QsoRecord.displayMode: String
|
||||
return label.trim().uppercase(Locale.US)
|
||||
}
|
||||
|
||||
/** The mode label as the merge compares it: satellite FT4 is written as MFSK + FT4, and
|
||||
* releases that dropped the sub-mode persisted plain MFSK — the same mode, spelled
|
||||
* differently, so both spellings must match. */
|
||||
val QsoRecord.matchableMode: String
|
||||
get() = displayMode.let { if (it == "MFSK") "FT4" else it }
|
||||
|
||||
val QsoRecord.isSatellite: Boolean
|
||||
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ data class QsoRecord(
|
||||
val theirCallsign: String,
|
||||
val myCallsign: String,
|
||||
val theirGrid: String = "",
|
||||
/** Every 4-char grid the OPPOSITE station logged this QSO under (ADIF
|
||||
* <GRIDSQUARE> + <VUCC_GRIDS>; 1–4 grids). [theirGrid] keeps the first;
|
||||
* the logbook's QSL slot shows the abbreviated set. */
|
||||
val theirVuccGrids: List<String> = emptyList(),
|
||||
val myGrid: String = "",
|
||||
val sentReport: String = "",
|
||||
val receivedReport: String = "",
|
||||
|
||||
@@ -72,7 +72,13 @@ data class GridQso(
|
||||
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
|
||||
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||
* its full grid set. Null for data synced before this field existed. */
|
||||
val stationKey: String? = null
|
||||
val stationKey: String? = null,
|
||||
/** Every 4-char grid of the OPPOSITE station for this QSO — ADIF
|
||||
* <GRIDSQUARE> plus every field of <VUCC_GRIDS> (comma-separated when
|
||||
* the contact spans several squares; up to four in one QSO). The logbook
|
||||
* shows the set in the QSL slot of a confirmed row; [myGrids] is the
|
||||
* same thing for the user's own side. */
|
||||
val theirGrids: List<String> = emptyList()
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
+37
-2
@@ -19,6 +19,7 @@
|
||||
package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||
import java.util.Locale
|
||||
|
||||
interface ILoTWUploadRepository {
|
||||
suspend fun certificate(): LoTWCertificate?
|
||||
@@ -96,7 +97,23 @@ data class LoTWUploadPreview(
|
||||
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()
|
||||
val submittedIds: List<Long> = emptyList(),
|
||||
/** The full station-location grid set this batch is signed with (1 inside a grid,
|
||||
* 2 on a line, 4 on a corner). Stamped onto the uploaded records so the logbook
|
||||
* can show which gridsquares the QSO went out under. */
|
||||
val grids: List<String> = emptyList(),
|
||||
/** True when this batch is a resubmit: already-uploaded/confirmed records were allowed
|
||||
* through so the corrected station location reaches LoTW as an update of the contact. */
|
||||
val resubmit: Boolean = false
|
||||
)
|
||||
|
||||
/** Pre-upload location check: the freshest known position grid is not covered by the
|
||||
* station grids an upload would be signed with. */
|
||||
data class LoTWPositionWarning(
|
||||
/** Normalized 4-character grid of the freshest known position. */
|
||||
val currentGrid: String,
|
||||
/** Distinct station-location grids the upload would be signed with. */
|
||||
val stationGrids: List<String>
|
||||
)
|
||||
|
||||
/** Why the un-uploadable records of a selection cannot be signed, and what was left out. */
|
||||
@@ -128,7 +145,25 @@ class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") :
|
||||
|
||||
enum class LoTWProblem {
|
||||
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, QSO_FUTURE, STATION_GRID,
|
||||
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Roaming check run before upload: warns when the operator's freshest known position
|
||||
* (see [ISettingsRepo.getCurrentGrid]) lies outside every grid of the station location the
|
||||
* batch would be signed with — the signature of operating while roaming with a station
|
||||
* location that was never updated. An unknown position, missing station grids, or a covered
|
||||
* position (boundary operations included) produce no warning.
|
||||
*/
|
||||
fun positionWarning(currentGrid: String?, stationGrids: List<String>): LoTWPositionWarning? {
|
||||
val current = currentGrid?.grid4() ?: return null
|
||||
val station = stationGrids.mapNotNull { it.grid4() }.toSet()
|
||||
if (station.isEmpty()) return null
|
||||
if (current in station) return null
|
||||
return LoTWPositionWarning(current, station.sorted())
|
||||
}
|
||||
|
||||
/** Normalized 4-character grid, null when the value is not a usable grid. */
|
||||
private fun String.grid4(): String? = trim().uppercase(Locale.US).take(4).takeIf { it.length >= 4 }
|
||||
+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
|
||||
|
||||
+6
@@ -48,6 +48,12 @@ interface ISettingsRepo {
|
||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||
fun setStationPosition(): Boolean
|
||||
fun setStationPosition(locator: String): Boolean
|
||||
/**
|
||||
* Freshest known position grid for the roaming check, read-only: the newer of a fresh
|
||||
* (≤ 24 h) system location fix and the stored station position. Null only when neither
|
||||
* yields a usable locator. Never writes the station position.
|
||||
*/
|
||||
fun getCurrentGrid(): String?
|
||||
//endregion
|
||||
|
||||
//region # Database update settings
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*
|
||||
* Contains code ported from OrbitDeckiOS (https://github.com/prstoetzer/OrbitDeckiOS),
|
||||
* Copyright (c) 2025 Paul Stoetzer, N8HM, licensed under the MIT License.
|
||||
* See THIRD_PARTY_NOTICES.md for the full MIT license text.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
+42
-2
@@ -40,7 +40,8 @@ class LogbookMergeTest {
|
||||
propagationMode = "SAT",
|
||||
status = QsoStatus.COMPLETE,
|
||||
lotwConfirmed = true,
|
||||
vuccGrids = listOf("OM60", "OM50")
|
||||
vuccGrids = listOf("OM60", "OM50"),
|
||||
theirVuccGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -73,12 +74,24 @@ class LogbookMergeTest {
|
||||
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmationLookupKey_groupsMfskAndFt4Spellings() {
|
||||
// Satellite FT4 is MFSK + FT4; a row persisted by an older release as plain MFSK is
|
||||
// the same mode and must resolve to the same contact.
|
||||
val plainMfsk = confirmed.copy(mode = "MFSK", submode = "")
|
||||
val ft4 = confirmed.copy(mode = "MFSK", submode = "FT4")
|
||||
assertEquals(plainMfsk.confirmationLookupKey(), ft4.confirmationLookupKey())
|
||||
assertTrue(sameContactIdentity(plainMfsk, ft4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||
val merged = local.withConfirmation(confirmed)
|
||||
assertTrue(merged.lotwConfirmed)
|
||||
assertTrue(merged.lotwReceived)
|
||||
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||
// The opposite station's grid set arrives with the confirmation too.
|
||||
assertEquals(listOf("EN52", "EN53"), merged.theirVuccGrids)
|
||||
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||
assertEquals("ba7opf", merged.myCallsign)
|
||||
}
|
||||
@@ -97,13 +110,16 @@ class LogbookMergeTest {
|
||||
cqz = 24,
|
||||
state = "GD",
|
||||
myGrid = "OM60",
|
||||
myGrids = setOf("OM60", "OM50")
|
||||
myGrids = setOf("OM60", "OM50"),
|
||||
theirGrids = listOf("EN52", "EN53")
|
||||
)
|
||||
val record = qso.toConfirmedRecord("ba7opf")
|
||||
assertEquals("BH6RJD", record.theirCallsign)
|
||||
assertEquals("BA7OPF", record.myCallsign)
|
||||
assertEquals("OM60", record.myGrid)
|
||||
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||
assertEquals("EN52", record.theirGrid)
|
||||
assertEquals(listOf("EN52", "EN53"), record.theirVuccGrids)
|
||||
assertTrue(record.lotwConfirmed)
|
||||
assertTrue(record.isSatellite)
|
||||
assertEquals("70CM", record.band)
|
||||
@@ -198,6 +214,30 @@ class LogbookMergeTest {
|
||||
assertEquals(1, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_foldsTwoConfirmedRowsAndKeepsTheRicher() {
|
||||
// A contact stored twice with BOTH sides confirmed: the mirrored import the old parser
|
||||
// created (no frequencies, report-only fills) plus the app row that was confirmed on
|
||||
// its own later (frequencies, upload state). Pass 1 cannot fold those; without pass 2
|
||||
// they stay doubled in the logbook forever.
|
||||
val mirroredImport = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val confirmedLocal = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
.copy(id = 2L, lotwConfirmed = true)
|
||||
val pairs = splitConfirmationPairs(listOf(mirroredImport, confirmedLocal))
|
||||
assertEquals(1, pairs.size)
|
||||
// The richer row (frequencies + consistent bands + upload state) is the survivor.
|
||||
assertEquals(1, pairs.first().localIndex)
|
||||
assertEquals(0, pairs.first().confirmationIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsTwoConfirmedRowsOfDifferentMinutesApart() {
|
||||
val one = reportedRecord("SO-50", band = "70CM", rxBand = "2M").copy(id = 1L)
|
||||
val otherMinute = reportedRecord("SO-50", band = "2M", rxBand = "70CM")
|
||||
.copy(id = 2L, startUtcMillis = REPORTED_START + 90_000L)
|
||||
assertTrue(splitConfirmationPairs(listOf(one, otherMinute)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitConfirmationPairs_keepsUnrelatedRowsApart() {
|
||||
val uploaded = loggedRecord("SAUDISAT 1C", 145_850_000L, 436_795_000L)
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.domain.logbook
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LogbookResubmitTest {
|
||||
|
||||
private fun record(
|
||||
id: Long,
|
||||
status: QsoStatus = QsoStatus.COMPLETE,
|
||||
uploaded: Boolean = false,
|
||||
confirmed: Boolean = false
|
||||
) = QsoRecord(
|
||||
id = id,
|
||||
startUtcMillis = 1_760_000_000_000L + id,
|
||||
theirCallsign = "BG5JSB",
|
||||
myCallsign = "BA7OPF",
|
||||
mode = "FM",
|
||||
satelliteName = "SO-50",
|
||||
status = status,
|
||||
lotwUploaded = uploaded,
|
||||
lotwConfirmed = confirmed
|
||||
)
|
||||
|
||||
@Test
|
||||
fun keepsUploadedAndConfirmedRecords() {
|
||||
// The whole point of a resubmit: rows already on LoTW must not be filtered out.
|
||||
val records = listOf(record(1, uploaded = true), record(2, confirmed = true), record(3))
|
||||
assertEquals(listOf(1L, 2L, 3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsRecordsOutsideTheSelection() {
|
||||
val records = listOf(record(1), record(2), record(3))
|
||||
assertEquals(listOf(2L), resubmitCandidates(records, setOf(2)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsIncompleteRecords() {
|
||||
val records = listOf(
|
||||
record(1, status = QsoStatus.DRAFT),
|
||||
record(2, status = QsoStatus.ABORTED),
|
||||
record(3, status = QsoStatus.COMPLETE)
|
||||
)
|
||||
assertEquals(listOf(3L), resubmitCandidates(records, setOf(1, 2, 3)).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptySelectionYieldsNothing() {
|
||||
assertEquals(emptyList<Long>(), resubmitCandidates(listOf(record(1)), emptySet()).map { it.id })
|
||||
}
|
||||
}
|
||||
+1
@@ -276,6 +276,7 @@ class LoTWGridSyncTest {
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getCurrentGrid(): String? = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
|
||||
+56
@@ -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.core.domain.repository
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LoTWPositionWarningTest {
|
||||
|
||||
@Test
|
||||
fun coveredPositionProducesNoWarning() {
|
||||
assertNull(positionWarning("OL62aa", listOf("OL62")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionInsideAnyStationGridIsCovered() {
|
||||
assertNull(positionWarning("OL61", listOf("OL61", "OL62", "OM60", "OM61")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun positionOutsideTheStationWarns() {
|
||||
val warning = positionWarning("om91", listOf("OL62"))
|
||||
assertEquals("OM91", warning?.currentGrid)
|
||||
assertEquals(listOf("OL62"), warning?.stationGrids)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stationGridsAreNormalizedForComparison() {
|
||||
assertNull(positionWarning("OL62", listOf("ol62aa")))
|
||||
assertNull(positionWarning("ol62", listOf("OL62")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownPositionOrStationProducesNoWarning() {
|
||||
assertNull(positionWarning(null, listOf("OL62")))
|
||||
assertNull(positionWarning("", listOf("OL62")))
|
||||
assertNull(positionWarning("OM91", emptyList()))
|
||||
assertNull(positionWarning("OM91", listOf("")))
|
||||
}
|
||||
}
|
||||
+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))
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -492,6 +492,7 @@ fun SwipeRevealRow(
|
||||
controller: SwipeController,
|
||||
modifier: Modifier = Modifier,
|
||||
revealWidth: Dp = 64.dp,
|
||||
gesturesEnabled: Boolean = true,
|
||||
revealAction: () -> Unit,
|
||||
revealButton: @Composable () -> Unit = {
|
||||
Box(
|
||||
@@ -555,7 +556,9 @@ fun SwipeRevealRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationX = offsetX }
|
||||
.pointerInput(revealPx) {
|
||||
.pointerInput(revealPx, gesturesEnabled) {
|
||||
// Selection mode turns reveals off; rows must not shift under the checkboxes.
|
||||
if (!gesturesEnabled) return@pointerInput
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { settleJob?.cancel() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 java.util.Locale
|
||||
|
||||
/**
|
||||
* Grid sets abbreviated the way operators read them: a grid is shortened to its
|
||||
* last two characters while its field (first two characters) stays the same as
|
||||
* the previous grid's; a field change is spelled out — "OL61/62" (same field)
|
||||
* but "OM91/PM01" (a shared prefix would read as OM01, the wrong square).
|
||||
* Used for the station grids a QSO was uploaded under (logbook rows and the
|
||||
* radar log page) and for the opposite station's confirmed grid set.
|
||||
*/
|
||||
fun gridsLabel(grids: List<String>): String {
|
||||
val clean = grids.map { it.trim().uppercase(Locale.US).take(4) }
|
||||
.filter { it.length >= 4 }.distinct().sorted()
|
||||
if (clean.isEmpty()) return ""
|
||||
return buildString {
|
||||
append(clean.first())
|
||||
for (index in 1 until clean.size) {
|
||||
val grid = clean[index]
|
||||
if (grid.take(2) == clean[index - 1].take(2)) append('/').append(grid.takeLast(2))
|
||||
else append('/').append(grid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
|
||||
/**
|
||||
* Pre-upload position check: the operator's current position grid falls outside the station
|
||||
* location the prepared batch would be signed with — the fingerprint of operating while
|
||||
* roaming with a station location that was never updated. The operator either jumps to the
|
||||
* station location to fix it, or ignores the warning and proceeds to the normal preview.
|
||||
*/
|
||||
@Composable
|
||||
fun LoTWPositionWarningDialog(
|
||||
warning: LoTWPositionWarning,
|
||||
onFixStation: () -> Unit,
|
||||
onIgnore: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onIgnore,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.lotw_upload_grid_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.lotw_upload_grid_mismatch,
|
||||
warning.currentGrid,
|
||||
gridsLabel(warning.stationGrids)
|
||||
),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onFixStation) { Text(stringResource(R.string.lotw_upload_grid_fix)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onIgnore) { Text(stringResource(R.string.lotw_upload_grid_ignore)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -197,6 +197,15 @@
|
||||
<string name="prefs_logbook_upload">上传</string>
|
||||
<string name="prefs_logbook_upload_title">上传到 LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">确认上传</string>
|
||||
<string name="prefs_logbook_resubmit">重传选中 (%1$d)</string>
|
||||
<string name="prefs_logbook_selected">已选 %1$d 条</string>
|
||||
<string name="prefs_logbook_select_hint">长按记录行可多选重传</string>
|
||||
<string name="prefs_logbook_resubmit_note">已上传记录将按当前台址就地更新;已被奖项使用的旧网格可能被拒绝。</string>
|
||||
<string name="lotw_upload_grid_title">上传前核对</string>
|
||||
<string name="lotw_upload_grid_fix">修改台址</string>
|
||||
<string name="lotw_upload_grid_ignore">忽略</string>
|
||||
<string name="lotw_upload_grid_mismatch">当前定位 %1$s 不在证书台址网格 %2$s 内——如果正在此处操作,请先更新台址再上传。</string>
|
||||
<string name="lotw_upload_pos_hint">当前定位 %1$s 不在证书台址网格 %2$s 内 · 点此更新</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW 上传证书</string>
|
||||
<string name="prefs_lotw_upload_configured">证书已导入 · 台址网格 %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">未配置 — 导入 TrustedQSL 证书后即可上传通联</string>
|
||||
@@ -406,6 +415,9 @@
|
||||
<string name="qso_edit_comment">备注</string>
|
||||
<string name="qso_edit_save">保存</string>
|
||||
<string name="qso_edit_cancel">取消</string>
|
||||
<string name="log_out_window_title">不在过境窗口</string>
|
||||
<string name="log_out_window_message">当前时间不在该过境窗口内,将默认按过境中点 %1$s 录入;如需修改,请点击该条通联记录。</string>
|
||||
<string name="log_out_window_confirm">录入</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>
|
||||
@@ -416,4 +428,61 @@
|
||||
<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>
|
||||
@@ -226,6 +226,15 @@
|
||||
<string name="prefs_logbook_upload">Upload</string>
|
||||
<string name="prefs_logbook_upload_title">Upload to LoTW</string>
|
||||
<string name="prefs_logbook_upload_confirm">Upload</string>
|
||||
<string name="prefs_logbook_resubmit">Resubmit (%1$d)</string>
|
||||
<string name="prefs_logbook_selected">%1$d selected</string>
|
||||
<string name="prefs_logbook_select_hint">Long-press a row to re-upload corrected records</string>
|
||||
<string name="prefs_logbook_resubmit_note">Existing contacts will be updated in place — a grid already used for an award may be rejected.</string>
|
||||
<string name="lotw_upload_grid_title">Upload check</string>
|
||||
<string name="lotw_upload_grid_fix">Fix station location</string>
|
||||
<string name="lotw_upload_grid_ignore">Ignore</string>
|
||||
<string name="lotw_upload_grid_mismatch">Current position %1$s is outside the certificate station grids %2$s — if you are operating here, update the station location before uploading.</string>
|
||||
<string name="lotw_upload_pos_hint">Current position %1$s is outside the station grids %2$s — tap to update</string>
|
||||
<string name="prefs_lotw_upload_title">LoTW upload certificate</string>
|
||||
<string name="prefs_lotw_upload_configured">Certificate imported · station grid %1$s</string>
|
||||
<string name="prefs_lotw_upload_not_configured">Not configured — import your TrustedQSL certificate to upload QSOs</string>
|
||||
@@ -434,6 +443,9 @@ Upstream: https://github.com/rt-bishop/Look4Sat</string>
|
||||
<string name="qso_edit_comment">Comment</string>
|
||||
<string name="qso_edit_save">Save</string>
|
||||
<string name="qso_edit_cancel">Cancel</string>
|
||||
<string name="log_out_window_title">Outside pass window</string>
|
||||
<string name="log_out_window_message">The current time is outside this pass window. The contact will be logged at the pass midpoint %1$s by default; tap the record afterwards to change the time if needed.</string>
|
||||
<string name="log_out_window_confirm">Log it</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>
|
||||
@@ -444,4 +456,61 @@ Upstream: https://github.com/rt-bishop/Look4Sat</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>
|
||||
@@ -44,6 +44,9 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import com.ve3nea.morse_expert.MainActivity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -91,7 +94,9 @@ fun CwSettingsDialog(controller: MainActivity, onDismiss: () -> Unit) {
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("CW Settings") },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle("CW Settings") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -93,10 +93,13 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||
import com.rtbishop.look4sat.core.presentation.NextPassRow
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
@@ -269,7 +272,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
|
||||
@@ -669,7 +679,9 @@ private fun WorkedGridQsoDialog(
|
||||
if (showMarkInput) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showMarkInput = false },
|
||||
title = { Text(stringResource(R.string.grid_mark_station)) },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.grid_mark_station)) },
|
||||
text = {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = callInput,
|
||||
|
||||
@@ -115,6 +115,7 @@ class FakeSettingsRepo(
|
||||
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||
override fun setStationPosition(): Boolean = TODO()
|
||||
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||
override fun getCurrentGrid(): String? = TODO()
|
||||
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -40,6 +41,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -60,14 +62,18 @@ import com.rtbishop.look4sat.core.domain.utility.uplinkHz
|
||||
import com.rtbishop.look4sat.core.domain.utility.voiceRepeater
|
||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||
import com.rtbishop.look4sat.core.presentation.QsoEditDialog
|
||||
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
|
||||
import com.rtbishop.look4sat.core.presentation.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
|
||||
@Composable
|
||||
fun LogPage(
|
||||
uiState: RadarState,
|
||||
logViewModel: LogViewModel,
|
||||
onFixGrid: (List<String>) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val logUiState by logViewModel.uiState.collectAsStateWithLifecycle()
|
||||
@@ -100,6 +106,12 @@ fun LogPage(
|
||||
if (catnum != 0) logViewModel.selectSatellite(catnum)
|
||||
}
|
||||
|
||||
// Refresh the roaming hint whenever the page re-enters composition (e.g. returning
|
||||
// from the station-location fix jump).
|
||||
LaunchedEffect(Unit) {
|
||||
logViewModel.refreshPositionHint()
|
||||
}
|
||||
|
||||
val selectedTxHz = remember(selectedRadio) { selectedRadio?.uplinkHz() }
|
||||
val selectedRxHz = remember(selectedRadio) { selectedRadio?.downlinkHz() }
|
||||
val repeaterTxHz = voiceRepeater?.uplinkHz()
|
||||
@@ -139,14 +151,28 @@ fun LogPage(
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = {
|
||||
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid)
|
||||
logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow)
|
||||
})
|
||||
)
|
||||
Button(
|
||||
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid) },
|
||||
onClick = { logViewModel.record(satName, mode, txHz, rxHz, logUiState.stationGrid, passWindow) },
|
||||
enabled = logUiState.callsignInput.isNotBlank() && satName.isNotBlank()
|
||||
) { Text("Log") }
|
||||
}
|
||||
logUiState.stationMismatch?.let { mismatch ->
|
||||
Text(
|
||||
text = "⚠ " + stringResource(
|
||||
R.string.lotw_upload_pos_hint,
|
||||
mismatch.currentGrid,
|
||||
gridsLabel(mismatch.stationGrids)
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onFixGrid(listOf(mismatch.currentGrid)) }
|
||||
)
|
||||
}
|
||||
if (isLinear) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("CW", "SSB", "FT4").forEach { candidate ->
|
||||
@@ -178,7 +204,7 @@ fun LogPage(
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { logViewModel.generatePost(satName, maxElev) },
|
||||
onClick = { logViewModel.generatePost(satName, maxElev, passWindow) },
|
||||
enabled = recent.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("生成通联记录", fontSize = 13.sp) }
|
||||
@@ -217,15 +243,29 @@ fun LogPage(
|
||||
)
|
||||
}
|
||||
|
||||
logUiState.preview?.let { preview ->
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
logUiState.uploadPositionWarning?.let { warning ->
|
||||
LoTWPositionWarningDialog(
|
||||
warning = warning,
|
||||
onFixStation = {
|
||||
logViewModel.abandonForGridFix()
|
||||
onFixGrid(listOf(warning.currentGrid))
|
||||
},
|
||||
onIgnore = logViewModel::ignorePositionWarning
|
||||
)
|
||||
}
|
||||
|
||||
// The preview only opens once the position check is out of the way.
|
||||
if (logUiState.uploadPositionWarning == null) {
|
||||
logUiState.preview?.let { preview ->
|
||||
UploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = logUiState.busy,
|
||||
onConfirm = logViewModel::confirmUpload,
|
||||
onDismiss = logViewModel::dismissPreview
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (logUiState.message.isNotBlank()) {
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::clearMessage,
|
||||
@@ -239,6 +279,34 @@ fun LogPage(
|
||||
)
|
||||
}
|
||||
|
||||
// Out-of-window notice: the clock is outside the current pass [aos, los], so the record
|
||||
// would never show in the window-filtered list. Confirm first, then store it at the pass
|
||||
// midpoint (there is always an edit dialog afterwards for the exact time).
|
||||
logUiState.outOfWindowLog?.let { pending ->
|
||||
val midpointText = remember(pending.midpointUtcMillis) {
|
||||
java.text.SimpleDateFormat("HH:mm'Z'", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(pending.midpointUtcMillis))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = logViewModel::dismissOutOfWindowLog,
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.log_out_window_title)) },
|
||||
text = { Text(stringResource(R.string.log_out_window_message, midpointText)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = logViewModel::confirmOutOfWindowLog) {
|
||||
Text(stringResource(R.string.log_out_window_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = logViewModel::dismissOutOfWindowLog) {
|
||||
Text(stringResource(R.string.btn_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editTarget?.let { target ->
|
||||
QsoEditDialog(
|
||||
record = target,
|
||||
@@ -264,6 +332,8 @@ private fun LogRecordRow(
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(java.util.Date(record.startUtcMillis))
|
||||
}
|
||||
// Own station grid: the upload-stamped set, else the grid the QSO was logged under.
|
||||
val ownGrids = gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
@@ -289,6 +359,14 @@ private fun LogRecordRow(
|
||||
maxLines = 1
|
||||
)
|
||||
Text(text = " ${record.displayMode}", fontSize = 14.sp, maxLines = 1)
|
||||
if (ownGrids.isNotBlank()) {
|
||||
Text(
|
||||
text = " $ownGrids",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
record.lotwConfirmed -> Text("QSL", fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
|
||||
@@ -25,8 +25,10 @@ import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -38,6 +40,23 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* A contact waiting for the operator to confirm logging while the clock is outside the
|
||||
* current pass window (after LOS, before AOS, or against a pass card that is not the one
|
||||
* being worked). Confirmed entries are stored at the pass midpoint — inside [aos, los] —
|
||||
* so the window-filtered log page can show them; the exact time can be re-dated from the
|
||||
* edit dialog afterwards.
|
||||
*/
|
||||
data class OutOfWindowLog(
|
||||
val callsign: String,
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val txHz: Long?,
|
||||
val rxHz: Long?,
|
||||
val myGrid: String,
|
||||
val midpointUtcMillis: Long
|
||||
)
|
||||
|
||||
data class LogUiState(
|
||||
val callsignInput: String = "",
|
||||
/** Current logbook mode for the selected satellite (FM/CW/SSB/FT4). */
|
||||
@@ -49,10 +68,18 @@ data class LogUiState(
|
||||
/** ARRL satellite names (config.tq6) — the only names a record may be signed with. */
|
||||
val satelliteCatalog: List<String> = emptyList(),
|
||||
val preview: LoTWUploadPreview? = null,
|
||||
/** Roaming guard: the current position grid is outside the station grids the prepared
|
||||
* batch would be signed with — shown as a dialog before the preview opens. */
|
||||
val uploadPositionWarning: LoTWPositionWarning? = null,
|
||||
/** Roaming hint on the log page: current position grid outside the station grids
|
||||
* (null when covered or unknown). Tapping it jumps to the station location. */
|
||||
val stationMismatch: LoTWPositionWarning? = null,
|
||||
/** User-facing upload / record message ("" when none). */
|
||||
val message: String = "",
|
||||
val postText: String? = null,
|
||||
val busy: Boolean = false
|
||||
val busy: Boolean = false,
|
||||
/** Out-of-window contact waiting for the operator's confirmation (null when closed). */
|
||||
val outOfWindowLog: OutOfWindowLog? = null
|
||||
)
|
||||
|
||||
class LogViewModel(
|
||||
@@ -85,6 +112,10 @@ class LogViewModel(
|
||||
)
|
||||
}
|
||||
}
|
||||
// The roaming hint tracks the freshest position data: refresh on init and every fix.
|
||||
viewModelScope.launch {
|
||||
settingsRepo.stationPosition.collect { refreshPositionHintNow() }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCallsign(value: String) =
|
||||
@@ -101,19 +132,64 @@ class LogViewModel(
|
||||
settingsRepo.setSatelliteMode(catnum, mode)
|
||||
}
|
||||
|
||||
fun record(satName: String, mode: String, txHz: Long?, rxHz: Long?, myGrid: String) {
|
||||
fun record(
|
||||
satName: String,
|
||||
mode: String,
|
||||
txHz: Long?,
|
||||
rxHz: Long?,
|
||||
myGrid: String,
|
||||
passWindow: ClosedRange<Long>?
|
||||
) {
|
||||
val call = _uiState.value.callsignInput.trim()
|
||||
if (call.isEmpty()) return
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
// 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
|
||||
// Logging while the clock sits outside the current pass window (after LOS, before AOS,
|
||||
// or against a pass card that is not the one being worked): the log page only shows
|
||||
// records inside [aos, los], so a "now" timestamp could never appear there. Ask the
|
||||
// operator first; the confirmed contact is stored at the pass midpoint (always inside
|
||||
// the window) and can be re-dated later from the edit dialog.
|
||||
if (passWindow != null && now !in passWindow) {
|
||||
val midpoint = ((passWindow.start + passWindow.endInclusive) / 2L)
|
||||
.let { it / 60_000L * 60_000L }
|
||||
.coerceIn(passWindow.start, passWindow.endInclusive)
|
||||
_uiState.update {
|
||||
it.copy(outOfWindowLog = OutOfWindowLog(call, satName, mode, txHz, rxHz, myGrid, midpoint))
|
||||
}
|
||||
return
|
||||
}
|
||||
storeRecord(call, satName, mode, txHz, rxHz, myGrid, now)
|
||||
}
|
||||
|
||||
/** The operator confirmed the out-of-window notice: store the contact at the pass midpoint. */
|
||||
fun confirmOutOfWindowLog() {
|
||||
val pending = _uiState.value.outOfWindowLog ?: return
|
||||
storeRecord(
|
||||
pending.callsign, pending.satName, pending.mode,
|
||||
pending.txHz, pending.rxHz, pending.myGrid, pending.midpointUtcMillis
|
||||
)
|
||||
_uiState.update { it.copy(outOfWindowLog = null) }
|
||||
}
|
||||
|
||||
fun dismissOutOfWindowLog() = _uiState.update { it.copy(outOfWindowLog = null) }
|
||||
|
||||
private fun storeRecord(
|
||||
call: String,
|
||||
satName: String,
|
||||
mode: String,
|
||||
txHz: Long?,
|
||||
rxHz: Long?,
|
||||
myGrid: String,
|
||||
timeMillis: Long
|
||||
) {
|
||||
val normalizedMode = mode.ifBlank { "FM" }.uppercase(Locale.US)
|
||||
// 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,
|
||||
startUtcMillis = timeMillis,
|
||||
endUtcMillis = timeMillis,
|
||||
theirCallsign = call,
|
||||
myCallsign = _uiState.value.certificateCallsign,
|
||||
myGrid = myGrid.take(6).uppercase(Locale.US),
|
||||
@@ -142,16 +218,16 @@ class LogViewModel(
|
||||
*/
|
||||
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) {
|
||||
/** English social post from the recorded QSOs of the current satellite.
|
||||
* Same window logic as the on-screen list: only the current pass (aos..los). */
|
||||
fun generatePost(satName: String, maxElev: Double, passWindow: ClosedRange<Long>?) {
|
||||
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 { satelliteIdentity(it.satelliteName) == identity }
|
||||
.filter { it.startUtcMillis > now - 24 * 3_600_000L }
|
||||
.filter { passWindow?.contains(it.startUtcMillis) == true }
|
||||
.sortedBy { it.startUtcMillis }
|
||||
if (list.isEmpty()) return@launch
|
||||
val shortName = officialSatelliteName(satName).substringBefore('(').trim().uppercase(Locale.US)
|
||||
@@ -200,7 +276,13 @@ class LogViewModel(
|
||||
// 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) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
preview = preview,
|
||||
uploadPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
} catch (e: LoTWOperationException) {
|
||||
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
@@ -215,8 +297,9 @@ class LogViewModel(
|
||||
_uiState.update { it.copy(busy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
if (result is LoTWUploadResult.Accepted && lastUploadedIds.isNotEmpty()) {
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed).
|
||||
qsoRepository.markUploaded(lastUploadedIds)
|
||||
// Mark the submitted QSOs as uploaded (distinct from confirmed) and stamp
|
||||
// the station grids this batch went out under.
|
||||
qsoRepository.markUploaded(lastUploadedIds, preview.grids)
|
||||
lastUploadedIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
@@ -231,6 +314,34 @@ class LogViewModel(
|
||||
|
||||
fun dismissPreview() = _uiState.update { it.copy(preview = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
fun ignorePositionWarning() = _uiState.update { it.copy(uploadPositionWarning = null) }
|
||||
|
||||
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
|
||||
fun abandonForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastUploadedIds = emptyList()
|
||||
_uiState.update { it.copy(uploadPositionWarning = null, preview = null) }
|
||||
}
|
||||
|
||||
/** Recompute the log-page roaming hint from the freshest station/position data. */
|
||||
fun refreshPositionHint() = viewModelScope.launch { refreshPositionHintNow() }
|
||||
|
||||
private suspend fun refreshPositionHintNow() {
|
||||
val station = runCatching { lotwUploadRepository.station() }.getOrNull()
|
||||
if (station == null) {
|
||||
_uiState.update { it.copy(stationMismatch = null) }
|
||||
return
|
||||
}
|
||||
val grids = station.grid.split(',').map(String::trim).filter(String::isNotBlank)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
stationMismatch = positionWarning(settingsRepo.getCurrentGrid(), grids),
|
||||
stationGrid = station.grid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() = _uiState.update { it.copy(message = "") }
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -175,7 +175,7 @@ internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadarDestination(navigateUp: () -> Unit) {
|
||||
fun RadarDestination(navigateUp: () -> Unit, onOpenLoTWStation: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
|
||||
@@ -216,9 +216,15 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||
}
|
||||
// Grid-check "fix it" jump from the log page: hand the affected records' grids to the
|
||||
// station-location page as its prefill (mirrors the Grid Finder's hand-off).
|
||||
val openLoTWStationForGridFix: (List<String>) -> Unit = { grids ->
|
||||
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||
onOpenLoTWStation()
|
||||
}
|
||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, logViewModel, requestMicPermission = {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
})
|
||||
}, onFixGrid = openLoTWStationForGridFix)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -228,7 +234,8 @@ private fun RadarScreen(
|
||||
navigateUp: () -> Unit,
|
||||
mutualData: MutualPassData,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit
|
||||
) {
|
||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||
// 日程功能: 把当前过境写入系统日历(原仓库的 addToCalendar, ic_calendar 按钮)
|
||||
@@ -308,16 +315,17 @@ private fun RadarScreen(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f), onFixGrid = onFixGrid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,6 +346,7 @@ private fun RadarFillArea(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
@@ -351,7 +360,7 @@ private fun RadarFillArea(
|
||||
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
|
||||
) {
|
||||
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
|
||||
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace), onFixGrid = onFixGrid)
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -361,6 +370,7 @@ private fun RadarFillArea(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
panelHeight = pagerPanelHeight,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
@@ -380,6 +390,7 @@ private fun CompactPagerOverlay(
|
||||
onAction: (RadarAction) -> Unit,
|
||||
logViewModel: LogViewModel,
|
||||
requestMicPermission: () -> Unit,
|
||||
onFixGrid: (List<String>) -> Unit,
|
||||
panelHeight: Dp,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -420,6 +431,7 @@ private fun CompactPagerOverlay(
|
||||
onAction = onAction,
|
||||
logViewModel = logViewModel,
|
||||
requestMicPermission = requestMicPermission,
|
||||
onFixGrid = onFixGrid,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(panelHeight),
|
||||
@@ -453,7 +465,8 @@ private fun PagerCard(
|
||||
requestMicPermission: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
startPage: RadarPage? = null,
|
||||
onCollapse: (() -> Unit)? = null
|
||||
onCollapse: (() -> Unit)? = null,
|
||||
onFixGrid: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val pages = rememberRadarPages(uiState)
|
||||
val pagerState = rememberPagerState(
|
||||
@@ -515,7 +528,8 @@ private fun PagerCard(
|
||||
)
|
||||
RadarPage.Log -> LogPage(
|
||||
uiState = uiState,
|
||||
logViewModel = logViewModel
|
||||
logViewModel = logViewModel,
|
||||
onFixGrid = onFixGrid
|
||||
)
|
||||
RadarPage.Sstv -> SstvPage(
|
||||
sstv = uiState.sstv,
|
||||
|
||||
+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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+118
-12
@@ -9,7 +9,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.settings
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -26,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
@@ -33,6 +36,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -47,6 +52,8 @@ 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.gridsLabel
|
||||
import com.rtbishop.look4sat.core.presentation.LoTWPositionWarningDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||
import com.rtbishop.look4sat.core.presentation.SwipeController
|
||||
@@ -82,24 +89,39 @@ fun LogbookDialog(
|
||||
uploadBusy: Boolean,
|
||||
uploadMessage: String,
|
||||
preview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview?,
|
||||
positionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onEdit: (QsoRecord) -> Unit,
|
||||
onUpload: () -> Unit,
|
||||
onConfirmUpload: () -> Unit,
|
||||
onDismissPreview: () -> Unit,
|
||||
onDismissMessage: () -> Unit
|
||||
onDismissMessage: () -> Unit,
|
||||
onIgnorePositionWarning: () -> Unit = {},
|
||||
onFixGrid: (List<String>) -> Unit = {},
|
||||
selectionMode: Boolean = false,
|
||||
selectedIds: Set<Long> = emptySet(),
|
||||
onStartSelection: (Long) -> Unit = {},
|
||||
onToggleSelection: (Long) -> Unit = {},
|
||||
onExitSelection: () -> Unit = {},
|
||||
onResubmitSelected: () -> Unit = {}
|
||||
) {
|
||||
val swipeController = rememberSwipeController()
|
||||
// Entering selection mode closes any row left swiped open — reveals are off there.
|
||||
LaunchedEffect(selectionMode) { if (selectionMode) swipeController.close() }
|
||||
// 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,
|
||||
onCancel = onDismiss,
|
||||
onAccept = onUpload,
|
||||
acceptText = stringResource(R.string.prefs_logbook_upload),
|
||||
acceptEnabled = !uploadBusy
|
||||
onCancel = if (selectionMode) onExitSelection else onDismiss,
|
||||
onAccept = if (selectionMode) onResubmitSelected else onUpload,
|
||||
acceptText = if (selectionMode) {
|
||||
stringResource(R.string.prefs_logbook_resubmit, selectedIds.size)
|
||||
} else {
|
||||
stringResource(R.string.prefs_logbook_upload)
|
||||
},
|
||||
acceptEnabled = if (selectionMode) selectedIds.isNotEmpty() && !uploadBusy else !uploadBusy
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = LocalSpacing.current.large),
|
||||
@@ -108,6 +130,21 @@ fun LogbookDialog(
|
||||
if (records.isEmpty()) {
|
||||
Text(stringResource(R.string.prefs_logbook_empty), fontSize = 14.sp)
|
||||
} else {
|
||||
// Selection mode shows the checked count; otherwise a hint that long-press
|
||||
// opens it (the path to resubmitting already-uploaded records).
|
||||
if (selectionMode) {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_logbook_selected, selectedIds.size),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.prefs_logbook_select_hint),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@@ -115,9 +152,16 @@ fun LogbookDialog(
|
||||
items(records, key = { it.id }) { record ->
|
||||
LogbookRow(
|
||||
record = record,
|
||||
selectionMode = selectionMode,
|
||||
selected = record.id in selectedIds,
|
||||
swipeController = swipeController,
|
||||
onDelete = { onDelete(record.id) },
|
||||
onClick = { editTarget = record }
|
||||
onClick = {
|
||||
if (selectionMode) onToggleSelection(record.id) else editTarget = record
|
||||
},
|
||||
onLongClick = {
|
||||
if (selectionMode) onToggleSelection(record.id) else onStartSelection(record.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +169,15 @@ fun LogbookDialog(
|
||||
}
|
||||
}
|
||||
|
||||
if (preview != null) {
|
||||
if (positionWarning != null) {
|
||||
// Roaming guard: the current position sits outside the station location this batch
|
||||
// would be signed with — confirm the station location before the preview opens.
|
||||
LoTWPositionWarningDialog(
|
||||
warning = positionWarning,
|
||||
onFixStation = { onFixGrid(listOf(positionWarning.currentGrid)) },
|
||||
onIgnore = onIgnorePositionWarning
|
||||
)
|
||||
} else if (preview != null) {
|
||||
LogbookUploadPreviewDialog(
|
||||
preview = preview,
|
||||
busy = uploadBusy,
|
||||
@@ -194,6 +246,15 @@ private fun LogbookUploadPreviewDialog(
|
||||
}
|
||||
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (preview.resubmit) {
|
||||
// LoTW treats an identical contact as an update, except grids already
|
||||
// locked in by award credits — say so before the operator commits.
|
||||
Text(
|
||||
stringResource(R.string.prefs_logbook_resubmit_note),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
|
||||
}
|
||||
},
|
||||
@@ -208,12 +269,19 @@ private fun LogbookUploadPreviewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** Confirmed-grid colour: the map's worked-grid green, so "confirmed" reads the same everywhere. */
|
||||
private val ConfirmedGreen = Color(0xFF4CD964)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun LogbookRow(
|
||||
record: QsoRecord,
|
||||
selectionMode: Boolean,
|
||||
selected: Boolean,
|
||||
swipeController: SwipeController,
|
||||
onDelete: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val time = remember(record.startUtcMillis) {
|
||||
SimpleDateFormat("MM-dd HH:mm'Z'", Locale.US).apply {
|
||||
@@ -222,11 +290,13 @@ private fun LogbookRow(
|
||||
}
|
||||
val satShort = record.satelliteName.substringBefore('(').trim()
|
||||
val band = record.band.ifBlank { frequencyBand(record.txFrequencyHz) }
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。
|
||||
val haptics = LocalHapticFeedback.current
|
||||
// 右划露出删除按钮(短信式);整行点击不再删除。选择模式下右划禁用、长按进入多选。
|
||||
SwipeRevealRow(
|
||||
key = record.id.toString(),
|
||||
controller = swipeController,
|
||||
revealAction = onDelete,
|
||||
gesturesEnabled = !selectionMode,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Row style mirrors the worked-grid QSO details on the map: callsign in
|
||||
@@ -234,10 +304,30 @@ private fun LogbookRow(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongClick()
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
// Checkbox slot: only while picking records for a resubmit.
|
||||
if (selectionMode) {
|
||||
Text(
|
||||
text = if (selected) "✓" else "○",
|
||||
fontSize = 15.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = record.theirCallsign,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -247,14 +337,30 @@ private fun LogbookRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Confirmed contacts show the opposite station's grid set from the LoTW
|
||||
// report (single or multi-grid, abbreviated) in the worked-grid green
|
||||
// instead of the word QSL; without any grid the QSL text stays.
|
||||
if (record.lotwConfirmed) {
|
||||
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
val oppositeGrids = gridsLabel(
|
||||
record.theirVuccGrids.ifEmpty { listOf(record.theirGrid) }
|
||||
)
|
||||
if (oppositeGrids.isNotEmpty()) {
|
||||
Text(text = oppositeGrids, fontSize = 13.sp, color = ConfirmedGreen, fontFamily = FontFamily.Monospace)
|
||||
} else {
|
||||
Text(text = "QSL", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
} else if (record.lotwUploaded) {
|
||||
Text(text = "UP", fontSize = 13.sp, color = MaterialTheme.colorScheme.primary, fontFamily = FontFamily.Monospace)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = listOf(satShort, record.displayMode, band, time).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
// Own station grid: the set the upload was stamped with, or — for rows from
|
||||
// before the stamp existed — the grid the QSO was logged under, so every
|
||||
// stored contact shows where the operator was.
|
||||
text = listOf(
|
||||
satShort, record.displayMode, band, time,
|
||||
gridsLabel(record.vuccGrids.ifEmpty { listOf(record.myGrid) })
|
||||
).filter { it.isNotBlank() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
+58
-7
@@ -89,16 +89,35 @@ 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)
|
||||
// Station-grid prefill for the "fix it" jump from the logbook grid check (single-use,
|
||||
// mirrors the Grid Finder's "Set as LoTW station" hand-off).
|
||||
val pendingStationGrid by container.pendingLoTWStationGrid.collectAsStateWithLifecycle()
|
||||
SettingsScreen(
|
||||
uiState = uiState,
|
||||
onAction = viewModel::onAction,
|
||||
onOpenGridFinder = onOpenGridFinder,
|
||||
pendingStationGrid = pendingStationGrid.orEmpty(),
|
||||
onFixStationGrid = { grids ->
|
||||
if (grids.isNotEmpty()) container.setPendingLoTWStationGrid(grids.joinToString(","))
|
||||
},
|
||||
onClearStationGridPrefill = { container.setPendingLoTWStationGrid(null) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
||||
private fun SettingsScreen(
|
||||
uiState: SettingsState,
|
||||
onAction: (SettingsAction) -> Unit,
|
||||
onOpenGridFinder: () -> Unit,
|
||||
pendingStationGrid: String = "",
|
||||
onFixStationGrid: (List<String>) -> Unit = {},
|
||||
onClearStationGridPrefill: () -> Unit = {}
|
||||
) {
|
||||
var showUpdateChecker by rememberSaveable { mutableStateOf(false) }
|
||||
var showMapSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var showProjectWiki by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -273,13 +292,33 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
uploadBusy = uiState.logbookUploadBusy,
|
||||
uploadMessage = uiState.logbookUploadMessage,
|
||||
preview = uiState.logbookPreview,
|
||||
onDismiss = { dialogs.logbook = false },
|
||||
positionWarning = uiState.logbookPositionWarning,
|
||||
onDismiss = {
|
||||
// Leaving the logbook resets the resubmit selection: re-entering starts
|
||||
// clean — the operator long-presses again to pick records.
|
||||
onAction(SettingsAction.ExitLogbookSelection)
|
||||
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) },
|
||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) }
|
||||
onDismissMessage = { onAction(SettingsAction.ClearLogbookMessage) },
|
||||
onIgnorePositionWarning = { onAction(SettingsAction.IgnoreLogbookPositionWarning) },
|
||||
onFixGrid = { grids ->
|
||||
onAction(SettingsAction.AbandonLogbookForGridFix)
|
||||
onAction(SettingsAction.ExitLogbookSelection)
|
||||
dialogs.logbook = false
|
||||
onFixStationGrid(grids)
|
||||
dialogs.lotwUpload = true
|
||||
},
|
||||
selectionMode = uiState.logbookSelectionMode,
|
||||
selectedIds = uiState.logbookSelectedIds,
|
||||
onStartSelection = { onAction(SettingsAction.StartLogbookSelection(it)) },
|
||||
onToggleSelection = { onAction(SettingsAction.ToggleLogbookSelection(it)) },
|
||||
onExitSelection = { onAction(SettingsAction.ExitLogbookSelection) },
|
||||
onResubmitSelected = { onAction(SettingsAction.ResubmitSelectedLogbook) }
|
||||
)
|
||||
}
|
||||
if (dialogs.lotwUpload) {
|
||||
@@ -290,7 +329,11 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
busy = uiState.lotwUploadBusy,
|
||||
error = uiState.lotwUploadError,
|
||||
errorDetail = uiState.lotwUploadErrorDetail,
|
||||
onDismiss = { dialogs.lotwUpload = false },
|
||||
initialGrid = pendingStationGrid,
|
||||
onDismiss = {
|
||||
dialogs.lotwUpload = false
|
||||
onClearStationGridPrefill()
|
||||
},
|
||||
onImport = { bytes, password -> onAction(SettingsAction.ImportLoTWCertificate(bytes, password)) },
|
||||
onRemove = { onAction(SettingsAction.RemoveLoTWCertificate) },
|
||||
onSaveStation = { onAction(SettingsAction.SaveLoTWStation(it)) }
|
||||
@@ -378,6 +421,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
showPosDialog = { dialogs.position = true },
|
||||
showLocDialog = { dialogs.locator = true },
|
||||
dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) },
|
||||
openGridFinder = onOpenGridFinder,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
@@ -443,7 +487,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
|
||||
@@ -453,6 +497,7 @@ private fun LocationCard(
|
||||
showPosDialog: () -> Unit,
|
||||
showLocDialog: () -> Unit,
|
||||
dismissPosMessage: () -> Unit,
|
||||
openGridFinder: () -> Unit,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -495,6 +540,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) {
|
||||
|
||||
+17
@@ -85,9 +85,16 @@ data class SettingsState(
|
||||
val lotwUploadErrorDetail: String = "",
|
||||
/** One-click logbook upload: prepared preview awaiting confirmation. */
|
||||
val logbookPreview: com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview? = null,
|
||||
/** Roaming guard: the current position grid is outside the station grids the prepared
|
||||
* batch would be signed with — shown as a dialog before the preview opens. */
|
||||
val logbookPositionWarning: com.rtbishop.look4sat.core.domain.repository.LoTWPositionWarning? = null,
|
||||
val logbookUploadBusy: Boolean = false,
|
||||
/** User-facing upload message shown inside the logbook dialog ("" = none). */
|
||||
val logbookUploadMessage: String = "",
|
||||
/** Logbook multi-select mode (entered by long-pressing a row); checked records can be
|
||||
* re-uploaded so a corrected station location updates them on LoTW (resubmit). */
|
||||
val logbookSelectionMode: Boolean = false,
|
||||
val logbookSelectedIds: Set<Long> = emptySet(),
|
||||
/** 指南针校准精度等级 (校准对话框进度条). */
|
||||
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNRELIABLE,
|
||||
/** 校正后航向(度, 含磁偏角+手动偏置), 校准对话框实时显示. */
|
||||
@@ -160,6 +167,16 @@ sealed interface SettingsAction {
|
||||
data object PrepareLogbookUpload : SettingsAction
|
||||
data object ConfirmLogbookUpload : SettingsAction
|
||||
data object DismissLogbookPreview : SettingsAction
|
||||
/** The operator acknowledged the position mismatch and wants to upload anyway. */
|
||||
data object IgnoreLogbookPositionWarning : SettingsAction
|
||||
/** The operator chose to fix the station location first; the preview is discarded. */
|
||||
data object AbandonLogbookForGridFix : SettingsAction
|
||||
/** Long-press on a row: enter selection mode with that record checked. */
|
||||
data class StartLogbookSelection(val id: Long) : SettingsAction
|
||||
data class ToggleLogbookSelection(val id: Long) : SettingsAction
|
||||
data object ExitLogbookSelection : SettingsAction
|
||||
/** Re-upload the checked records — uploaded/confirmed rows included (resubmit). */
|
||||
data object ResubmitSelectedLogbook : SettingsAction
|
||||
data object ClearLogbookMessage : SettingsAction
|
||||
|
||||
// LoTW upload configuration (certificate + station)
|
||||
|
||||
+95
-4
@@ -34,10 +34,13 @@ import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||
import com.rtbishop.look4sat.core.domain.repository.lotwCursorEpochMs
|
||||
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||
import com.rtbishop.look4sat.core.domain.repository.positionWarning
|
||||
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||
import com.rtbishop.look4sat.core.domain.logbook.resubmitCandidates
|
||||
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||
import com.rtbishop.look4sat.core.domain.logbook.unavailableUploadSummary
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -233,6 +236,12 @@ class SettingsViewModel(
|
||||
SettingsAction.PrepareLogbookUpload -> prepareLogbookUpload()
|
||||
SettingsAction.ConfirmLogbookUpload -> confirmLogbookUpload()
|
||||
SettingsAction.DismissLogbookPreview -> dismissLogbookPreview()
|
||||
SettingsAction.IgnoreLogbookPositionWarning -> ignoreLogbookPositionWarning()
|
||||
SettingsAction.AbandonLogbookForGridFix -> abandonLogbookForGridFix()
|
||||
is SettingsAction.StartLogbookSelection -> startLogbookSelection(action.id)
|
||||
is SettingsAction.ToggleLogbookSelection -> toggleLogbookSelection(action.id)
|
||||
SettingsAction.ExitLogbookSelection -> exitLogbookSelection()
|
||||
SettingsAction.ResubmitSelectedLogbook -> resubmitSelectedLogbook()
|
||||
SettingsAction.ClearLogbookMessage -> clearLogbookMessage()
|
||||
// LoTW upload configuration
|
||||
SettingsAction.LoadLoTWUploadStatus -> loadLoTWUploadStatus()
|
||||
@@ -396,7 +405,13 @@ class SettingsViewModel(
|
||||
// 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) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
@@ -410,8 +425,9 @@ class SettingsViewModel(
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true) }
|
||||
val result = lotwUploadRepository.upload(preview.id)
|
||||
if (result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||
qsoRepository.markUploaded(lastLogbookUploadIds)
|
||||
val accepted = result is com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Accepted
|
||||
if (accepted && lastLogbookUploadIds.isNotEmpty()) {
|
||||
qsoRepository.markUploaded(lastLogbookUploadIds, preview.grids)
|
||||
lastLogbookUploadIds = emptyList()
|
||||
}
|
||||
val msg = when (result) {
|
||||
@@ -420,12 +436,87 @@ class SettingsViewModel(
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.Unknown -> "Unknown result — will not auto-retry"
|
||||
com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult.ExpiredPreview -> "Preview expired — tap upload again"
|
||||
}
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = null, logbookUploadMessage = msg) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = null,
|
||||
logbookUploadMessage = msg,
|
||||
// An accepted resubmit is done — drop the checks; a failed one keeps
|
||||
// them so retrying stays one tap away.
|
||||
logbookSelectionMode = if (accepted) false else it.logbookSelectionMode,
|
||||
logbookSelectedIds = if (accepted) emptySet() else it.logbookSelectedIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissLogbookPreview() = _uiState.update { it.copy(logbookPreview = null) }
|
||||
|
||||
/** Operator chose "ignore" on the position check: keep the prepared preview. */
|
||||
private fun ignoreLogbookPositionWarning() = _uiState.update { it.copy(logbookPositionWarning = null) }
|
||||
|
||||
/** Operator chose to fix the station grid first: drop the prepared preview and leave. */
|
||||
private fun abandonLogbookForGridFix() {
|
||||
lotwUploadRepository.discardPreview()
|
||||
lastLogbookUploadIds = emptyList()
|
||||
_uiState.update { it.copy(logbookPositionWarning = null, logbookPreview = null) }
|
||||
}
|
||||
|
||||
/** Long-press entry: selection mode with the pressed record checked. */
|
||||
private fun startLogbookSelection(id: Long) = _uiState.update {
|
||||
it.copy(logbookSelectionMode = true, logbookSelectedIds = setOf(id))
|
||||
}
|
||||
|
||||
private fun toggleLogbookSelection(id: Long) = _uiState.update {
|
||||
val selection = it.logbookSelectedIds.toMutableSet().apply { if (!add(id)) remove(id) }
|
||||
it.copy(logbookSelectedIds = selection)
|
||||
}
|
||||
|
||||
private fun exitLogbookSelection() = _uiState.update {
|
||||
it.copy(logbookSelectionMode = false, logbookSelectedIds = emptySet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-upload the checked records — already-uploaded and confirmed rows included — so a
|
||||
* corrected station location reaches LoTW and the server updates the existing contacts.
|
||||
* The rest is the normal prepare → preview → upload cycle, resubmit flag included.
|
||||
*/
|
||||
private fun resubmitSelectedLogbook() {
|
||||
if (_uiState.value.logbookUploadBusy) return
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(logbookUploadBusy = true, logbookUploadMessage = "") }
|
||||
try {
|
||||
val selected = resubmitCandidates(qsoRepository.records.first(), _uiState.value.logbookSelectedIds)
|
||||
if (selected.isEmpty()) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No complete QSOs in the selection") }
|
||||
return@launch
|
||||
}
|
||||
val preview = lotwUploadRepository.prepare(selected, true)
|
||||
if (preview.count == 0) {
|
||||
// Every checked record proved un-signable — say why instead of opening
|
||||
// a dead preview whose POST could only expire.
|
||||
val message = unavailableUploadSummary(
|
||||
preview.unavailableSkipped, preview.unavailableReasons, duplicates = preview.duplicateSkipped
|
||||
).ifBlank { "No QSOs to resubmit" }
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = message) }
|
||||
return@launch
|
||||
}
|
||||
lastLogbookUploadIds = preview.submittedIds
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
logbookUploadBusy = false,
|
||||
logbookPreview = preview,
|
||||
logbookPositionWarning = positionWarning(settingsRepo.getCurrentGrid(), preview.grids)
|
||||
)
|
||||
}
|
||||
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
|
||||
} catch (_: Exception) {
|
||||
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearLogbookMessage() = _uiState.update { it.copy(logbookUploadMessage = "") }
|
||||
|
||||
private fun loadLoTWUploadStatus() {
|
||||
|
||||
+6
-1
@@ -81,7 +81,10 @@ import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||
import com.rtbishop.look4sat.core.presentation.InfoDialog
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.presentation.SheetDialogTitle
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogContainerColor
|
||||
import com.rtbishop.look4sat.core.presentation.sheetDialogShape
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
@@ -591,7 +594,9 @@ private fun AmSatUploadConfirmDialog(
|
||||
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||
shape = sheetDialogShape(),
|
||||
containerColor = sheetDialogContainerColor(),
|
||||
title = { SheetDialogTitle(stringResource(R.string.amsat_upload_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionCode = "544"
|
||||
appVersionCode = "555"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
appVersionName = "4.4.8-ba7opf.1.1"
|
||||
appVersionName = "4.4.8-ba7opf.3"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
compileSdk = "37"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
|
||||
@@ -22,6 +22,7 @@ include(
|
||||
)
|
||||
include(
|
||||
":feature:cw",
|
||||
":feature:gridfinder",
|
||||
":feature:map",
|
||||
":feature:mutual",
|
||||
":feature:passes",
|
||||
|
||||
Reference in new issue
Block a user