From a9fe0b74e95c1b54a0d9e422bd3cb0ec73146c8b Mon Sep 17 00:00:00 2001 From: atsunatsu Date: Mon, 7 Sep 2026 17:54:14 +0800 Subject: [PATCH] feat: mutual match screen target-grid chip (needle bearing + grid + distance); grid mode centering no longer swallowed when station position is not yet available --- .../core/presentation/GridTargetChip.kt | 146 ++++++++++++++++++ .../look4sat/feature/map/MapScreen.kt | 11 +- .../look4sat/feature/mutual/MutualScreen.kt | 25 ++- gradle/libs.versions.toml | 4 +- 4 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridTargetChip.kt diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridTargetChip.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridTargetChip.kt new file mode 100644 index 00000000..8cd54abb --- /dev/null +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/GridTargetChip.kt @@ -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 +} 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 8d86c6ed..a9a9ff89 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 @@ -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. var prevGridMode by remember { mutableStateOf(false) } 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( 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) { uiState.stationPosition?.let { setStationPosition(it, view) } uiState.track?.let { setSatelliteTrack(it, view) } diff --git a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt index ebc41802..c42e923c 100644 --- a/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt +++ b/feature/mutual/src/main/java/com/rtbishop/look4sat/feature/mutual/MutualScreen.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.repository.MutualPassData 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.ScreenColumn import com.rtbishop.look4sat.core.presentation.TopBar @@ -114,7 +115,29 @@ fun MutualScreen( 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) + } } } ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9643b29..c63e73e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "456" +appVersionCode = "457" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.4.6-ba7opf.9.4" +appVersionName = "4.4.6-ba7opf.9.5" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry