mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-04 11:37:22 +00:00
feat(map): tap worked grid at zoom>=7 to show per-grid satellite QSO dialog
- LoTW sync now stores per-QSO detail (call/time/sat/mode/bands) grouped by gridsquare (PROP_MODE=SAT only), single JSON blob in SharedPreferences - GridQso model derives band label (V/U, U/V, V/A...) from BAND_RX/BAND - Tap detection via osmdroid MapEventsOverlay; selected grid outlined green - Centered dialog: calls sorted by first-contact time, first-QSO summary, expandable call rows list subsequent QSOs in that grid; tap outside closes
This commit is contained in:
1 parent
9ed2aebc9d
commit
39d4498a54
14 files changed
+557
-14
No files matched your search
+21
-9
@@ -61,6 +61,11 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 90, 200, 255)
|
||||
}
|
||||
private val selectedLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 4.5f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.argb(255, 76, 217, 100)
|
||||
}
|
||||
|
||||
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
|
||||
var workedGrids: Set<String> = emptySet()
|
||||
@@ -68,6 +73,9 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
/** The station's own 4-char gridsquare, drawn with a distinct outline. */
|
||||
var ownGrid: String? = null
|
||||
|
||||
/** The currently tapped worked grid (4-char) highlighted with a distinct outline, or null. */
|
||||
var selectedGrid: String? = null
|
||||
|
||||
/** Viewport width, refreshed each draw; used by projectionToX bounds. */
|
||||
private var canvasWidthPx = 1080f
|
||||
|
||||
@@ -118,6 +126,8 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
|
||||
// The station's own grid square (4-char, only meaningful at sub-square zoom)
|
||||
val ownCell = ownGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
// The tapped worked grid gets a distinct outline (same zoom gate).
|
||||
val selectedCell = selectedGrid?.takeIf { zoom >= GRID_ZOOM_SUB }
|
||||
|
||||
// Worked-grid highlight fills.
|
||||
// - Sub-square zoom: fill each worked 4-char cell directly.
|
||||
@@ -180,22 +190,24 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
}
|
||||
|
||||
// The station's own grid square: redraw its four borders thicker on top.
|
||||
if (ownCell != null) {
|
||||
val ownColIdx = firstCol..lastCol
|
||||
// The tapped worked grid gets the same treatment in a different color.
|
||||
if (ownCell != null || selectedCell != null) {
|
||||
for (row in firstRow..lastRow) {
|
||||
val lat = row * cellLat
|
||||
if (lat < -90.0 || lat >= 90.0) continue
|
||||
for (col in ownColIdx) {
|
||||
for (col in firstCol..lastCol) {
|
||||
val lon = col * cellLon
|
||||
if (cellLabel(lat, lon, zoom) != ownCell) continue
|
||||
val label = cellLabel(lat, lon, zoom)
|
||||
if (label != ownCell && label != selectedCell) continue
|
||||
val yTop = projectionToY(projection, lat + cellLat) ?: continue
|
||||
val yBottom = projectionToY(projection, lat) ?: continue
|
||||
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
|
||||
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, ownLinePaint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, ownLinePaint)
|
||||
val paint = if (label == selectedCell) selectedLinePaint else ownLinePaint
|
||||
canvas.drawLine(xLeft, yTop, xRight, yTop, paint)
|
||||
canvas.drawLine(xLeft, yBottom, xRight, yBottom, paint)
|
||||
canvas.drawLine(xLeft, yTop, xLeft, yBottom, paint)
|
||||
canvas.drawLine(xRight, yTop, xRight, yBottom, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,7 +318,7 @@ class MaidenheadGridOverlay : Overlay() {
|
||||
return l
|
||||
}
|
||||
|
||||
private companion object {
|
||||
internal companion object {
|
||||
const val FIELD_LAT = 10.0
|
||||
const val FIELD_LON = 20.0
|
||||
const val SUB_SQUARE_LAT = 1.0
|
||||
|
||||
@@ -25,14 +25,18 @@ import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
@@ -138,6 +142,36 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
|
||||
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
|
||||
// only consumer and it resets when leaving the page.
|
||||
var selectedGrid by remember { mutableStateOf<String?>(null) }
|
||||
// Attach the tap listener whenever grid mode / worked grids change.
|
||||
val workedGrids = uiState.workedGrids
|
||||
val isGridMode = uiState.isGridMode
|
||||
DisposableEffect(isGridMode, workedGrids) {
|
||||
val receiver = object : org.osmdroid.events.MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
if (!isGridMode || p == null) return false
|
||||
val zoom = mapView.zoomLevelDouble
|
||||
if (zoom < MaidenheadGridOverlay.LABEL_ZOOM_SUB) return false
|
||||
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
|
||||
if (grid !in workedGrids) return false
|
||||
selectedGrid = grid
|
||||
return true
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean = false
|
||||
}
|
||||
val eventsOverlay = org.osmdroid.views.overlay.MapEventsOverlay(receiver)
|
||||
mapView.overlays.add(eventsOverlay)
|
||||
onDispose { mapView.overlays.remove(eventsOverlay) }
|
||||
}
|
||||
// Keep the overlay's selected highlight in sync with the dialog.
|
||||
LaunchedEffect(selectedGrid) {
|
||||
(mapView.overlays.getOrNull(OVERLAY_GRID) as? MaidenheadGridOverlay)?.selectedGrid = selectedGrid
|
||||
mapView.invalidate()
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.track) {
|
||||
// In grid mode the map is centered on the local grid square; following
|
||||
// the satellite subpoint here would override that centering on every
|
||||
@@ -200,8 +234,155 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
}
|
||||
}
|
||||
}
|
||||
// Centered dialog listing the tapped worked grid's confirmed satellite QSOs.
|
||||
// Dismissed by tapping outside (no explicit close button).
|
||||
selectedGrid?.let { grid ->
|
||||
WorkedGridQsoDialog(
|
||||
grid = grid,
|
||||
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
|
||||
isUtc = uiState.isUtc,
|
||||
onDismiss = { selectedGrid = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// region Worked-grid QSO dialog
|
||||
|
||||
@Composable
|
||||
private fun WorkedGridQsoDialog(
|
||||
grid: String,
|
||||
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
|
||||
// Centered card, ~85% width, internal scroll for long lists.
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
modifier = Modifier.fillMaxWidth(0.88f)
|
||||
) {
|
||||
Column {
|
||||
// Header: grid + counts
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
text = grid,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
|
||||
)
|
||||
val distinctCalls = qsos.map { it.call }.distinct().size
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_calls_count, distinctCalls, qsos.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.HorizontalDivider()
|
||||
if (qsos.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_no_sat),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
// Group by callsign preserving first-contact order (list is
|
||||
// already sorted oldest-first); expandable rows.
|
||||
val grouped = remember(qsos) {
|
||||
qsos.groupBy { it.call }.entries.sortedBy { it.value.first().epochMs }
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 380.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
grouped.forEach { (call, callQsos) ->
|
||||
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
|
||||
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkedGridCallRow(
|
||||
call: String,
|
||||
callQsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
|
||||
isUtc: Boolean,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val first = callQsos.first()
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
) {
|
||||
Text(
|
||||
text = call,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = formatDate(first.epochMs, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = if (expanded) " ▴" else " ▾",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
// First-QSO summary line, always visible.
|
||||
Text(
|
||||
text = stringResource(R.string.grid_qso_first) + " · " + qsoSummary(first, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
// Subsequent QSOs in this grid, revealed on expand.
|
||||
if (expanded && callQsos.size > 1) {
|
||||
callQsos.drop(1).forEachIndexed { index, qso ->
|
||||
val ordinal = "${index + 2}"
|
||||
Text(
|
||||
text = "$ordinal " + qsoSummary(qso, isUtc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun qsoSummary(qso: com.rtbishop.look4sat.core.domain.model.GridQso, isUtc: Boolean): String {
|
||||
val sat = qso.satName.ifBlank { "?" }
|
||||
val mode = qso.mode.ifBlank { "?" }
|
||||
val band = qso.bandLabel
|
||||
val time = formatTime(qso.epochMs, isUtc)
|
||||
return listOf(sat, mode, band, time).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun formatDate(epochMs: Long, isUtc: Boolean): String {
|
||||
if (epochMs <= 0L) return "--"
|
||||
val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault()
|
||||
return java.time.Instant.ofEpochMilli(epochMs).atZone(zone)
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
}
|
||||
|
||||
private fun formatTime(epochMs: Long, isUtc: Boolean): String {
|
||||
if (epochMs <= 0L) return "--:--"
|
||||
val zone = if (isUtc) java.time.ZoneOffset.UTC else java.time.ZoneId.systemDefault()
|
||||
return java.time.Instant.ofEpochMilli(epochMs).atZone(zone)
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern(if (isUtc) "HH:mm'Z'" else "HH:mm"))
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Map data composables
|
||||
@Composable
|
||||
private fun MapDataCard(data: MapData) {
|
||||
@@ -345,6 +526,17 @@ private fun ownGridOf(stationPosition: GeoPos?): String? {
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
/** The 4-char Maidenhead gridsquare containing the given point, or null outside the grid. */
|
||||
private fun gridOfPoint(latitude: Double, longitude: Double): String? {
|
||||
if (latitude < -90.0 || latitude >= 90.0) return null
|
||||
val lon = ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0
|
||||
val fieldLat = ((latitude + 90.0) / 10.0).toInt().coerceIn(0, 17)
|
||||
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
|
||||
val subLat = ((latitude + 90.0) % 10.0).toInt()
|
||||
val subLon = ((lon + 180.0) % 20.0 / 2.0).toInt()
|
||||
return "${'A' + fieldLon}${'A' + fieldLat}$subLon$subLat"
|
||||
}
|
||||
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_STATION]
|
||||
|
||||
@@ -28,6 +28,7 @@ data class MapState(
|
||||
val isUtc: Boolean = false,
|
||||
val isGridMode: Boolean = false,
|
||||
val workedGrids: Set<String> = emptySet(),
|
||||
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
|
||||
@@ -79,7 +79,12 @@ class MapViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
settingsRepo.wavelogSettings.collectLatest { _ ->
|
||||
_uiState.update { it.copy(workedGrids = settingsRepo.getWorkedGrids()) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
workedGrids = settingsRepo.getWorkedGrids(),
|
||||
workedGridQsos = settingsRepo.getWorkedGridQsos()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
|
||||
Reference in new issue
Block a user