Compare commits

..
Author SHA1 Message Date
atsunatsu c4c4dd682d build: bump to 4.4.7-ba7opf.14.2 2026-09-23 15:54:59 +08:00
atsunatsu 62ad995aeb feat(map): multiple marked stations per grid with pin-to-top
- MarkedStation storage upgraded to an ordered list per grid
  (Map<String, List<MarkedStation>>); JSON array shape with legacy
  v14.1 single-object migration on read
- MapAction: SetMarkedStation appends (duplicate callsign ignored),
  RemoveMarkedStation deletes by callsign, PinMarkedStation moves a
  mark to the top of the list
- MaidenheadGridOverlay: red fill for any marked grid unchanged;
  first-call label mode shows the FIRST (pinned) marked callsign
- MapScreen: one row per mark (pale-yellow callsign), pin icon
  (冖 + stroke ↑, drawn in-app) left of the Marked label — shown only
  when the grid has 2+ marks; pinned row's icon grey, others pale
  yellow; delete icon per row
- New ic_pin.xml vector drawable; strings grid_mark_pin
2026-09-23 15:47:17 +08:00
atsunatsu c8fdd2e93e build: bump to 4.4.7-ba7opf.14.1 2026-09-23 15:15:47 +08:00
atsunatsu dc013f920d feat(map,mutual): uppercase callsign input; mutual match honors historical pass window
- MapScreen: mark-a-station callsign input uppercases as you type
- MutualViewModel: read passesSettings.hoursBefore, shift the mutual
  match query start to now-hoursBefore so historical passes are
  included when the main Passes page has a lookback window set;
  endTime stays at now+hoursAhead
2026-09-23 15:08:46 +08:00
atsunatsu f36a426855 build: bump to 4.4.7-ba7opf.14 2026-09-23 13:26:00 +08:00
atsunatsu 4da59bdd17 feat(map): mark a station you want to contact in unworked grids
- New MarkedStation model + ISettingsRepo/SettingsRepo persistence
  (single JSON key markedGridStations, org.json only)
- MapState/MapViewModel: markedGrids state, Set/RemoveMarkedStation
  actions; marks auto-clear once the grid becomes worked
- MaidenheadGridOverlay: red fill (alpha 90, same as green worked
  fill) for marked grids at both zoom levels; first-call label mode
  shows the marked callsign in pale yellow on red cells
- MapScreen: unworked-grid dialog gains a 'mark a station you want
  to contact' button + callsign input dialog; marked row shows red
  callsign, 'Marked' in the count column, marking date, delete icon
- i18n: values/ + values-zh/ (grid_mark_*)
2026-09-23 13:14:38 +08:00
16 changed files with 402 additions and 27 deletions

No files matched your search

@@ -269,6 +269,59 @@ class SettingsRepo(
preferences.edit { putString(keyRoamedGrids, array.toString()) }
}
// Marked stations in unworked gridsquares, persisted as a single JSON
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
// ordered list (first = pinned/top). v14.1 stored a single object per
// grid; reads transparently migrate that old shape to a one-element list.
private val keyMarkedGridStations = "markedGridStations"
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
if (json.isBlank()) return emptyMap()
return try {
val root = org.json.JSONObject(json)
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
for (grid in root.keys()) {
val value = root.opt(grid)
val list = when (value) {
// v14.2+ shape: JSON array of station objects.
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
val o = value.optJSONObject(i) ?: return@mapNotNull null
val call = o.optString("c").ifBlank { return@mapNotNull null }
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
}
// v14.1 legacy shape: a single station object per grid.
is org.json.JSONObject -> {
val call = value.optString("c").ifBlank { null }
if (call == null) emptyList()
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
}
else -> emptyList()
}
if (list.isNotEmpty()) result[grid] = list
}
result
} catch (_: Exception) {
emptyMap()
}
}
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
val root = org.json.JSONObject()
for ((grid, list) in stations) {
val array = org.json.JSONArray()
for (station in list) {
array.put(
org.json.JSONObject()
.put("c", station.call)
.put("t", station.epochMs)
)
}
root.put(grid, array)
}
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
}
// LoTW credentials (stored locally on the device only)
private val keyLoTWCall = "lotwCallsign"
private val keyLoTWPass = "lotwPassword"
@@ -312,6 +312,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setRoamedGrids(grids: Set<String>) {
roamedGrids.value = grids
}
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -189,6 +189,8 @@ private class FakeSettingsRepoForSearch : ISettingsRepo {
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
override fun getRoamedGrids(): Set<String> = emptySet()
override fun setRoamedGrids(grids: Set<String>) = Unit
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
@@ -0,0 +1,31 @@
/*
* 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.core.domain.model
/**
* A station the user marked in a gridsquare they have NOT yet worked — a
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
* per grid).
*
* @param call the callsign the user wants to work (uppercase)
* @param epochMs the moment the mark was created, UTC milliseconds
*/
data class MarkedStation(
val call: String,
val epochMs: Long
)
@@ -105,6 +105,11 @@ interface ISettingsRepo {
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
fun getRoamedGrids(): Set<String>
fun setRoamedGrids(grids: Set<String>)
/** Stations the user marked as "want to contact" in unworked gridsquares
* (grid -> ordered list of marks; the first is the pinned/top one). The
* whole grid clears automatically once it becomes worked. */
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
//endregion
//region # LoTW confirmed-grids settings
@@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.GridQso
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
import com.rtbishop.look4sat.core.domain.model.MarkedStation
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -261,6 +262,9 @@ class LoTWGridSyncTest {
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
override fun getRoamedGrids(): Set<String> = roamedGrids
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
override fun getLastLotwSyncDate(): String = lastSyncDate
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 冖 (秃宝盖): 顶横 + 两侧短竖, 笔画型 -->
<path
android:pathData="M5,4 L19,4 M5,4 L5,8.5 M19,4 L19,8.5"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
<!-- 笔画型向上箭头 ↑: 竖杆 + 两斜线箭头头 -->
<path
android:pathData="M12,9 L12,20 M12,9 L7.5,14 M12,9 L16.5,14"
android:strokeColor="@android:color/white"
android:strokeWidth="2"
android:strokeLineCap="round"
android:fillColor="#00000000" />
</vector>
@@ -131,6 +131,13 @@
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="grid_qso_match">匹配</string>
<!-- 未通联网格中的“标记想通联的电台”提醒 -->
<string name="grid_mark_station">标记你想通联的电台</string>
<string name="grid_mark_callsign">呼号</string>
<string name="grid_mark_confirm">标记</string>
<string name="grid_mark_label">标记</string>
<string name="grid_mark_delete">删除标记</string>
<string name="grid_mark_pin">置顶</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
@@ -151,6 +151,13 @@
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="grid_qso_match">Match</string>
<!-- Mark-a-station reminder in unworked grids -->
<string name="grid_mark_station">Mark a station you want to contact</string>
<string name="grid_mark_callsign">Callsign</string>
<string name="grid_mark_confirm">Mark</string>
<string name="grid_mark_label">Marked</string>
<string name="grid_mark_delete">Remove mark</string>
<string name="grid_mark_pin">Pin to top</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
@@ -64,6 +64,11 @@ class MaidenheadGridOverlay : Overlay() {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
// 标记(想通联)网格填充: 红色, 透明度与绿色 workedPaint 一致(alpha 90).
private val markedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 255, 59, 48)
}
private val roamStripePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 14f
@@ -87,6 +92,12 @@ class MaidenheadGridOverlay : Overlay() {
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** Marked gridsquares (4-char, uppercase) — unworked grids the user has
* marked stations they want to contact in; drawn red, same alpha as the
* green worked fill. Value is the ordered mark list (first = pinned);
* label mode shows the FIRST marked callsign. */
var markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
/** Grid-mode first-call labels: label worked (green) cells with the first
* callsign worked in that grid instead of the Maidenhead code; non-worked
* cells get no label at all. Only meaningful at sub-square zoom. */
@@ -189,6 +200,9 @@ class MaidenheadGridOverlay : Overlay() {
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
if (cellLabel(lat, lon, zoom) in markedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
} else {
@@ -215,6 +229,22 @@ class MaidenheadGridOverlay : Overlay() {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
}
}
for (grid in markedGrids.keys) {
val cell = gridCellBounds(grid) ?: continue
for (turn in -colRepeats..colRepeats) {
val dLon = turn * 360.0
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
val yTop = projectionToY(projection, cell.latTop) ?: continue
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, markedPaint)
}
}
}
}
@@ -414,12 +444,15 @@ class MaidenheadGridOverlay : Overlay() {
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
if (showFirstCallLabels) {
// 首通呼号模式: 只有绿格(worked)标注该格第一个通联的呼号,
// 非绿格空着不写网格字符.
if (label in workedGrids) {
firstCallsByGrid[label]?.let { call ->
// 首通呼号模式: 绿格(worked)标注该格第一个通联的呼号, 红格(marked)
// 标注用户标记想通联的呼号, 均淡黄文字; 其它格空着不写网格字符.
when {
label in workedGrids -> firstCallsByGrid[label]?.let { call ->
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
label in markedGrids -> markedGrids[label]?.firstOrNull()?.let { station ->
canvas.drawText(station.call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
}
} else {
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
@@ -362,7 +362,8 @@ private fun MapScreen(
centerOnStation = shouldCenter,
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
firstCallsByGrid = firstCallsByGrid
firstCallsByGrid = firstCallsByGrid,
markedGrids = uiState.markedGrids
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
@@ -422,15 +423,20 @@ private fun MapScreen(
}
}
// Centered dialog listing the tapped worked grid's confirmed satellite QSOs.
// Dismissed by tapping outside (no explicit close button).
// Dismissed by tapping outside (no explicit close button). Unworked grids
// open the same dialog with an empty list plus a mark-a-station reminder.
selectedGrid?.let { grid ->
WorkedGridQsoDialog(
grid = grid,
qsos = uiState.workedGridQsos[grid].orEmpty().sortedBy { it.epochMs },
marked = uiState.markedGrids[grid].orEmpty(),
isUtc = uiState.isUtc,
matchCalculating = matchCalculating,
onDismiss = { selectedGrid = null },
onMatch = { onMatchGrid(grid) }
onMatch = { onMatchGrid(grid) },
onMark = { call -> onAction(MapAction.SetMarkedStation(grid, call)) },
onRemoveMark = { call -> onAction(MapAction.RemoveMarkedStation(grid, call)) },
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
)
}
}
@@ -441,11 +447,17 @@ private fun MapScreen(
private fun WorkedGridQsoDialog(
grid: String,
qsos: List<com.rtbishop.look4sat.core.domain.model.GridQso>,
marked: List<com.rtbishop.look4sat.core.domain.model.MarkedStation>,
isUtc: Boolean,
matchCalculating: Boolean,
onDismiss: () -> Unit,
onMatch: () -> Unit
onMatch: () -> Unit,
onMark: (String) -> Unit,
onRemoveMark: (String) -> Unit,
onPinMark: (String) -> Unit
) {
var showMarkInput by remember { mutableStateOf(false) }
var callInput by remember { mutableStateOf("") }
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
// Centered card, ~85% width, internal scroll for long lists.
Card(
@@ -497,26 +509,158 @@ private fun WorkedGridQsoDialog(
}
}
androidx.compose.material3.HorizontalDivider()
if (qsos.isNotEmpty()) {
// 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())
) {
// Marked stations show first as red reminder rows: pale-yellow
// callsign, "标记" in the count column, the marking date in the
// date column, a small pin icon (first = pinned, grey; others
// pale yellow) and a delete icon to remove the mark.
val showPin = marked.size > 1
marked.forEachIndexed { index, station ->
MarkedStationRow(
marked = station,
isPinned = index == 0,
showPin = showPin,
isUtc = isUtc,
onRemoveMark = { onRemoveMark(station.call) },
onPinMark = { onPinMark(station.call) }
)
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
}
Column(
Modifier
.heightIn(max = 380.dp)
.verticalScroll(rememberScrollState())
) {
if (qsos.isNotEmpty()) {
// 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 }
}
grouped.forEach { (call, callQsos) ->
WorkedGridCallRow(call, callQsos = callQsos, isUtc = isUtc)
androidx.compose.material3.HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
}
}
// Unworked grids: offer to mark a station the user wants to
// contact in this grid (a reminder, cleared when worked).
if (qsos.isEmpty()) {
TextButton(
onClick = {
callInput = ""
showMarkInput = true
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.grid_mark_station))
}
}
}
}
}
}
// Mark-a-station input dialog: enter a callsign as a reminder.
if (showMarkInput) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showMarkInput = false },
title = { Text(stringResource(R.string.grid_mark_station)) },
text = {
androidx.compose.material3.OutlinedTextField(
value = callInput,
onValueChange = { callInput = it.uppercase() },
label = { Text(stringResource(R.string.grid_mark_callsign)) },
singleLine = true
)
},
confirmButton = {
TextButton(
enabled = callInput.isNotBlank(),
onClick = {
onMark(callInput)
showMarkInput = false
}
) { Text(stringResource(R.string.grid_mark_confirm)) }
},
dismissButton = {
TextButton(onClick = { showMarkInput = false }) { Text(stringResource(R.string.btn_cancel)) }
}
)
}
}
@Composable
private fun MarkedStationRow(
marked: com.rtbishop.look4sat.core.domain.model.MarkedStation,
isPinned: Boolean,
showPin: Boolean,
isUtc: Boolean,
onRemoveMark: () -> Unit,
onPinMark: () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = marked.call,
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = ComposeColor(0xFFFFE082),
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// "标记" column: pin icon (only when the grid has multiple marks) sits
// left of the label; pinned (first) row's icon is grey, others pale
// yellow — tapping moves that mark to the top.
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
if (showPin) {
Icon(
painter = painterResource(R.drawable.ic_pin),
contentDescription = stringResource(R.string.grid_mark_pin),
tint = if (isPinned) ComposeColor(0xFF9E9E9E) else ComposeColor(0xFFFFE082),
modifier = Modifier
.size(18.dp)
.clickable(onClick = onPinMark)
)
Spacer(Modifier.width(2.dp))
}
Text(
text = stringResource(R.string.grid_mark_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.weight(1f)
) {
Text(
text = formatDate(marked.epochMs, isUtc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.grid_mark_delete),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.size(18.dp)
.clickable(onClick = onRemoveMark)
)
}
}
}
@Composable
@@ -944,7 +1088,8 @@ private fun setGridMode(
stationPosition: GeoPos?,
centerOnStation: Boolean = false,
showFirstCallLabels: Boolean = false,
firstCallsByGrid: Map<String, String> = emptyMap()
firstCallsByGrid: Map<String, String> = emptyMap(),
markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
@@ -955,6 +1100,7 @@ private fun setGridMode(
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
gridOverlay.showFirstCallLabels = showFirstCallLabels
gridOverlay.firstCallsByGrid = firstCallsByGrid
gridOverlay.markedGrids = markedGrids
// ownGrid must be set on EVERY update — the position is available
// regardless of whether this frame centers (centering happens only
// on entry, but passing null here would wipe the bold outline).
@@ -966,6 +1112,7 @@ private fun setGridMode(
this.roamedGrids = roamedGrids
this.showFirstCallLabels = showFirstCallLabels
this.firstCallsByGrid = firstCallsByGrid
this.markedGrids = markedGrids
this.ownGrid = ownGridOf(stationPosition)
}
}
@@ -34,6 +34,9 @@ data class MapState(
val workedGridQsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap(),
/** 4-char gridsquares the station operated from (LoTW MY_GRIDSQUARE) — blue stripes. */
val roamedGrids: Set<String> = emptySet(),
/** Stations the user marked in unworked gridsquares (grid -> ordered list;
* first = pinned/top) — red fill. */
val markedGrids: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap(),
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null,
@@ -52,6 +55,9 @@ sealed interface MapAction {
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetMarkedStation(val grid: String, val call: String) : MapAction
data class RemoveMarkedStation(val grid: String, val call: String) : MapAction
data class PinMarkedStation(val grid: String, val call: String) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
@@ -90,11 +90,19 @@ class MapViewModel(
}
viewModelScope.launch {
settingsRepo.wavelogSettings.collectLatest { _ ->
val workedGrids = settingsRepo.getWorkedGrids()
val markedGrids = settingsRepo.getMarkedGridStations()
// Marked stations auto-clear once their grid becomes worked:
// the reminder has served its purpose (user req: 网格变绿后标记自动清除).
val stale = markedGrids.keys.filter { it in workedGrids }
val activeMarks = if (stale.isEmpty()) markedGrids else markedGrids - stale.toSet()
if (stale.isNotEmpty()) settingsRepo.setMarkedGridStations(activeMarks)
_uiState.update {
it.copy(
workedGrids = settingsRepo.getWorkedGrids(),
workedGrids = workedGrids,
workedGridQsos = settingsRepo.getWorkedGridQsos(),
roamedGrids = settingsRepo.getRoamedGrids()
roamedGrids = settingsRepo.getRoamedGrids(),
markedGrids = activeMarks
)
}
}
@@ -112,6 +120,38 @@ class MapViewModel(
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is MapAction.ToggleFirstCallLabels -> settingsRepo.updateOtherSettings { it.copy(stateOfMapFirstCall = action.value) }
is MapAction.SetMarkedStation -> {
val call = action.call.trim().uppercase()
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
// 重复呼号忽略: 同一网格已标记过的呼号不再重复添加.
val updated = if (current.any { it.call == call }) existing
else existing + (action.grid to current + com.rtbishop.look4sat.core.domain.model.MarkedStation(
call = call,
epochMs = System.currentTimeMillis()
))
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.RemoveMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val remaining = current.filterNot { it.call == action.call }
val updated = if (remaining.isEmpty()) existing - action.grid
else existing + (action.grid to remaining)
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
is MapAction.PinMarkedStation -> {
val existing = settingsRepo.getMarkedGridStations()
val current = existing[action.grid].orEmpty()
val target = current.firstOrNull { it.call == action.call }
if (target != null) {
val updated = existing + (action.grid to listOf(target) + current.filterNot { it.call == action.call })
settingsRepo.setMarkedGridStations(updated)
_uiState.update { it.copy(markedGrids = updated) }
}
}
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -52,6 +52,9 @@ data class MutualUiState(
val stationBGrid: String = "",
val stationBMinElev: Double = 10.0,
val hoursAhead: Int = 24,
/** 历史过境回看窗口(小时), 跟随主页面 Passes 设置; 匹配查询从
* now-hoursBefore 开始, 使设置过历史回看时匹配页也包含历史过境. */
val hoursBefore: Int = 0,
val mutualPasses: List<MutualPass> = emptyList(),
val isCalculating: Boolean = false,
val hasSearched: Boolean = false,
@@ -108,6 +111,12 @@ class MutualViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
}
}
// 历史过境回看窗口跟随主页面 Passes 设置 (设置里改小时数, 匹配页同步生效)
viewModelScope.launch {
settingsRepo.passesSettings.collectLatest { settings ->
_uiState.update { it.copy(hoursBefore = settings.hoursBefore) }
}
}
}
fun onStationALat(value: String) {
@@ -276,14 +285,17 @@ class MutualViewModel(
queryGeneration += 1
viewModelScope.launch {
val time = System.currentTimeMillis()
val now = System.currentTimeMillis()
// 历史回看: 查询窗口起点 = now - hoursBefore (跟随主页面 Passes 设置),
// 终点 = now + hoursAhead 不变.
val time = now - state.hoursBefore * 60L * 60L * 1000L
val minElevA = state.stationAMinElev
val minElevB = state.stationBMinElev
val hours = state.hoursAhead
val results = try {
withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
}
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
@@ -328,9 +340,11 @@ class MutualViewModel(
satellites: List<OrbitalObject>,
posA: GeoPos, posB: GeoPos,
minElevADeg: Double, minElevBDeg: Double,
time: Long, hours: Int
time: Long, hours: Int, hoursBefore: Int
): List<MutualPass> {
val endTime = time + hours * 60L * 60L * 1000L
// 终点始终是 now + hoursAhead; 起点已由调用方下移 hoursBefore 小时,
// 因此这里的 endTime 要在起点之上补回 hoursBefore, 保证窗口 = [now-hb, now+h].
val endTime = time + (hoursBefore + hours) * 60L * 60L * 1000L
val sampleInterval = 5_000L
// Use the main page's pass list for AOS/LOS times, then sample the curves
@@ -115,6 +115,8 @@ class FakeSettingsRepo(initialPosition: GeoPos = GeoPos(23.13, 113.26)) : ISetti
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) = TODO()
override fun getRoamedGrids(): Set<String> = TODO()
override fun setRoamedGrids(grids: Set<String>) = TODO()
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
override fun getLastLotwSyncDate(): String = ""
override fun setLastLotwSyncDate(date: String) = Unit
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "493"
appVersionCode = "496"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.13"
appVersionName = "4.4.7-ba7opf.14.2"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry