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
atsunatsu b8138c948b build: bump to 4.4.7-ba7opf.13 2026-09-22 16:41:59 +08:00
atsunatsu 0e28231c4a feat(map): run the Match query before navigating to the mutual page
Tapping the grid-QSO dialog "Match" button now starts the pass query on
the map page and only navigates to the match page once it finishes, so the
page's first frame already contains the results — the time-range card at
the top and the pass curves right below, with no scroll flicker. The dialog
button shows a spinner and is disabled while calculating. A uiState
collector in MainScreen performs the actual navigation (still a push, so
the back gesture returns to the map). Failures/exceptions still navigate so
the user is never stuck on the map page.
2026-09-22 16:41:55 +08:00
atsunatsu d6bdf5ecb6 fix(mutual): keep scroll position when re-querying from the match page
queryMutualPasses() reset listScrollIndex to 0 on every query, so tapping
"query" on the match page jumped the list back to the top. Now the default
keeps the current position; the stale results stay on screen while the new
query runs (clearing them shrank the list below a screen and clamped the
position away) and are replaced when the new results arrive. New VM test
covers position + stale-results behaviour through a settled re-query.
2026-09-22 16:41:51 +08:00
atsunatsu 6962a4bfa1 fix(mutual): retry prefill scroll until it really lands
On a device the first frame of the match page only contains the station
cards and the time-range card, which can be shorter than the viewport —
scrollToItem(1) then has no scroll range and silently does nothing, and
by the time the async query results grow the list past one screen the
one-shot flag is already consumed. Retry every 100ms (up to 2s) until
firstVisibleItemIndex reaches the time-range card. Verified with a new
Robolectric test that grows the content after a delay. Bump 12.6.
2026-09-22 15:50:31 +08:00
atsunatsu 94a1a4f7b7 diagnostics(mutual): log prefill scroll path under tag Look4SatMutual
Temporary Log.d probes at prefill, list-layout gate, scroll effect entry,
scrollToItem outcome and consume, to locate why the map Match prefill
scroll does not land on the device (Robolectric covers it). Bump 12.5.
2026-09-22 15:13:47 +08:00
atsunatsu dbf2229774 chore: bump version to 4.4.7-ba7opf.12.4 (versionCode 490) 2026-09-22 14:49:32 +08:00
atsunatsu f2fc1b7b28 test(mutual): portrait-size Robolectric + NavDisplay entry scenario
Run the scroll tests at 411x891dp portrait (device-like layout) and add
a NavDisplay entryProvider scenario that composes MutualScreen the way
the app does, so the prefill scroll is exercised end-to-end.
2026-09-22 14:49:04 +08:00
atsunatsu d600836e19 fix(mutual): gate prefill scroll on real list layout (onGloballyPositioned)
On a device the Mutual entry is composed inside the NavDisplay transition;
LaunchedEffect could run before the LazyColumn is measurable and the
scrollToItem then never lands, leaving the page at the top. Only scroll
once onGloballyPositioned confirms the list has actually been laid out.
2026-09-22 14:49:00 +08:00
atsunatsu 94bffe80b0 chore: bump version to 4.4.7-ba7opf.12.3 (versionCode 489) 2026-09-22 13:13:11 +08:00
atsunatsu 4260fbb096 fix(nav): map Match prefill pushes Mutual onto the stack
The grid-QSO dialog Match button now adds Mutual on top of the Map entry
instead of clearing the stack, so the system back gesture pops back to
the map page (the page the user came from). Bottom-nav tab switches keep
their replace semantics.
2026-09-22 13:13:08 +08:00
atsunatsu 3d5b55faa3 test(mutual): Robolectric Compose UI tests for prefill scroll; bump 4.4.7-ba7opf.12.2
MutualMatchPrefillScrollTest verifies the map grid-QSO Match prefill:
time-range card lands at the top after scrollToItem, default open stays
at station inputs, and the query auto-starts with grid+24h pre-filled.
Adds Robolectric + compose ui-test-junit4 to the mutual test classpath.
2026-09-22 12:23:55 +08:00
atsunatsu 466f2f6088 fix(mutual): map Match prefill scrolls to time-range card via scrollToItem
Compose ui 1.11.x (BOM 2026.06.01) ignores the LazyListState
firstVisibleItemIndex constructor parameter - the list always starts at
item 0. Restore the scrollToItem approach (verified effective by
Robolectric Compose UI tests) and keep the auto-started query.
2026-09-22 12:23:50 +08:00
atsunatsu 14bd984327 chore: bump version to 4.4.7-ba7opf.12.1 (versionCode 487) 2026-09-22 11:05:50 +08:00
atsunatsu d5c100903b fix(mutual,passes): map Match prefill opens at time-range card; whats-new dialog scrollable
- Match prefill now positions the LazyColumn via its initial state
  (queryMutualPasses(initialScrollIndex = 1)) instead of a post-layout
  scrollToItem, so the page reliably opens with the time-range card at the
  top while the 24h query runs.
- The install-time what's new dialog text now scrolls inside a bounded box
  (max 360dp) instead of overflowing the fixed dialog frame.
2026-09-22 11:05:47 +08:00
atsunatsu 1c6388f3bc chore: bump version to 4.4.7-ba7opf.12 (versionCode 486) 2026-09-22 10:47:00 +08:00
atsunatsu 3e0874e6a8 feat(map): grid QSO dialog Match button prefills the match page
- Grid-QSO dialog gains a Match action (radio-tower icon + label) in its
  header; tapping it navigates to the match page pre-filled with the tapped
  grid as the opposite station and a 24h time range, auto-starts the query,
  and scrolls the page straight to the time-range card.
- Every grid is now tappable (previously only worked grids opened the
  dialog); unworked grids show an empty QSO list instead of a hint.
2026-09-22 10:46:56 +08:00
atsunatsu 6beaeb3e1a chore: bump version to 4.4.7-ba7opf.11.4 (versionCode 485) 2026-09-21 16:51:50 +08:00
atsunatsu 91f0cb1c56 fix(map): grid labels match first-call font size and display zoom 2026-09-21 16:51:13 +08:00
atsunatsu 3b72f8d68a fix(res): rename pass-history slider label 先前时间 to 历史过境时间 (values-zh) 2026-09-21 16:51:09 +08:00
atsunatsu 3bc2983d26 fix(passes): expire finished passes beyond history window, refresh keeps lookback 2026-09-21 16:51:06 +08:00
23 changed files with 1029 additions and 98 deletions

No files matched your search

@@ -177,6 +177,28 @@ fun MainScreen(
factory = MapFilterViewModel.factory()
)
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
// grid and run the query BEFORE navigating, so the page's first frame
// already contains the results (time-range card at the top, pass curves
// right below — no scroll flicker). Navigation happens once the query
// finishes (see the collector below).
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
LaunchedEffect(mutualViewModel) {
mutualViewModel.uiState.collect { state ->
if (state.pendingNavigation && !state.isCalculating) {
mutualViewModel.consumePendingNavigation()
// Push Mutual on top of the Map entry instead of replacing the
// stack (bottom-nav style): the system back gesture then pops
// back to the map page, which is the page the user came from.
if (backStack.lastOrNull() !is Screen.Mutual) {
backStack.add(Screen.Mutual)
}
}
}
}
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
low = otherSettings.lowElevation,
@@ -244,7 +266,18 @@ fun MainScreen(
)
}
entry<Screen.Map> {
MapDestination(mapFilterViewModel = mapFilterViewModel)
MapDestination(
mapFilterViewModel = mapFilterViewModel,
matchCalculating = matchCalculating,
onMatchGrid = { grid ->
// Grid-QSO dialog "Match" button: pre-fill
// the match page for that grid and start the
// query. Navigation to the match page is
// handled by the uiState collector above,
// once the query finishes.
mutualViewModel.prefillMatchFromGrid(grid)
}
)
}
entry<Screen.Mutual> {
MutualScreen(
@@ -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>
@@ -28,7 +28,7 @@
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">提前时间</string>
<string name="pass_filter_history_hours">先前时间</string>
<string name="pass_filter_history_hours">历史过境时间</string>
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
@@ -41,7 +41,7 @@
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- AMSAT Status screen -->
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(先前时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="pass_whatsnew_message">4.4.6 以来的全部更新(4.4.7 系列):\n\n• 网格模式地图:LoTW 已确认网格绿色填充、漫游网格蓝色条纹、当前网格加粗框;右上角网格/卫星模式开关。\n\n• 首通呼号(VUCC 视图):绿格显示该格最早通联的呼号,非绿格空白。\n\n• worked 网格可按操作网格筛选(含“全部”);点击网格弹出 QSO 明细;每格 QSO 计数。\n\n• 各操作网格独立 VUCC 计数;极区网格标签;反经线修复;漫游条纹密度一致。\n\n• WAPC/WAJA 地图标签随系统语言。\n\n• 网格模式视口与筛选跨页保持。\n\n• LoTW 同步:账号直连、全量/增量模式、实时进度、可取消、启动自动同步(独立开关)、设置页显示上次同步时间。\n\n• 历史过境:过境筛选可回看过去数小时(历史过境时间)。\n\n• 指南针校准:8 字动作精度指示 + 手动航向偏置。\n\n• AMSAT 状态页:连续相同状态报告计数。\n\n• 过境/雷达:UTC 切换重算过境;日出日落与仰角颜色修复;卫星模糊搜索;相互过境页滚动保持、跟随 UTC、雷达快捷入口。\n\n• CW 解码器支持 32/64 位设备;Android 17 (API 37) 适配。</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
@@ -130,6 +130,14 @@
<string name="grid_qso_count_many">%d QSOs</string>
<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 -->
@@ -150,6 +150,14 @@
<string name="grid_qso_count_many">%d QSOs</string>
<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 -->
@@ -46,15 +46,16 @@ class MaidenheadGridOverlay : Overlay() {
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
// 网格标签与首通呼号标签同字号 (用户要求 2026-09-21: 字体一样大)
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 26f
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
// 首通呼号标签: 比 4 字符网格代码略小, 以容纳更长的呼号(如 BG2GQG).
// 首通呼号标签: 与网格标签同字号.
private val firstCallPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 20f
textSize = LABEL_TEXT_SIZE
style = android.graphics.Paint.Style.FILL
color = Color.argb(230, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
@@ -63,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
@@ -86,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. */
@@ -188,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 {
@@ -214,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)
}
}
}
}
@@ -362,21 +393,15 @@ class MaidenheadGridOverlay : Overlay() {
// Labels: centered in each cell, only when the cell is large enough on
// screen to hold a label (avoid clutter at low zoom).
// Field (2-char) labels show at every zoom, subject only to the pixel-
// size check below; sub-square (4-char) labels only from LABEL_ZOOM_SUB
// (one level above the grid lines, so zoom 6 shows lines but no names).
// size check below; sub-square (4-char) labels appear together with the
// grid LINES at GRID_ZOOM_SUB (user req 2026-09-21: 与首通呼号同一显示缩放).
// First-call labels replace the 4-char grid codes and only exist at
// sub-square zoom; at field zoom NO labels are drawn at all (the user
// requirement is that non-worked cells carry no grid characters).
// Show as soon as the 4-char grid LINES appear (GRID_ZOOM_SUB), not
// only at the name-label zoom — user req 2026-09-21.
if (showFirstCallLabels && zoom < GRID_ZOOM_SUB) return
// First-call labels gate on the grid-LINE zoom; normal labels keep the
// name-label zoom (one level above the lines).
val showLabels = if (showFirstCallLabels) {
zoom >= GRID_ZOOM_SUB
} else {
zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
}
// 网格标签与首通呼号同一显示缩放 (用户要求 2026-09-21): 子方块(4字符)
// 标签与首通呼号都在网格线出现的 GRID_ZOOM_SUB 同时显示, 不再晚一档.
val showLabels = zoom >= GRID_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
// project two points 1° apart in latitude and measure the pixel distance.
@@ -384,10 +409,9 @@ class MaidenheadGridOverlay : Overlay() {
val y2 = projectionToY(projection, 1.0)
if (y1 == null || y2 == null) return
val pixelsPerDegree = Math.abs(y2 - y1)
// First-call labels must appear as soon as the 4-char grid LINES do
// (zoom 6.0), where 1° cells are ~45 px on the equator — below the
// 48 px normal-label threshold. Relax the pixel gate for this mode only.
val minCellPx = if (showFirstCallLabels) MIN_LABEL_CELL_PX * 0.6f else MIN_LABEL_CELL_PX
// 两种模式同一像素门限: GRID_ZOOM_SUB 处 1° 格约 45px, 低于 48px
// 常规阈值, 统一用放宽的 0.6x 门限, 保证网格标签与首通呼号同时出现.
val minCellPx = MIN_LABEL_CELL_PX * 0.6f
if (pixelsPerDegree * cellLat < minCellPx) return
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
@@ -420,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)
@@ -547,9 +574,8 @@ class MaidenheadGridOverlay : Overlay() {
// the sub-square grid is too dense to read; 6 roughly doubles the
// on-screen size of each square.
const val GRID_ZOOM_SUB = 6.0
// Zoom at which the 4-char sub-square names appear (one level above the
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
const val LABEL_ZOOM_SUB = 6.5
// 网格标签与首通呼号标签统一字号 (用户要求 2026-09-21: 字体一样大).
const val LABEL_TEXT_SIZE = 20f
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
/** Center-to-center spacing of the roamed-grid zebra stripes, in px. */
@@ -31,10 +31,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -46,10 +48,13 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -63,6 +68,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -145,7 +151,13 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
@Composable
fun MapDestination(
mapFilterViewModel: MapFilterViewModel
mapFilterViewModel: MapFilterViewModel,
// Invoked with the tapped grid when the QSO dialog's "Match" button is
// pressed: navigate to the match page pre-filled for that grid.
onMatchGrid: (String) -> Unit = {},
// True while the map "Match" query is running (the page only navigates
// once the query finished, so the match page opens with results ready).
matchCalculating: Boolean = false
) {
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -182,7 +194,7 @@ fun MapDestination(
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel)
MapScreen(uiState, viewModel::onAction, mapView, mapFilterViewModel, onMatchGrid, matchCalculating)
}
@Composable
@@ -190,7 +202,9 @@ private fun MapScreen(
uiState: MapState,
onAction: (MapAction) -> Unit,
mapView: MapView,
mapFilterViewModel: MapFilterViewModel
mapFilterViewModel: MapFilterViewModel,
onMatchGrid: (String) -> Unit,
matchCalculating: Boolean
) {
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
@@ -258,10 +272,11 @@ private fun MapScreen(
if (!isGridMode || p == null) return false
val zoom = mapView.zoomLevelDouble
// Allow tapping worked cells as soon as the 4-char grid LINES
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
// appear (GRID_ZOOM_SUB); grid labels now appear at that zoom too.
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
if (grid !in workedGrids) return false
// Every grid is tappable: worked grids show their confirmed QSOs,
// unworked grids open the same dialog with an empty list.
selectedGrid = grid
return true
}
@@ -347,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
@@ -407,13 +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,
onDismiss = { selectedGrid = null }
matchCalculating = matchCalculating,
onDismiss = { selectedGrid = null },
onMatch = { onMatchGrid(grid) },
onMark = { call -> onAction(MapAction.SetMarkedStation(grid, call)) },
onRemoveMark = { call -> onAction(MapAction.RemoveMarkedStation(grid, call)) },
onPinMark = { call -> onAction(MapAction.PinMarkedStation(grid, call)) }
)
}
}
@@ -424,9 +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,
onDismiss: () -> Unit
matchCalculating: Boolean,
onDismiss: () -> 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(
@@ -434,48 +465,202 @@ private fun WorkedGridQsoDialog(
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
)
// Header: grid + counts on the left, "Match" action on the right
// (jumps to the match page pre-filled with this grid).
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)
) {
Column(Modifier.weight(1f)) {
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
)
}
TextButton(
enabled = !matchCalculating,
onClick = {
onDismiss()
onMatch()
}
) {
if (matchCalculating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp
)
} else {
Icon(
painter = painterResource(R.drawable.ic_radio_tower),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.grid_qso_match))
}
}
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())
) {
// 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
@@ -903,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]
@@ -914,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).
@@ -925,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
}
}
+17 -1
View File
@@ -4,4 +4,20 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
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)
}
@@ -17,6 +17,7 @@
*/
package com.rtbishop.look4sat.feature.mutual
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -54,11 +55,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -77,6 +80,7 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
@@ -157,7 +161,7 @@ fun MutualScreen(
viewModel = viewModel,
state = state,
isVertical = isVertical,
onQuery = viewModel::queryMutualPasses,
onQuery = { viewModel.queryMutualPasses() },
onSelectPass = viewModel::onSelectPass,
onNavigateToRadar = navigateToRadar,
onStationALat = viewModel::onStationALat,
@@ -220,8 +224,24 @@ private fun MutualContent(
}
}
// Set once the list has actually been laid out. The prefill scroll only
// fires after this, because on a real device the Mutual entry appears
// through the NavDisplay transition and LaunchedEffect alone can run
// before the list is measurable (the scroll then never lands and the
// page stays at the top). onGloballyPositioned fires on the first real
// layout, so the subsequent scrollToItem always has a measured list.
var listReady by remember { mutableStateOf(false) }
Log.d(TAG, "composed: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=${if (state.errorMessage != null) 2 else 1} listReady=$listReady")
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned {
if (!listReady) {
Log.d(TAG, "lazy list laid out, opening prefill scroll gate")
listReady = true
}
},
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
@@ -246,7 +266,7 @@ private fun MutualContent(
}
// Input form
item {
item(key = "stationInputs") {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
@@ -323,16 +343,21 @@ private fun MutualContent(
)
}
}
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
}
// Time range + query card. Its own LazyColumn item so a prefill from
// the map (grid-QSO dialog "Match" button) can scroll the page exactly
// to the time-range selector.
item(key = "matchSearch") {
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
}
// Results
itemsIndexed(state.mutualPasses) { index, pass ->
val mutualData = MutualPassData(
@@ -364,8 +389,44 @@ private fun MutualContent(
)
}
}
// Prefill from the map's grid-QSO dialog "Match" button: once the page is
// laid out, jump straight to the time-range card. Index accounts for the
// optional error card at the top (error = 0, station inputs = 1, time
// range = 2; else 1). scrollToItem is used instead of initializing the
// LazyListState at index 1: the constructor parameter is ignored by this
// Compose version (verified in tests), while scrollToItem lands correctly.
val matchSearchIndex = if (state.errorMessage != null) 2 else 1
LaunchedEffect(state.scrollToTimeRange, matchSearchIndex, listReady) {
Log.d(TAG, "scroll effect: scrollToTimeRange=${state.scrollToTimeRange} matchIndex=$matchSearchIndex listReady=$listReady")
if (state.scrollToTimeRange && listReady) {
// Keep trying until the scroll really lands: on a device the first
// frame only contains the station cards + time-range card, which
// can be shorter than the viewport (no scroll range), so a single
// scrollToItem does nothing. When the async query results arrive
// the list grows past one screen and the scroll becomes possible.
for (attempt in 0 until 20) {
Log.d(TAG, "attempting scrollToItem($matchSearchIndex) #$attempt")
try {
listState.scrollToItem(matchSearchIndex)
} catch (t: Throwable) {
Log.e(TAG, "scrollToItem($matchSearchIndex) threw", t)
break
}
if (listState.firstVisibleItemIndex == matchSearchIndex) {
Log.d(TAG, "scroll landed at $matchSearchIndex on attempt #$attempt")
break
}
delay(100)
}
viewModel.consumeScrollToTimeRange()
Log.d(TAG, "scrollToTimeRange consumed")
}
}
}
private const val TAG = "Look4SatMutual"
@Composable
private fun MutualStatusChip(state: MutualUiState) {
val colorScheme = MaterialTheme.colorScheme
@@ -40,6 +40,8 @@ import kotlinx.coroutines.withContext
import kotlin.math.PI
import kotlin.math.roundToInt
private const val TAG = "Look4SatMutual"
data class MutualUiState(
val stationALat: String = "",
val stationALon: String = "",
@@ -50,12 +52,24 @@ 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,
val selectedPassIndex: Int = -1,
val isUtc: Boolean = false,
val errorMessage: String? = null
val errorMessage: String? = null,
// One-shot flag set by prefillMatchFromGrid() (map grid → Match button):
// the page scrolls to the time-range card after the first layout, then
// consumeScrollToTimeRange() clears it.
val scrollToTimeRange: Boolean = false,
// One-shot flag set by prefillMatchFromGrid(): the map page waits for the
// pre-filled query to finish, then navigates to the match page so the very
// first frame already contains the results (time-range card + pass curves,
// no scroll flicker). consumePendingNavigation() clears it.
val pendingNavigation: Boolean = false
)
class MutualViewModel(
@@ -97,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) {
@@ -183,7 +203,50 @@ class MutualViewModel(
fun onHoursAhead(value: Int) = _uiState.update { it.copy(hoursAhead = value) }
fun onSelectPass(index: Int) = _uiState.update { it.copy(selectedPassIndex = index) }
fun queryMutualPasses() {
/**
* Pre-fill the match page for a target grid picked from the map's grid-QSO
* dialog ("Match" button): set the opposite-station grid (with its
* coordinates), reset the time range to 24h, start the query immediately
* so results are ready when the page opens, and flag the page to scroll
* straight to the time-range card once it is laid out.
*/
fun prefillMatchFromGrid(grid: String) {
val g = grid.trim().uppercase()
val pos = qthToPosition(g)
android.util.Log.d(TAG, "prefillMatchFromGrid('$g')")
_uiState.update {
it.copy(
stationBGrid = g,
stationBLat = pos?.let { p -> "%.4f".format(p.latitude) } ?: it.stationBLat,
stationBLon = pos?.let { p -> "%.4f".format(p.longitude) } ?: it.stationBLon,
hoursAhead = 24,
scrollToTimeRange = true,
pendingNavigation = true
)
}
queryMutualPasses()
}
fun consumePendingNavigation() {
android.util.Log.d(TAG, "consumePendingNavigation")
_uiState.update { it.copy(pendingNavigation = false) }
}
fun consumeScrollToTimeRange() {
android.util.Log.d(TAG, "consumeScrollToTimeRange")
_uiState.update { it.copy(scrollToTimeRange = false) }
}
/**
* Run a mutual-pass query. `initialScrollIndex` non-null forces the list
* to rebuild at that position (legacy callers); null keeps the current
* scroll position, so re-running a query from the match page does not
* jump back to the top. The previous results stay on screen while the
* query runs (the list keeps its height, so the scroll position is not
* clamped away); they are replaced — possibly with empty — when the new
* results arrive.
*/
fun queryMutualPasses(initialScrollIndex: Int? = null) {
val state = _uiState.value
// Resolve positions from lat/lon or grid
@@ -206,24 +269,39 @@ class MutualViewModel(
isCalculating = true,
hasSearched = true,
errorMessage = null,
mutualPasses = emptyList(),
// Deliberately keep mutualPasses: clearing it would shrink the
// list below one screen and clamp the scroll position away.
// The stale results are replaced when the new ones arrive.
selectedPassIndex = -1
)
}
// The results list is about to be replaced, so the scroll position must
// not leak from the previous query's list.
listScrollIndex = 0
listScrollOffset = 0
// Scroll position: an explicit initialScrollIndex overrides (legacy
// callers start at a fixed spot); null keeps the current position so
// re-querying does not jump back to the top.
if (initialScrollIndex != null) {
listScrollIndex = initialScrollIndex
listScrollOffset = 0
}
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 = withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours)
val results = try {
withContext(computeDispatcher) {
findMutualPasses(satellites, posA, posB, minElevA, minElevB, time, hours, state.hoursBefore)
}
} catch (t: Throwable) {
// Never leave the query stuck in "calculating" (which would also
// block the pending map→match navigation forever).
android.util.Log.e(TAG, "findMutualPasses threw", t)
emptyList()
}
val errorMsg = if (results.isEmpty()) {
@@ -262,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
@@ -0,0 +1,291 @@
package com.rtbishop.look4sat.feature.mutual
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.presentation.Screen
import kotlinx.coroutines.delay
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Verifies the map grid-QSO dialog "Match" prefill: after
* [MutualViewModel.prefillMatchFromGrid] the page opens with the time-range
* card at the top and the 24h query auto-started.
*
* Note: `LazyListState(firstVisibleItemIndex = 1)` is deliberately NOT used to
* position the page. In this Compose version (BOM 2026.06.01 / ui 1.11.x) the
* constructor parameter is ignored — the list still starts at item 0 — while
* `listState.scrollToItem(1)` from a LaunchedEffect lands correctly. See
* [scrollToItemLandsAtTop] below, which guards the scrollToItem mechanism.
*
* Robolectric quirk: `assertIsNotDisplayed()` is unreliable here (a node
* scrolled fully out of the viewport can still report "displayed"), so the
* prefill test asserts the time-range card IS displayed (positive assertion)
* rather than that the station card is NOT.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w411dp-h891dp-port")
class MutualMatchPrefillScrollTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun sanity_textRenders() {
composeRule.setContent {
MaterialTheme {
Text("sanity-check-text")
}
}
composeRule.onNodeWithText("sanity-check-text").assertIsDisplayed()
}
@Test
fun scrollToItemLandsAtTop() {
// Guards the mechanism the prefill relies on: scrollToItem(1) from a
// LaunchedEffect moves item 1 to the top of the viewport.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val state = remember { LazyListState(firstVisibleItemIndex = 0) }
LaunchedEffect(Unit) { state.scrollToItem(1) }
LazyColumn(state = state) {
item { Text("min-item-0") }
item { Text("min-item-1") }
item { Text("min-item-2") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("min-item-1").assertIsDisplayed()
}
@Test
fun defaultOpen_showsStationInputsAtTop() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
// Without a prefill the list starts at item 0 (station inputs).
composeRule.onNodeWithText("Your Station").assertIsDisplayed()
}
@Test
fun prefillFromMap_opensAtTimeRangeCard() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
// Same call the map grid-QSO "Match" button makes.
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
// The time-range card must be visible at the top of the page after the
// prefill scroll.
composeRule.onNodeWithText("Time range").assertIsDisplayed()
// The snapshotFlow write-back stores the scrolled position in the VM.
// It equals 1 only if scrollToItem(1) REALLY scrolled the station card
// out. If the list content is shorter than the viewport there is no
// scroll range, scrollToItem cannot move, and this stays 0 — that is
// exactly the device symptom ("page stays at the top").
org.junit.Assert.assertEquals(
"list must have actually scrolled to item 1 (content shorter than viewport?)",
1, vm.listScrollIndex
)
}
@Test
fun prefillAutoStartsQueryAndFillsState() {
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val s = vm.uiState.value
// Query auto-started: with the empty-satellite fake the query reaches
// the "no satellite data" guard (rather than never being triggered),
// proving prefillMatchFromGrid kicks off queryMutualPasses.
assertTrue(
"auto-query should reach the satellite guard: gridA=${s.stationAGrid} " +
"gridB=${s.stationBGrid} err=${s.errorMessage}",
s.errorMessage?.contains("satellite", ignoreCase = true) == true
)
// ...and the target grid + 24h range pre-filled.
org.junit.Assert.assertEquals("OL62", s.stationBGrid)
org.junit.Assert.assertEquals(24, s.hoursAhead)
// ...and navigation to the match page is pending until the query done.
assertTrue("navigation should be pending", s.pendingNavigation)
// The early-return guard means the query finished immediately (no
// satellites): the map page must now be free to navigate.
org.junit.Assert.assertFalse("query finished -> not calculating", s.isCalculating)
vm.consumePendingNavigation()
org.junit.Assert.assertFalse("consume clears pending navigation", vm.uiState.value.pendingNavigation)
}
@Test
fun navDisplayEntryAfterPrefill_scrollsToTimeRange() {
// The Mutual screen composed inside a NavDisplay entry (back stack
// [Mutual]) right after prefillMatchFromGrid.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Mutual)
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun navDisplay_switchToMutualEntry_scrollsToTimeRange() {
// Closest device path: start on the Map entry, then push the Mutual
// entry (what the map "Match" button does). NavDisplay runs a real
// fade transition while the Mutual screen composes.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
vm.prefillMatchFromGrid("OL62")
val backStackRef = mutableStateOf<NavBackStack<NavKey>?>(null)
composeRule.setContent {
val backStack = rememberNavBackStack(Screen.Map)
backStackRef.value = backStack
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<Screen.Map> { Text("MAP PAGE") }
entry<Screen.Mutual> { MutualScreen(viewModel = vm) }
}
)
}
}
}
composeRule.waitForIdle()
// Push Mutual like onMatchGrid does, so the transition composes
// MutualScreen with scrollToTimeRange already set.
composeRule.runOnIdle { backStackRef.value?.add(Screen.Mutual) }
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
@Test
fun scrollRetriesUntilContentGrows() {
// Reproduces the real-device mechanism: the first frame's content
// (item 0 short) is shorter than the viewport, so scrollToItem(1) has
// no range and cannot move. Then the content grows (async results)
// past one screen; the retry loop must land on item 1.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val tall = mutableStateOf(false)
LaunchedEffect(Unit) { delay(300); tall.value = true }
val state = remember { LazyListState() }
LaunchedEffect(state) {
// Same retry loop MutualScreen uses for the prefill.
for (attempt in 0 until 20) {
state.scrollToItem(1)
if (state.firstVisibleItemIndex == 1) return@LaunchedEffect
delay(100)
}
}
LazyColumn(state = state) {
item {
if (tall.value) Spacer(modifier = Modifier.height(900.dp))
else Spacer(modifier = Modifier.height(40.dp))
}
item { Text("target-item") }
}
}
}
}
composeRule.waitForIdle()
composeRule.onNodeWithText("target-item").assertIsDisplayed()
// Confirm the list really scrolled (station card equivalent is gone).
// We can't read listState from here, so use the VM-style probe: none —
// the target visible + viewport tall enough implies item 1 on top.
}
@Test
fun keepPositionAcrossTabs_thenMapMatchPrefill_scrollsToTimeRange() {
// User hypothesis: the "keep scroll position across tab switches"
// machinery (listState remember(queryGeneration) + snapshotFlow
// write-back) interferes with the prefill auto-scroll. Reproduce the
// full journey: first visit -> scroll a bit -> leave -> return
// (position restored) -> leave -> enter via map Match button.
val vm = MutualViewModel(FakeSatelliteRepo(), FakeSettingsRepo())
val showMutual = mutableStateOf(true)
// First visit (e.g. bottom nav), user scrolls a little; the
// snapshotFlow write-back stored index/offset in the VM.
composeRule.setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
if (showMutual.value) MutualScreen(viewModel = vm)
}
}
}
composeRule.waitForIdle()
vm.listScrollIndex = 0
vm.listScrollOffset = 40
// Leave the page (tab switch destroys the composition).
showMutual.value = false
composeRule.waitForIdle()
// Re-enter: keep-position restores the scroll offset.
showMutual.value = true
composeRule.waitForIdle()
// Leave again, then enter via the map grid-QSO Match button.
showMutual.value = false
composeRule.waitForIdle()
vm.prefillMatchFromGrid("OL62")
showMutual.value = true
composeRule.waitForIdle()
composeRule.onNodeWithText("Time range").assertIsDisplayed()
}
}
@@ -285,4 +285,39 @@ class MutualViewModelTest {
assertNull(vm.uiState.value.errorMessage)
}
@Test
fun `requery keeps scroll position and stale results while running`() = runTest(mainDispatcherRule.dispatcher.scheduler) {
val windows = TestOrbits.findPassWindows()
assertTrue("fixture must produce ISS windows", windows.isNotEmpty())
val vm = createVm(
satellites = listOf(TestOrbits.ISS),
passes = windows
)
vm.onStationBGrid("OL62")
queryAndSettle(vm)
assertTrue("fixture must yield passes", vm.uiState.value.mutualPasses.isNotEmpty())
// The user scrolled into the results; the snapshotFlow write-back has
// stored this position in the VM.
vm.listScrollIndex = 4
vm.listScrollOffset = 137
// Re-run the query from the match page ("query" button).
vm.queryMutualPasses()
// While the query runs, the old results stay on screen (the list keeps
// its height, so the scroll position is not clamped away)...
assertTrue("stale results kept while calculating", vm.uiState.value.mutualPasses.isNotEmpty())
// ...and the scroll position must not be reset to the top.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
advanceUntilIdle()
assertFalse(vm.uiState.value.isCalculating)
assertTrue("fresh results replace the stale ones", vm.uiState.value.mutualPasses.isNotEmpty())
// Position intact after the query settled.
assertEquals(4, vm.listScrollIndex)
assertEquals(137, vm.listScrollOffset)
}
}
@@ -30,12 +30,15 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -145,11 +148,16 @@ private fun PassesScreen(
onAccept = dismiss,
titleFontSize = 18
) { padding ->
// The changelog is long; keep the dialog frame fixed and let the
// text scroll inside it (matches the update-checker page pattern).
Text(
text = stringResource(R.string.pass_whatsnew_message),
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
modifier = Modifier
.padding(horizontal = padding)
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
)
}
}
@@ -96,8 +96,12 @@ class PassesViewModel(
val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace
// 历史过境窗口: 已结束的过境保留到超过 hoursBefore 小时后消失;
// DeepSpace 保持原行为(始终显示). hoursBefore=0 时过境一结束即消失.
val historyCutoff = timeNow - _uiState.value.hoursBefore * 3600_000L
val filtered = allPasses
.let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
.filter { pass -> pass.isDeepSpace || pass.losTime >= historyCutoff }
val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
@@ -339,9 +343,10 @@ class PassesViewModel(
private fun refreshPasses() = viewModelScope.launch {
val settings = settingsRepo.passesSettings.value
// 下拉刷新也保留历史过境窗口, 否则刷新即丢 history (与筛选/启动计算口径一致)
satelliteRepo.calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
time = System.currentTimeMillis() - settings.hoursBefore * 60L * 60L * 1000L,
hoursAhead = settings.hoursBefore + settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
+7 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "484"
appVersionCode = "496"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.11.3"
appVersionName = "4.4.7-ba7opf.14.2"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -33,6 +33,8 @@ other-okhttp = "5.4.0"
other-osmdroid = "6.1.20"
test-junit4 = "4.13.2"
robolectric = "4.16"
androidx-test-core = "1.7.0"
androidTest-junit = "1.3.0"
androidTest-espresso = "3.7.0"
@@ -79,6 +81,9 @@ other-osmdroid = { module = "org.osmdroid:osmdroid-android", version.ref = "othe
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" }
test-junit4 = { module = "junit:junit", version.ref = "test-junit4" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" }
compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
# Real org.json for unit tests (android.jar stub throws "not mocked")
test-json = { module = "org.json:json", version = "20240303" }