From 4c3fdeaa9c57ecc39eebb452baa82bd5953d3b1a Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Tue, 29 Sep 2026 12:12:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(gridfinder):=20VUCC=20=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E5=AF=BB=E7=BA=BF=E5=AE=9E=E5=9C=B0=E5=AF=BC=E8=88=AA=20+=20Lo?= =?UTF-8?q?TW=20=E4=B8=8A=E4=BC=A0=E5=8F=B0=E5=9D=80=E9=A2=84=E5=A1=AB?= =?UTF-8?q?=E8=81=94=E5=8A=A8=20+=20=E7=A7=BB=E9=99=A4=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GridGeometry 网格线/点几何 + QthConverter grid4/grid8;golden vectors 单测 - 独立定位流 ILocationRepo/LocationRepo(stop 时 removeUpdates),不自动写台址 - feature/gridfinder 模块:罗盘表盘、极坐标近距图、线上/点上精度门控(连续 3 次 ≤6.1m) - 「设为 LoTW 上传台址」:跳证书配置页并预填当前网格(1/2/4 格),只改台站 grid 字段 - 地图页入口移除,设置页单入口;顶部间距改 ScreenColumn 结构(与设置页同构) - 术语统一「最近网格点」(中英) --- .../java/com/rtbishop/look4sat/MainScreen.kt | 33 +- .../look4sat/convention/ApplicationPlugin.kt | 1 + .../core/data/injection/MainContainer.kt | 17 + .../core/data/repository/LocationRepo.kt | 134 ++++ .../core/domain/repository/ILocationRepo.kt | 65 ++ .../core/domain/repository/IMainContainer.kt | 4 + .../core/domain/utility/GridGeometry.kt | 189 +++++ .../core/domain/utility/QthConverter.kt | 39 + .../core/domain/utility/GridGeometryTest.kt | 171 +++++ .../look4sat/core/presentation/Navigation.kt | 8 + .../src/main/res/drawable/ic_grid_finder.xml | 13 + .../src/main/res/values-zh/strings.xml | 57 ++ .../src/main/res/values/strings.xml | 57 ++ feature/gridfinder/build.gradle.kts | 23 + .../gridfinder/src/main/AndroidManifest.xml | 4 + .../feature/gridfinder/GridFinderCanvas.kt | 188 +++++ .../feature/gridfinder/GridFinderScreen.kt | 676 ++++++++++++++++++ .../feature/gridfinder/GridFinderState.kt | 92 +++ .../feature/gridfinder/GridFinderViewModel.kt | 214 ++++++ .../feature/gridfinder/GridFinderGateTest.kt | 74 ++ .../look4sat/feature/map/MapScreen.kt | 9 +- .../settings/LoTWUploadConfigDialog.kt | 5 +- .../feature/settings/LoTWUploadDestination.kt | 71 ++ .../feature/settings/SettingsScreen.kt | 20 +- settings.gradle.kts | 1 + 25 files changed, 2156 insertions(+), 9 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LocationRepo.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILocationRepo.kt create mode 100644 core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GridGeometry.kt create mode 100644 core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/GridGeometryTest.kt create mode 100644 core/presentation/src/main/res/drawable/ic_grid_finder.xml create mode 100644 feature/gridfinder/build.gradle.kts create mode 100644 feature/gridfinder/src/main/AndroidManifest.xml create mode 100644 feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderCanvas.kt create mode 100644 feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderScreen.kt create mode 100644 feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderState.kt create mode 100644 feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderViewModel.kt create mode 100644 feature/gridfinder/src/test/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderGateTest.kt create mode 100644 feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt index 81fd48d8..c9162c0a 100644 --- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt +++ b/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt @@ -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 { MainScreen( - navigateToRadar = navigateToRadar + navigateToRadar = navigateToRadar, + navigateToGridFinder = navigateToGridFinder ) } entry { @@ -143,6 +150,25 @@ fun NavRoot(deeplink: String? = null) { RadarDestination(navigateUp = navigateBack) } } + entry { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + GridFinderDestination( + navigateUp = navigateBack, + onOpenLoTWStation = navigateToLoTWStation + ) + } + } + entry { + 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 { SatStatusDestination() } entry { - SettingsDestination() + SettingsDestination(onOpenGridFinder = navigateToGridFinder) } } ) diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt index 0a26f7ee..5e99fd62 100644 --- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt +++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt @@ -32,6 +32,7 @@ internal class ApplicationPlugin : Plugin { 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")) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index c94c73c5..4fa8b74c 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -17,11 +17,14 @@ */ package com.rtbishop.look4sat.core.data.injection +import android.Manifest import android.bluetooth.BluetoothManager import android.content.Context +import android.content.pm.PackageManager import android.hardware.SensorManager import android.hardware.display.DisplayManager import android.location.LocationManager +import androidx.core.content.ContextCompat import androidx.room.Room import com.rtbishop.look4sat.core.data.database.Look4SatDb import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2 @@ -43,6 +46,7 @@ import com.rtbishop.look4sat.core.data.repository.SensorsRepo import com.rtbishop.look4sat.core.data.repository.SettingsRepo import com.rtbishop.look4sat.core.data.repository.UpdateRepository import com.rtbishop.look4sat.core.data.repository.LoTWRepository +import com.rtbishop.look4sat.core.data.repository.LocationRepo import com.rtbishop.look4sat.core.data.repository.WavelogRepository import com.rtbishop.look4sat.core.data.source.LocalSource import com.rtbishop.look4sat.core.data.source.RemoteSource @@ -59,6 +63,7 @@ import com.rtbishop.look4sat.core.domain.repository.IRadioController import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IReporter import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository +import com.rtbishop.look4sat.core.domain.repository.ILocationRepo import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo @@ -107,13 +112,25 @@ class MainContainer(private val context: Context) : IMainContainer { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) } + override val locationRepo: ILocationRepo by lazy { + val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + LocationRepo(manager) { + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + } + } private val _mutualPassData = MutableStateFlow(MutualPassData()) override val mutualPassData: StateFlow = _mutualPassData.asStateFlow() + private val _pendingLoTWStationGrid = MutableStateFlow(null) + override val pendingLoTWStationGrid: StateFlow = _pendingLoTWStationGrid.asStateFlow() override fun setMutualPassData(data: MutualPassData) { _mutualPassData.value = data } + override fun setPendingLoTWStationGrid(grid: String?) { + _pendingLoTWStationGrid.value = grid + } override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LocationRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LocationRepo.kt new file mode 100644 index 00000000..f12cb1ab --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/LocationRepo.kt @@ -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 . + */ +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 = _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 + ) +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILocationRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILocationRepo.kt new file mode 100644 index 00000000..0e2f03be --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ILocationRepo.kt @@ -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 . + */ +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 + + /** 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() +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index 4205dad8..c45aab0e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -38,8 +38,12 @@ interface IMainContainer { val qsoRepository: IQsoRepository val lotwUploadRepository: ILoTWUploadRepository val radioTrackingService: IRadioTrackingService + val locationRepo: ILocationRepo val mutualPassData: StateFlow + /** Grid Finder → LoTW station page prefill, consumed once by that page. */ + val pendingLoTWStationGrid: StateFlow fun setMutualPassData(data: MutualPassData) + fun setPendingLoTWStationGrid(grid: String?) fun provideAddToCalendar(): IAddToCalendar fun provideShowToast(): IShowToast fun provideBluetoothReporter(): IReporter diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GridGeometry.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GridGeometry.kt new file mode 100644 index 00000000..1e7ff43a --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/GridGeometry.kt @@ -0,0 +1,189 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +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, + val nearestLineMeters: Double, + val nearestLineBearingDegrees: Double, + val nearestLineIsLatitude: Boolean, + val nearestLineGrids: List +) + +/** + * 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 { + 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): 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 +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt index 4f9bedea..4cd28994 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/QthConverter.kt @@ -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) } diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/GridGeometryTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/GridGeometryTest.kt new file mode 100644 index 00000000..e368c2dd --- /dev/null +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/utility/GridGeometryTest.kt @@ -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 . + */ +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)) + } +} diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt index 3efb850b..1887e948 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/Navigation.kt @@ -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? } diff --git a/core/presentation/src/main/res/drawable/ic_grid_finder.xml b/core/presentation/src/main/res/drawable/ic_grid_finder.xml new file mode 100644 index 00000000..8ec24196 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_grid_finder.xml @@ -0,0 +1,13 @@ + + + + diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index f43ee945..8d7d8b99 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -416,4 +416,61 @@ 保存并签名时使用 %1$s 没有上行频率:LoTW 要求 BAND,补上频率前这条无法上传 + + 网格寻线 + 位于网格点上 + 位于网格线上 + 在网格内部 + 位置不可用 + 正在获取定位… + 需要定位权限 + 本机定位服务未开启 + 已由 %1$d 次连续满足 20 英尺规则的定位确认 + 尚未确认:精度达 6.1 m 的连续定位 %1$d/%2$d 次 + 授予定位权限 + 位置 + 最近网格点引导 + 最近网格线引导 + 两条边界线 + 近距离网格图 + 定位质量 + 罗盘 + 此处可计分的网格 + 网格 (4) + 网格 (6) + 网格 (8) + 纬度 + 经度 + 海拔 + 水平精度 + 定位时刻 + 定位来源 + 已开启 + 未开启 + 距离 + 方位角 + 方向 + 网格 + 纬线 + 经线 + 位于%2$s方向 %1$s + 这是纬度边界,东西走向 + 这是经度边界,南北走向 + 朝向 + 罗盘精度 + 设为我的台址 + 台址已更新 —— 过境预报将使用这个位置 + 校准罗盘 + 中心点是你的位置,虚线是正北与正东,直线是两条网格边界,绿点是最近的网格点,圆环是最近网格线的垂足,红色指针是你的朝向 + 设置里未开启罗盘,表盘按正北朝上显示。方位角数字仍可引导你 + 本机没有磁力传感器 —— 请按方位角数字导航 + 请平持手机,箭头指向应走的方向 + VUCC 20 英尺规则:%1$.1f m + 北 + 南 + 东 + 西 + 设为 LoTW 上传台址(%1$s) + 预填上传台站网格:上传时 LoTW 按这些格记录(线上 2 格、角上 4 格);证书呼号保持不变。 + diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index ade1596f..4ccd4016 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -444,4 +444,61 @@ Upstream: https://github.com/rt-bishop/Look4Sat Saved and signed as %1$s No uplink frequency: LoTW requires a band, so this QSO cannot be uploaded yet + + Grid Finder + ON GRID POINT + ON GRID LINE + INSIDE GRID + Position unavailable + Waiting for a fix… + Location permission required + Location services are off on this device + Confirmed by %1$d consecutive fixes inside the 20 ft rule + Not confirmed yet: %1$d of %2$d consecutive fixes good to 6.1 m + Grant location access + Location + Guide to the nearest grid point + Guide to the nearest grid line + Both boundary lines + Close-up grid map + Fix quality + Compass + VUCC grids here + Grid (4) + Grid (6) + Grid (8) + Latitude + Longitude + Altitude + Horizontal accuracy + Fix age + Location provider + On + Off + Distance + Bearing + Direction + Grids + Latitude line + Longitude line + %1$s to the %2$s + This is the latitude boundary, which runs east to west + This is the longitude boundary, which runs north to south + Heading + Compass accuracy + Set as my station + Station position updated — pass predictions now use this fix + Calibrate compass + 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 + The compass is switched off in Settings, so the dials stay north-up. The bearing numbers still guide you + This device has no magnetic sensor — navigate with the bearing numbers + Hold the phone flat; the arrow points the way to walk + VUCC 20 ft rule: %1$.1f m + north + south + east + west + Set as LoTW station (%1$s) + Prefills the upload station grid — LoTW then records these squares (2 on a line, 4 on a corner). The certificate callsign stays unchanged. + diff --git a/feature/gridfinder/build.gradle.kts b/feature/gridfinder/build.gradle.kts new file mode 100644 index 00000000..d70f237d --- /dev/null +++ b/feature/gridfinder/build.gradle.kts @@ -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) +} diff --git a/feature/gridfinder/src/main/AndroidManifest.xml b/feature/gridfinder/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e1000761 --- /dev/null +++ b/feature/gridfinder/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderCanvas.kt b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderCanvas.kt new file mode 100644 index 00000000..786a211c --- /dev/null +++ b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderCanvas.kt @@ -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 . + */ +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() + ) +} diff --git a/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderScreen.kt b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderScreen.kt new file mode 100644 index 00000000..fdf3906c --- /dev/null +++ b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderScreen.kt @@ -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 . + */ +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, + 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)] +} diff --git a/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderState.kt b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderState.kt new file mode 100644 index 00000000..a85ffb22 --- /dev/null +++ b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderState.kt @@ -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 . + */ +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 = 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 +} diff --git a/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderViewModel.kt b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderViewModel.kt new file mode 100644 index 00000000..a5bb005e --- /dev/null +++ b/feature/gridfinder/src/main/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderViewModel.kt @@ -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 . + */ +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 = _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? = 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() + ) + } + } + } +} diff --git a/feature/gridfinder/src/test/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderGateTest.kt b/feature/gridfinder/src/test/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderGateTest.kt new file mode 100644 index 00000000..fe29edab --- /dev/null +++ b/feature/gridfinder/src/test/java/com/rtbishop/look4sat/feature/gridfinder/GridFinderGateTest.kt @@ -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 . + */ +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 + ) +} diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt index 9dc17002..0035c3bf 100644 --- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt +++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt @@ -269,7 +269,14 @@ fun MapDestination( viewModel.onAction(MapAction.SetVisible(false)) } } - MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating) + MapScreen( + uiState = uiState, + onAction = viewModel::onAction, + mapView = mapView, + mapFilterViewModel = mapFilterViewModel, + onMatchGrid = onMatchGrid, + matchCalculating = matchCalculating + ) } @Composable diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt index 29575798..b68a89cf 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadConfigDialog.kt @@ -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(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()) } diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt new file mode 100644 index 00000000..bdba7979 --- /dev/null +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/LoTWUploadDestination.kt @@ -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 . + */ +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)) } + ) + } +} diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt index a6cff1ab..220f7224 100644 --- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt @@ -89,16 +89,20 @@ import java.util.Date import java.util.Locale @Composable -fun SettingsDestination() { +fun SettingsDestination(onOpenGridFinder: () -> Unit = {}) { val context = LocalContext.current val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - SettingsScreen(uiState, viewModel::onAction) + SettingsScreen(uiState, viewModel::onAction, onOpenGridFinder) } @Composable -private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) { +private fun SettingsScreen( + uiState: SettingsState, + onAction: (SettingsAction) -> Unit, + onOpenGridFinder: () -> Unit +) { var showUpdateChecker by rememberSaveable { mutableStateOf(false) } var showMapSettings by rememberSaveable { mutableStateOf(false) } var showProjectWiki by rememberSaveable { mutableStateOf(false) } @@ -378,6 +382,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> showPosDialog = { dialogs.position = true }, showLocDialog = { dialogs.locator = true }, dismissPosMessage = { onAction(SettingsAction.DismissPosMessages) }, + openGridFinder = onOpenGridFinder, onAction = onAction ) } @@ -443,7 +448,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> private fun LocationCardPreview() = MainTheme { val stationPos = GeoPos(0.0, 0.0, 0.0, "IO91vl", 0L) val settings = PositionSettings(true, stationPos, 0) - LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}) {} + LocationCard(settings = settings, setGpsPos = {}, showPosDialog = {}, {}, {}, {}) {} } @Composable @@ -453,6 +458,7 @@ private fun LocationCard( showPosDialog: () -> Unit, showLocDialog: () -> Unit, dismissPosMessage: () -> Unit, + openGridFinder: () -> Unit, onAction: (SettingsAction) -> Unit ) { ElevatedCard(modifier = Modifier.fillMaxWidth()) { @@ -495,6 +501,12 @@ private fun LocationCard( modifier = Modifier.weight(1f) ) } + Spacer(modifier = Modifier.height(4.dp)) + CardButton( + onClick = openGridFinder, + text = stringResource(id = R.string.gridfinder_title), + modifier = Modifier.fillMaxWidth() + ) } } if (settings.messageResId != 0) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 3bae83c4..627e3220 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ include( ) include( ":feature:cw", + ":feature:gridfinder", ":feature:map", ":feature:mutual", ":feature:passes",