feat(gridfinder): VUCC 网格寻线实地导航 + LoTW 上传台址预填联动 + 移除地图入口

- GridGeometry 网格线/点几何 + QthConverter grid4/grid8;golden vectors 单测
- 独立定位流 ILocationRepo/LocationRepo(stop 时 removeUpdates),不自动写台址
- feature/gridfinder 模块:罗盘表盘、极坐标近距图、线上/点上精度门控(连续 3 次 ≤6.1m)
- 「设为 LoTW 上传台址」:跳证书配置页并预填当前网格(1/2/4 格),只改台站 grid 字段
- 地图页入口移除,设置页单入口;顶部间距改 ScreenColumn 结构(与设置页同构)
- 术语统一「最近网格点」(中英)
This commit is contained in:
atsunatsu committed 2026-09-29 12:12:47 +08:00
1 parent 33a7f23d7a
commit 4c3fdeaa9c
25 files changed
+2156 -9

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.gridfinder"
testOptions {
unitTests {
// Robolectric needs real Android resources for Compose UI tests.
isIncludeAndroidResources = true
}
}
}
dependencies {
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.navigation3)
debugImplementation(libs.compose.debug.manifest)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,188 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.gridfinder
import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
/**
* Compass rose with a needle pointing along the true-north [bearingDegrees].
* When [headingDegrees] is available the rose turns with the device, so the
* needle is read against the top edge of the phone ("walk the way the arrow
* points"); without a magnetometer the rose stays north-up.
*/
@Composable
fun GridCompassDial(
bearingDegrees: Double,
headingDegrees: Float?,
tint: Color,
modifier: Modifier = Modifier
) {
val dialColor = MaterialTheme.colorScheme.outline
// North is a longer, brighter tick; red is reserved for the heading needle
// in the proximity map, so the legend stays unambiguous.
val northColor = MaterialTheme.colorScheme.onSurface
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
val measurer = rememberTextMeasurer()
Canvas(modifier = modifier) {
val center = Offset(size.width / 2f, size.height / 2f)
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
if (radius <= 0f) return@Canvas
val roseRotation = headingDegrees?.let { -it } ?: 0f
drawCircle(color = dialColor, radius = radius, center = center, style = Stroke(1.5.dp.toPx()))
drawCircle(
color = dialColor.copy(alpha = 0.35f),
radius = radius * 0.62f,
center = center,
style = Stroke(1.dp.toPx())
)
for (degrees in 0 until 360 step 30) {
val angle = Math.toRadians((degrees + roseRotation).toDouble())
val isNorth = degrees == 0
val innerRadius = radius * if (isNorth) 0.70f else 0.86f
val start = center.at(innerRadius, angle)
val end = center.at(radius, angle)
drawLine(
color = if (isNorth) northColor else dialColor,
start = start,
end = end,
strokeWidth = if (isNorth) 3.dp.toPx() else 1.dp.toPx(),
cap = StrokeCap.Round
)
}
listOf(0 to "N", 90 to "E", 180 to "S", 270 to "W").forEach { (degrees, label) ->
val angle = Math.toRadians((degrees + roseRotation).toDouble())
val position = center.at(radius * 0.50f, angle)
val layout = measurer.measure(
AnnotatedString(label),
TextStyle(fontSize = 12.sp, color = if (degrees == 0) northColor else labelColor)
)
drawText(
textLayoutResult = layout,
topLeft = Offset(
position.x - layout.size.width / 2f,
position.y - layout.size.height / 2f
)
)
}
val needleAngle = Math.toRadians(bearingDegrees - (headingDegrees ?: 0f))
val tip = center.at(radius * 0.88f, needleAngle)
drawLine(
color = tint,
start = center,
end = tip,
strokeWidth = 3.dp.toPx(),
cap = StrokeCap.Round
)
drawCircle(color = tint, radius = 4.dp.toPx(), center = tip)
drawCircle(color = tint, radius = 2.5.dp.toPx(), center = center)
}
}
/**
* North-up proximity sketch around the fix: dashed lines are the nearest grid
* boundaries, the green dot is the grid corner, the ring marks the closest
* point of the nearest line, and the amber needle is the device heading.
*/
@Composable
fun GridProximityMap(
geometry: GridGeometry,
headingDegrees: Float?,
modifier: Modifier = Modifier
) {
val outline = MaterialTheme.colorScheme.outline
val gridColor = MaterialTheme.colorScheme.primary
val lineColor = MaterialTheme.colorScheme.tertiary
val cornerColor = MaterialTheme.colorScheme.secondary
val headingColor = MaterialTheme.colorScheme.error
val youColor = MaterialTheme.colorScheme.onSurface
val dash = PathEffect.dashPathEffect(floatArrayOf(14f, 12f))
Canvas(modifier = modifier) {
val center = Offset(size.width / 2f, size.height / 2f)
val radius = minOf(size.width, size.height) / 2f - 8.dp.toPx()
if (radius <= 0f) return@Canvas
val dNorth = when (geometry.latLineDirection) {
GridBoundaryDirection.NORTH -> geometry.latLineMeters
else -> -geometry.latLineMeters
}
val dEast = when (geometry.lonLineDirection) {
GridBoundaryDirection.EAST -> geometry.lonLineMeters
else -> -geometry.lonLineMeters
}
val span = max(max(abs(dNorth), abs(dEast)), geometry.cornerMeters)
val scale = radius / max(span * 1.15, 15.0).toFloat()
drawCircle(color = outline.copy(alpha = 0.35f), radius = radius, center = center, style = Stroke(1.dp.toPx()))
drawLine(outline, Offset(center.x, center.y - radius), Offset(center.x, center.y + radius), 1.dp.toPx(), pathEffect = dash)
drawLine(outline, Offset(center.x - radius, center.y), Offset(center.x + radius, center.y), 1.dp.toPx(), pathEffect = dash)
val latY = center.y - (dNorth * scale).toFloat()
val lonX = center.x + (dEast * scale).toFloat()
drawLine(lineColor, Offset(center.x - radius, latY), Offset(center.x + radius, latY), 2.dp.toPx())
drawLine(lineColor, Offset(lonX, center.y - radius), Offset(lonX, center.y + radius), 2.dp.toPx())
val corner = Offset(lonX, latY)
drawCircle(color = cornerColor, radius = 5.dp.toPx(), center = corner)
val nearestPoint = if (geometry.nearestLineIsLatitude) {
Offset(center.x, latY)
} else {
Offset(lonX, center.y)
}
drawCircle(color = gridColor, radius = 5.dp.toPx(), center = nearestPoint, style = Stroke(2.dp.toPx()))
headingDegrees?.let { heading ->
val angle = Math.toRadians(heading.toDouble())
val tip = center.at(radius * 0.92f, angle)
drawLine(headingColor, center, tip, 2.dp.toPx(), cap = StrokeCap.Round)
drawCircle(headingColor, 3.dp.toPx(), tip)
}
drawCircle(color = youColor, radius = 3.dp.toPx(), center = center)
}
}
private fun Offset.at(radius: Float, angleRadians: Double): Offset {
return Offset(
x + (radius * sin(angleRadians)).toFloat(),
y - (radius * cos(angleRadians)).toFloat()
)
}
@@ -0,0 +1,676 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.gridfinder
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.utility.GridBoundaryDirection
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
import com.rtbishop.look4sat.core.domain.utility.VUCC_BOUNDARY_TOLERANCE_METERS
import com.rtbishop.look4sat.core.presentation.ElevationHighColor
import com.rtbishop.look4sat.core.presentation.ElevationLowColor
import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SharedDialog
import com.rtbishop.look4sat.core.presentation.TopBar
import kotlin.math.abs
/**
* Full-screen field tool: walks the operator onto a VUCC grid line or corner.
* Reached from the map's grid view and from Settings; the live fix it uses
* never moves the station unless the operator taps "Set as my station".
*/
@Composable
fun GridFinderDestination(
navigateUp: () -> Unit,
onOpenLoTWStation: () -> Unit = {}
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: GridFinderViewModel = viewModel(factory = GridFinderViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> viewModel.onAction(GridFinderAction.LocationPermissionResult(granted)) }
val requestLocationPermission = { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
var hasAskedPermission by rememberSaveable { mutableStateOf(false) }
// "Set as LoTW station": hand the grids the operator currently stands on
// (1 inside, 2 on a line, 4 on a corner) to the upload station page.
val openLoTWStation: () -> Unit = {
val grids = uiState.claimableGrids
if (grids.isNotEmpty()) {
container.setPendingLoTWStationGrid(grids.joinToString(","))
onOpenLoTWStation()
}
}
DisposableEffect(viewModel) {
viewModel.onAction(GridFinderAction.StartUpdates)
onDispose { viewModel.onAction(GridFinderAction.StopUpdates) }
}
// Ask once automatically on the first visit; after a refusal the banner
// offers the request again, so returning to the screen is not a prompt loop.
LaunchedEffect(uiState.permissionGranted) {
if (!uiState.permissionGranted && !hasAskedPermission) {
hasAskedPermission = true
requestLocationPermission()
}
}
GridFinderScreen(
uiState = uiState,
onAction = viewModel::onAction,
navigateUp = navigateUp,
openLoTWStation = openLoTWStation,
requestLocationPermission = requestLocationPermission
)
}
@Composable
private fun GridFinderScreen(
uiState: GridFinderState,
onAction: (GridFinderAction) -> Unit,
navigateUp: () -> Unit,
openLoTWStation: () -> Unit,
requestLocationPermission: () -> Unit
) {
// Same top-bar → content rhythm as the settings page: ScreenColumn puts a
// small gap between the header row and the rounded surfaceContainer panel.
ScreenColumn(
topBar = {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
Text(
text = stringResource(R.string.gridfinder_title),
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 10.dp)
)
}
}
) {
LazyColumn(
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.medium),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 0.dp, bottom = 24.dp)
) {
item { StandingCard(uiState, requestLocationPermission) }
uiState.geometry?.let { geometry ->
item { LocationCard(uiState, onAction, openLoTWStation) }
item {
GuideCard(
title = stringResource(R.string.gridfinder_card_corner),
bearing = geometry.cornerBearingDegrees,
distanceMeters = geometry.cornerMeters,
direction = null,
grids = geometry.cornerGrids,
tint = ElevationHighColor,
heading = uiState.headingDegrees
)
}
item {
GuideCard(
title = stringResource(R.string.gridfinder_card_line),
bearing = geometry.nearestLineBearingDegrees,
distanceMeters = geometry.nearestLineMeters,
direction = null,
grids = geometry.nearestLineGrids,
tint = MaterialTheme.colorScheme.primary,
heading = uiState.headingDegrees,
extra = stringResource(
if (geometry.nearestLineIsLatitude) {
R.string.gridfinder_line_latitude
} else {
R.string.gridfinder_line_longitude
}
)
)
}
item { BoundaryLinesCard(geometry) }
item { ProximityCard(geometry, uiState.headingDegrees) }
}
item { FixQualityCard(uiState) }
item { CompassCard(uiState, onAction) }
}
}
}
@Composable
private fun StandingCard(uiState: GridFinderState, requestLocationPermission: () -> Unit) {
val grids = uiState.claimableGrids.joinToString(" · ")
val (headline, tint) = when {
!uiState.permissionGranted -> stringResource(R.string.gridfinder_status_blocked) to ElevationLowColor
!uiState.providerEnabled -> stringResource(R.string.gridfinder_status_disabled) to ElevationLowColor
uiState.fix == null -> stringResource(R.string.gridfinder_status_waiting) to MaterialTheme.colorScheme.onSurface
uiState.status == null -> stringResource(R.string.gridfinder_status_unknown) to ElevationLowColor
uiState.status == GridFixStatus.ON_GRID_CORNER -> stringResource(R.string.gridfinder_status_corner) to ElevationHighColor
uiState.status == GridFixStatus.ON_GRID_LINE -> stringResource(R.string.gridfinder_status_line) to ElevationHighColor
else -> stringResource(R.string.gridfinder_status_inside) to MaterialTheme.colorScheme.onSurface
}
GridCard(Modifier.fillMaxWidth()) {
Text(
text = headline,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.fillMaxWidth()
)
if (grids.isNotEmpty()) {
GridMetricRow(
label = stringResource(R.string.gridfinder_label_claimable),
value = grids,
valueColor = when (uiState.status) {
GridFixStatus.ON_GRID_CORNER, GridFixStatus.ON_GRID_LINE -> ElevationHighColor
else -> null
}
)
}
when {
uiState.fix == null -> Unit
uiState.status == GridFixStatus.INSIDE_GRID -> Text(
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
uiState.isStandingConfirmed -> Text(
text = stringResource(R.string.gridfinder_status_confirmed, PRECISE_FIXES_REQUIRED),
fontSize = 12.sp,
color = ElevationHighColor
)
else -> Text(
text = stringResource(
R.string.gridfinder_status_unconfirmed,
uiState.preciseFixesInARow,
PRECISE_FIXES_REQUIRED
),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (!uiState.permissionGranted) {
Button(onClick = requestLocationPermission) {
Text(text = stringResource(R.string.gridfinder_grant_permission))
}
}
}
}
@Composable
private fun LocationCard(
uiState: GridFinderState,
onAction: (GridFinderAction) -> Unit,
openLoTWStation: () -> Unit
) {
GridCard(Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.gridfinder_card_location),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
val geometry = uiState.geometry
val fix = uiState.fix
GridMetricRow(stringResource(R.string.gridfinder_label_grid4), geometry?.grid4 ?: "-")
GridMetricRow(stringResource(R.string.gridfinder_label_grid6), geometry?.grid6 ?: "-")
GridMetricRow(stringResource(R.string.gridfinder_label_grid8), geometry?.grid8 ?: "-")
GridMetricRow(
label = stringResource(R.string.gridfinder_label_latitude),
value = fix?.let { String.format("%+.5f°", it.latitude) } ?: "-"
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_longitude),
value = fix?.let { String.format("%+.5f°", it.longitude) } ?: "-"
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_altitude),
value = fix?.let { String.format("%.0f m", it.altitudeMeters) } ?: "-"
)
if (uiState.fix != null) {
Button(
onClick = { onAction(GridFinderAction.SetAsStation) },
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.gridfinder_action_set_station))
}
if (uiState.isStationSaved) {
Text(
text = stringResource(R.string.gridfinder_action_station_saved),
fontSize = 12.sp,
color = ElevationHighColor
)
}
val claimable = uiState.claimableGrids
if (claimable.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Button(
onClick = openLoTWStation,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(
R.string.gridfinder_action_set_lotw_station,
claimable.joinToString(",")
)
)
}
Text(
text = stringResource(R.string.gridfinder_lotw_station_hint),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun GuideCard(
title: String,
bearing: Double,
distanceMeters: Double,
direction: GridBoundaryDirection?,
grids: List<String>,
tint: Color,
heading: Float?,
extra: String? = null
) {
GridCard(Modifier.fillMaxWidth()) {
Text(
text = title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
GridCompassDial(
bearingDegrees = bearing,
headingDegrees = heading,
tint = tint,
modifier = Modifier.size(108.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
GridMetricRow(
label = stringResource(R.string.gridfinder_label_distance),
value = formatDistance(distanceMeters),
valueColor = tint
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_bearing),
value = String.format("%.0f° %s", bearing, cardinal(bearing))
)
if (direction != null) {
GridMetricRow(stringResource(R.string.gridfinder_label_direction), directionLabel(direction))
}
if (grids.isNotEmpty()) {
GridMetricRow(stringResource(R.string.gridfinder_label_grids), grids.joinToString(" / "))
}
extra?.let {
Text(text = it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
@Composable
private fun BoundaryLinesCard(geometry: GridGeometry) {
GridCard(Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.gridfinder_card_lines),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_lat_line),
value = stringResource(
R.string.gridfinder_line_value,
formatDistance(geometry.latLineMeters),
directionLabel(geometry.latLineDirection)
)
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_lon_line),
value = stringResource(
R.string.gridfinder_line_value,
formatDistance(geometry.lonLineMeters),
directionLabel(geometry.lonLineDirection)
)
)
Text(
text = stringResource(R.string.gridfinder_vucc_rule_note, VUCC_BOUNDARY_TOLERANCE_METERS),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ProximityCard(
geometry: GridGeometry,
heading: Float?
) {
GridCard(Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.gridfinder_card_map),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
GridProximityMap(
geometry = geometry,
headingDegrees = heading,
modifier = Modifier
.fillMaxWidth()
.height(240.dp)
)
Text(
text = stringResource(R.string.gridfinder_map_legend),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun FixQualityCard(uiState: GridFinderState) {
val fix = uiState.fix
GridCard(Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.gridfinder_card_fix),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_accuracy),
value = fix?.let { String.format("± %.1f m", it.accuracyMeters) } ?: "-",
valueColor = fix?.let {
if (it.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) ElevationHighColor else ElevationLowColor
}
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_fix_age),
value = fix?.let { formatAge(it.epochMs) } ?: "-"
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_provider),
value = stringResource(
if (uiState.providerEnabled) {
R.string.gridfinder_value_provider_on
} else {
R.string.gridfinder_value_provider_off
}
)
)
LinearProgressIndicator(
progress = { uiState.preciseFixesInARow.toFloat() / PRECISE_FIXES_REQUIRED },
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun CompassCard(uiState: GridFinderState, onAction: (GridFinderAction) -> Unit) {
var showCalibration by rememberSaveable { mutableStateOf(false) }
GridCard(Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.gridfinder_card_compass),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_heading),
value = uiState.headingDegrees?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-"
)
GridMetricRow(
label = stringResource(R.string.gridfinder_label_compass_accuracy),
value = stringResource(compassAccuracyRes(uiState.compassAccuracy))
)
if (uiState.isCompassEnabled) {
Button(
onClick = { showCalibration = true },
enabled = uiState.compassAccuracy != CompassAccuracy.UNAVAILABLE,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(R.string.gridfinder_action_calibrate))
}
}
if (!uiState.isCompassEnabled) {
Text(
text = stringResource(R.string.gridfinder_compass_disabled_hint),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else if (uiState.compassAccuracy == CompassAccuracy.UNAVAILABLE) {
Text(
text = stringResource(R.string.gridfinder_compass_unavailable_hint),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = stringResource(R.string.gridfinder_compass_hint),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showCalibration) {
GridCompassCalibrationDialog(
accuracy = uiState.compassAccuracy,
heading = uiState.headingDegrees,
onSaveOffset = { offset -> onAction(GridFinderAction.SetCompassOffset(offset)) },
onDismiss = { showCalibration = false }
)
}
}
/**
* Compact figure-eight calibration dialog. It mirrors the settings screen's
* compass calibration but is self-contained in this module; the trim is saved
* through the same setting the radar page uses, so both stay in sync.
*/
@Composable
private fun GridCompassCalibrationDialog(
accuracy: CompassAccuracy,
heading: Float?,
onSaveOffset: (Float) -> Unit,
onDismiss: () -> Unit
) {
var offsetText by rememberSaveable { mutableStateOf("") }
val parsedOffset = offsetText.replace(',', '.').toFloatOrNull()?.takeIf { it in -180f..180f }
val progress = when (accuracy) {
CompassAccuracy.HIGH -> 1f
CompassAccuracy.MEDIUM -> 0.66f
CompassAccuracy.LOW -> 0.33f
else -> 0f
}
SharedDialog(
title = stringResource(R.string.prefs_compass_calibration_title),
onDismissRequest = onDismiss,
onCancel = onDismiss,
acceptEnabled = parsedOffset != null,
onAccept = {
parsedOffset?.let(onSaveOffset)
onDismiss()
}
) { padding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = padding)
) {
Text(
text = "∞",
fontSize = 84.sp,
color = MaterialTheme.colorScheme.primary,
lineHeight = 84.sp
)
Text(
text = stringResource(R.string.prefs_compass_calibration_instruction),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(R.string.prefs_compass_accuracy, stringResource(compassAccuracyRes(accuracy))),
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = heading?.let { String.format("%.0f° %s", it, cardinal(it.toDouble())) } ?: "-",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
text = stringResource(R.string.prefs_compass_offset_support),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedTextField(
value = offsetText,
onValueChange = { offsetText = it },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
label = { Text(text = stringResource(R.string.prefs_compass_offset)) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
private fun GridCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
ElevatedCard(modifier = modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(12.dp),
content = content
)
}
}
@Composable
private fun GridMetricRow(label: String, value: String, valueColor: Color? = null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = valueColor ?: MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun directionLabel(direction: GridBoundaryDirection): String = stringResource(
when (direction) {
GridBoundaryDirection.NORTH -> R.string.gridfinder_direction_north
GridBoundaryDirection.SOUTH -> R.string.gridfinder_direction_south
GridBoundaryDirection.EAST -> R.string.gridfinder_direction_east
GridBoundaryDirection.WEST -> R.string.gridfinder_direction_west
}
)
@Composable
private fun compassAccuracyRes(accuracy: CompassAccuracy): Int = when (accuracy) {
CompassAccuracy.HIGH -> R.string.prefs_compass_accuracy_high
CompassAccuracy.MEDIUM -> R.string.prefs_compass_accuracy_medium
CompassAccuracy.LOW -> R.string.prefs_compass_accuracy_low
CompassAccuracy.UNRELIABLE -> R.string.prefs_compass_accuracy_unreliable
CompassAccuracy.UNAVAILABLE -> R.string.prefs_compass_accuracy_unavailable
}
private fun formatDistance(meters: Double): String {
return if (abs(meters) < 1000.0) {
String.format("%.1f m", meters)
} else {
String.format("%.2f km", meters / 1000.0)
}
}
private fun formatAge(epochMs: Long): String {
val seconds = ((System.currentTimeMillis() - epochMs) / 1000L).coerceAtLeast(0L)
return if (seconds < 60L) "${seconds}s" else "${seconds / 60L}min"
}
private fun cardinal(bearingDegrees: Double): String {
val normalized = (bearingDegrees % 360.0 + 360.0) % 360.0
val names = listOf("N", "NE", "E", "SE", "S", "SW", "W", "NW", "N")
return names[((normalized + 22.5) / 45.0).toInt().coerceIn(0, 8)]
}
@@ -0,0 +1,92 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.gridfinder
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
import com.rtbishop.look4sat.core.domain.repository.LocationFix
import com.rtbishop.look4sat.core.domain.utility.GridFixStatus
import com.rtbishop.look4sat.core.domain.utility.GridGeometry
/**
* How many consecutive precise fixes are required before a grid-line / corner
* claim is presented as confirmed. A single fix is not trustworthy enough to
* tell a walker that they are standing on a 6 m boundary.
*/
const val PRECISE_FIXES_REQUIRED = 3
/** A fix must be at least this good before its grid line/corner claim counts. */
const val VUCC_FIX_ACCURACY_LIMIT_METERS = 6.1f
/** Outcome of pushing one fix through the 20 ft precision gate. */
data class FixGateResult(val streak: Int, val isConfirmed: Boolean)
/**
* Counts consecutive fixes that are both good to the 20 ft rule and inside a
* grid-square boundary. Any one bad fix resets the counter, so a claim is only
* confirmed once the walker has held it for several readings.
*/
fun nextFixGate(previousStreak: Int, fix: LocationFix): FixGateResult {
val streak = if (fix.isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS)) {
(previousStreak + 1).coerceAtMost(PRECISE_FIXES_REQUIRED)
} else {
0
}
return FixGateResult(streak = streak, isConfirmed = streak >= PRECISE_FIXES_REQUIRED)
}
/**
* Grid Finder screen state. [status] and [claimableGrids] are always derived
* from the latest fix's distance to the boundary; [isStandingConfirmed] says
* whether that reading is backed by [PRECISE_FIXES_REQUIRED] consecutive fixes
* that are themselves good to the 20 ft rule.
*/
data class GridFinderState(
val permissionGranted: Boolean = false,
val providerEnabled: Boolean = true,
val providerReady: Boolean = true,
val fix: LocationFix? = null,
val geometry: GridGeometry? = null,
val claimableGrids: List<String> = emptyList(),
val status: GridFixStatus? = null,
val preciseFixesInARow: Int = 0,
val isStandingConfirmed: Boolean = false,
val headingDegrees: Float? = null,
val compassAccuracy: CompassAccuracy = CompassAccuracy.UNAVAILABLE,
val isCompassEnabled: Boolean = false,
val isStationSaved: Boolean = false
) {
val isWaitingForFix: Boolean get() = fix == null
}
sealed interface GridFinderAction {
/** Called when the screen appears: starts location and (if enabled) compass updates. */
data object StartUpdates : GridFinderAction
/** Called when the screen goes away: releases both listeners. */
data object StopUpdates : GridFinderAction
data class LocationPermissionResult(val granted: Boolean) : GridFinderAction
/** Copies the current fix into the station position that drives pass predictions. */
data object SetAsStation : GridFinderAction
data object StationSavedShown : GridFinderAction
/** Saves the compass trim, shared with the radar page's calibration. */
data class SetCompassOffset(val offsetDegrees: Float) : GridFinderAction
}
@@ -0,0 +1,214 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.gridfinder
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ILocationRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.LocationFix
import com.rtbishop.look4sat.core.domain.repository.LocationState
import com.rtbishop.look4sat.core.domain.utility.gridFixStatus
import com.rtbishop.look4sat.core.domain.utility.gridGeometry
import com.rtbishop.look4sat.core.domain.utility.vuccClaimableGrids
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.math.abs
/** Declination is only recomputed once the fix has moved this far (degrees). */
private const val DECLINATION_REFRESH_DEGREES = 0.01
/**
* Drives the Grid Finder: streams the live fix, turns it into grid-line /
* grid-corner geometry, and gates the VUCC claim on [PRECISE_FIXES_REQUIRED]
* consecutive fixes good to the 20 ft rule.
*
* The live fix never touches the station position - [GridFinderAction.SetAsStation]
* is the only thing that writes it, and only when the operator asks.
*/
class GridFinderViewModel(
private val settingsRepo: ISettingsRepo,
private val locationRepo: ILocationRepo,
private val sensorsRepo: ISensorsRepo
) : ViewModel() {
private val _uiState = MutableStateFlow(GridFinderState())
val uiState: StateFlow<GridFinderState> = _uiState.asStateFlow()
private var locationJob: Job? = null
private var compassJob: Job? = null
private var accuracyJob: Job? = null
private var sensorsEnabledByUs = false
private var declinationCache: Pair<GeoPos, Float>? = null
private var lastSensorAzimuth: Float? = null
fun onAction(action: GridFinderAction) {
when (action) {
GridFinderAction.StartUpdates -> startUpdates()
GridFinderAction.StopUpdates -> stopUpdates()
is GridFinderAction.LocationPermissionResult -> startUpdates()
GridFinderAction.SetAsStation -> setAsStation()
GridFinderAction.StationSavedShown -> _uiState.update { it.copy(isStationSaved = false) }
is GridFinderAction.SetCompassOffset -> setCompassOffset(action.offsetDegrees)
}
}
/**
* Saves the compass trim shared with the radar page, then recomputes the
* heading from the last raw sensor sample so the dial reacts immediately.
*/
private fun setCompassOffset(offsetDegrees: Float) {
settingsRepo.updateOtherSettings { it.copy(compassOffsetDegrees = offsetDegrees) }
lastSensorAzimuth?.let { azimuth ->
_uiState.update { it.copy(headingDegrees = correctedHeading(azimuth)) }
}
}
private fun startUpdates() {
val permissionGranted = locationRepo.hasPermission()
_uiState.update { it.copy(permissionGranted = permissionGranted) }
if (!permissionGranted) return
locationRepo.startUpdates()
collectLocation()
collectCompass()
}
private fun collectLocation() {
if (locationJob != null) return
locationJob = viewModelScope.launch {
locationRepo.state.collect { state ->
when (state) {
is LocationState.Fix -> onFix(state.location)
is LocationState.Waiting -> _uiState.update {
it.copy(
permissionGranted = state.permissionGranted,
providerEnabled = state.providerEnabled
)
}
}
}
}
}
private fun collectCompass() {
val compassEnabled = settingsRepo.otherSettings.value.stateOfSensors
_uiState.update {
it.copy(isCompassEnabled = compassEnabled, compassAccuracy = sensorsRepo.compassAccuracy.value)
}
if (!compassEnabled || compassJob != null) return
sensorsRepo.enableSensor()
sensorsEnabledByUs = true
accuracyJob = viewModelScope.launch {
sensorsRepo.compassAccuracy.collect { accuracy ->
_uiState.update { it.copy(compassAccuracy = accuracy) }
}
}
compassJob = viewModelScope.launch {
sensorsRepo.sensorData.collect { data ->
lastSensorAzimuth = data.first
_uiState.update { it.copy(headingDegrees = correctedHeading(data.first)) }
}
}
}
/** Same convention as the radar page: sensor azimuth + magnetic declination + manual trim. */
private fun correctedHeading(sensorAzimuth: Float): Float {
val offset = settingsRepo.otherSettings.value.compassOffsetDegrees
val azimuth = sensorAzimuth + magDeclination() + offset
return (azimuth % 360f + 360f) % 360f
}
private fun magDeclination(): Float {
val position = _uiState.value.fix?.let { GeoPos(it.latitude, it.longitude, it.altitudeMeters) }
?: settingsRepo.stationPosition.value
val cached = declinationCache
if (cached != null &&
abs(cached.first.latitude - position.latitude) < DECLINATION_REFRESH_DEGREES &&
abs(cached.first.longitude - position.longitude) < DECLINATION_REFRESH_DEGREES
) {
return cached.second
}
val declination = sensorsRepo.getMagDeclination(position)
declinationCache = position to declination
return declination
}
private fun onFix(fix: LocationFix) {
val geometry = gridGeometry(fix.latitude, fix.longitude)
val claimable = vuccClaimableGrids(fix.latitude, fix.longitude)
val gate = nextFixGate(_uiState.value.preciseFixesInARow, fix)
_uiState.update {
it.copy(
permissionGranted = true,
providerReady = geometry != null,
fix = fix,
geometry = geometry,
claimableGrids = claimable,
status = if (claimable.isEmpty()) null else gridFixStatus(claimable),
preciseFixesInARow = gate.streak,
isStandingConfirmed = gate.isConfirmed
)
}
}
private fun setAsStation() {
val fix = _uiState.value.fix ?: return
val saved = settingsRepo.setStationPosition(fix.latitude, fix.longitude, fix.altitudeMeters)
if (saved) _uiState.update { it.copy(isStationSaved = true) }
}
private fun stopUpdates() {
locationRepo.stopUpdates()
locationJob?.cancel()
locationJob = null
compassJob?.cancel()
compassJob = null
accuracyJob?.cancel()
accuracyJob = null
if (sensorsEnabledByUs) {
sensorsRepo.disableSensor()
sensorsEnabledByUs = false
}
}
override fun onCleared() {
stopUpdates()
super.onCleared()
}
companion object {
fun factory(container: IMainContainer) = viewModelFactory {
initializer {
GridFinderViewModel(
settingsRepo = container.settingsRepo,
locationRepo = container.locationRepo,
sensorsRepo = container.provideSensorsRepo()
)
}
}
}
}
@@ -0,0 +1,74 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.gridfinder
import com.rtbishop.look4sat.core.domain.repository.LocationFix
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GridFinderGateTest {
@Test
fun `fix within the 20 ft rule is precise enough`() {
assertTrue(fix(accuracy = 6.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
}
@Test
fun `fix worse than the 20 ft rule is not precise enough`() {
assertFalse(fix(accuracy = 12.0f).isPreciseEnough(VUCC_FIX_ACCURACY_LIMIT_METERS))
}
@Test
fun `streak grows on good fixes and confirms on the third`() {
val first = nextFixGate(0, fix(accuracy = 4.0f))
assertEquals(1, first.streak)
assertFalse(first.isConfirmed)
val second = nextFixGate(first.streak, fix(accuracy = 4.0f))
assertEquals(2, second.streak)
assertFalse(second.isConfirmed)
val third = nextFixGate(second.streak, fix(accuracy = 4.0f))
assertEquals(PRECISE_FIXES_REQUIRED, third.streak)
assertTrue(third.isConfirmed)
}
@Test
fun `streak stays capped once confirmed`() {
val capped = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 1.0f))
assertEquals(PRECISE_FIXES_REQUIRED, capped.streak)
assertTrue(capped.isConfirmed)
}
@Test
fun `one coarse fix resets a confirmed streak`() {
val reset = nextFixGate(PRECISE_FIXES_REQUIRED, fix(accuracy = 25.0f))
assertEquals(0, reset.streak)
assertFalse(reset.isConfirmed)
}
private fun fix(accuracy: Float) = LocationFix(
latitude = 22.0,
longitude = 114.0,
altitudeMeters = 30.0,
accuracyMeters = accuracy,
epochMs = 0L
)
}
@@ -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
@@ -93,6 +93,7 @@ fun LoTWUploadConfigDialog(
busy: Boolean,
error: LoTWUploadError?,
errorDetail: String = "",
initialGrid: String = "",
onDismiss: () -> Unit,
onImport: (ByteArray, CharArray) -> Unit,
onRemove: () -> Unit,
@@ -100,7 +101,9 @@ fun LoTWUploadConfigDialog(
) {
var password by remember { mutableStateOf("") }
var selectedFile by remember { mutableStateOf<Uri?>(null) }
var grid by remember { mutableStateOf(station?.grid.orEmpty()) }
// A grid Finder prefill (e.g. "OL62,OL72" for a line, four grids for a
// corner) wins over the saved station grid; otherwise keep what TQSL had.
var grid by remember { mutableStateOf(initialGrid.ifBlank { station?.grid.orEmpty() }) }
var cqZone by remember { mutableStateOf(station?.cqZone.orEmpty()) }
var ituZone by remember { mutableStateOf(station?.ituZone.orEmpty()) }
var iota by remember { mutableStateOf(station?.iota.orEmpty()) }
@@ -0,0 +1,71 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.settings
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
/**
* Full-screen LoTW upload certificate + station location page.
*
* Reached from the Grid Finder's "Set as LoTW station location" button: the
* grids the operator currently stands on (1 inside, 2 on a line, 4 on a corner)
* are dropped into [container.pendingLoTWStationGrid] and consumed here as the
* station grid prefill, so the next upload stamps those VUCC squares instead of
* the certificate's home grid.
*/
@Composable
fun LoTWUploadDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container, context))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Consume the prefill once; re-entering the page later keeps the saved grid.
val initialGrid = container.pendingLoTWStationGrid.value.orEmpty()
LaunchedEffect(Unit) {
container.setPendingLoTWStationGrid(null)
}
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
LoTWUploadConfigDialog(
certificate = uiState.lotwCertificate,
station = uiState.lotwStation,
stationMeta = uiState.lotwStationMeta,
busy = uiState.lotwUploadBusy,
error = uiState.lotwUploadError,
errorDetail = uiState.lotwUploadErrorDetail,
initialGrid = initialGrid,
onDismiss = navigateUp,
onImport = { bytes, password ->
viewModel.onAction(SettingsAction.ImportLoTWCertificate(bytes, password))
},
onRemove = { viewModel.onAction(SettingsAction.RemoveLoTWCertificate) },
onSaveStation = { viewModel.onAction(SettingsAction.SaveLoTWStation(it)) }
)
}
}
@@ -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) {