mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
feat: mutual match screen target-grid chip (needle bearing + grid + distance); grid mode centering no longer swallowed when station position is not yet available
This commit is contained in:
1 parent
2c9a5b376f
commit
a9fe0b74e9
4 files changed
+181
-5
No files matched your search
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Maidenhead locator for display. Per IARU convention and the user's
|
||||||
|
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
|
||||||
|
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
|
||||||
|
*/
|
||||||
|
fun formatGridDisplay(grid: String): String? {
|
||||||
|
val g = grid.trim().uppercase()
|
||||||
|
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
|
||||||
|
if (g.length == 4) return g
|
||||||
|
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
|
||||||
|
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
|
||||||
|
* great-circle bearing to the target grid, 0 deg = north), grid locator and
|
||||||
|
* distance. Shows a dimmed placeholder when no target grid is entered.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GridTargetChip(
|
||||||
|
grid: String?,
|
||||||
|
distanceKm: Double?,
|
||||||
|
bearingDeg: Double?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||||
|
val accent = MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
NavigationNeedle(
|
||||||
|
bearingDeg = bearingDeg ?: 0.0,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
dimmed = !configured,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = grid?.let { formatGridDisplay(it) } ?: "----",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = if (configured) Color.White else placeholder,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = if (configured) MaterialTheme.colorScheme.onSurfaceVariant else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map-style navigation needle: slim triangular pointer whose tip points at
|
||||||
|
* [bearingDeg] (0 = north/up, clockwise positive).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavigationNeedle(
|
||||||
|
bearingDeg: Double,
|
||||||
|
color: Color,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val cx = size.width / 2f
|
||||||
|
val cy = size.height / 2f
|
||||||
|
val r = size.minDimension / 2f
|
||||||
|
if (dimmed) {
|
||||||
|
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
|
||||||
|
}
|
||||||
|
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
|
||||||
|
lineTo(cx - r * 0.42f, cy + r * 0.55f)
|
||||||
|
lineTo(cx, cy + r * 0.28f)
|
||||||
|
lineTo(cx + r * 0.42f, cy + r * 0.55f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Great-circle distance in km between two positions. */
|
||||||
|
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLat = (lat2 - lat1) * rad
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
|
||||||
|
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
|
||||||
|
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val y = sin(dLon) * cos(lat2 * rad)
|
||||||
|
val x = cos(lat1 * rad) * sin(lat2 * rad) -
|
||||||
|
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
|
||||||
|
return (Math.atan2(y, x) / rad + 360.0) % 360.0
|
||||||
|
}
|
||||||
@@ -165,11 +165,18 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
// the map page with grid mode already ON also centers the map.
|
// the map page with grid mode already ON also centers the map.
|
||||||
var prevGridMode by remember { mutableStateOf(false) }
|
var prevGridMode by remember { mutableStateOf(false) }
|
||||||
AndroidView({ mapView }) { view ->
|
AndroidView({ mapView }) { view ->
|
||||||
|
// Center on the station grid whenever entering grid mode.
|
||||||
|
// Only mark the transition as consumed once a real position
|
||||||
|
// was available; otherwise a first frame with a null
|
||||||
|
// stationPosition would swallow the centering forever.
|
||||||
|
val shouldCenter = uiState.isGridMode && !prevGridMode
|
||||||
setGridMode(
|
setGridMode(
|
||||||
uiState.isGridMode, uiState.workedGrids, view,
|
uiState.isGridMode, uiState.workedGrids, view,
|
||||||
if (uiState.isGridMode && !prevGridMode) uiState.stationPosition else null
|
if (shouldCenter) uiState.stationPosition else null
|
||||||
)
|
)
|
||||||
prevGridMode = uiState.isGridMode
|
if (!shouldCenter || uiState.stationPosition != null) {
|
||||||
|
prevGridMode = uiState.isGridMode
|
||||||
|
}
|
||||||
if (!uiState.isGridMode) {
|
if (!uiState.isGridMode) {
|
||||||
uiState.stationPosition?.let { setStationPosition(it, view) }
|
uiState.stationPosition?.let { setStationPosition(it, view) }
|
||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
import com.rtbishop.look4sat.core.domain.repository.TrackSampleData
|
||||||
|
import com.rtbishop.look4sat.core.presentation.GridTargetChip
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
@@ -114,7 +115,29 @@ fun MutualScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MutualStatusChip(state)
|
// Target-grid chip: station B grid (station A defaults to
|
||||||
|
// the user's own position), bearing/distance from A to B.
|
||||||
|
val gridB = state.stationBGrid.trim().uppercase()
|
||||||
|
val posA = com.rtbishop.look4sat.core.domain.utility.qthToPosition(state.stationAGrid)
|
||||||
|
?: state.stationALat.toDoubleOrNull()?.let { lat ->
|
||||||
|
state.stationALon.toDoubleOrNull()?.let { lon ->
|
||||||
|
com.rtbishop.look4sat.core.domain.predict.GeoPos(lat, lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val posB = com.rtbishop.look4sat.core.domain.utility.qthToPosition(gridB)
|
||||||
|
if (posA != null && posB != null) {
|
||||||
|
GridTargetChip(
|
||||||
|
grid = gridB,
|
||||||
|
distanceKm = com.rtbishop.look4sat.core.presentation.greatCircleDistanceKm(
|
||||||
|
posA.latitude, posA.longitude, posB.latitude, posB.longitude
|
||||||
|
),
|
||||||
|
bearingDeg = com.rtbishop.look4sat.core.presentation.greatCircleBearingDeg(
|
||||||
|
posA.latitude, posA.longitude, posB.latitude, posB.longitude
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GridTargetChip(grid = null, distanceKm = null, bearingDeg = null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "456"
|
appVersionCode = "457"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6-ba7opf.9.4"
|
appVersionName = "4.4.6-ba7opf.9.5"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
Reference in new issue
Block a user