Compare commits

...
Author SHA1 Message Date
atsunatsu 3f7a087938 chore: bump version to 4.4.7-ba7opf.11.1 (versionCode 482) 2026-09-21 09:44:46 +08:00
atsunatsu 4b82332133 feat(map): first-call callsign label toggle in VUCC grid mode
Add a 'first call' pill toggle below the grid-mode switch (map top-right,
visible only in grid mode + VUCC award). When on, worked (green) cells are
labelled with the first callsign worked in that grid (earliest confirmed
QSO by time) instead of the Maidenhead code; non-worked cells get no label
at all. Persisted via OtherSettings.stateOfMapFirstCall (full MVI chain).
The feature is VUCC-only: All and other award overlays never draw it.
2026-09-21 09:44:43 +08:00
atsunatsu dcd16a0196 chore: bump version to 4.4.7-ba7opf.11 (versionCode 481) 2026-09-19 04:27:55 +08:00
atsunatsu 9e88609efc feat(amsat): show consecutive same-status report count in status cells
Day-cell numbers now count the most recent consecutive reports sharing the
newest status (counting back from the newest report, stopping at the first
different status), instead of the total report count of the day. Empty gaps
do not break the streak; statuses compare by exact report text; counting
does not cross day boundaries.

- SatDay gains streakCount (computed in AmSatRepository.buildStatuses)
- DayCell renders day.streakCount instead of slot count sum
- Add real org.json to core:data unit tests (android.jar stub cannot parse)
- Add 2 streak tests: mixed-status slot breaks, all-same-status counts all
2026-09-19 04:27:19 +08:00
14 changed files with 224 additions and 22 deletions

No files matched your search

@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
ksp(libs.androidx.room.compiler)
implementation(libs.kotlin.coroutines)
implementation(libs.other.okhttp)
// android.jar's org.json is a stub ("not mocked") in unit tests;
// provide a real implementation so JSON parsing is testable.
testImplementation(libs.test.json)
}
}
}
@@ -221,7 +221,22 @@ class AmSatRepository(
}
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
val dayStart = nowSec - (d + 1) * 86400L
val dayEnd = nowSec - d * 86400L
val dayReports = byName[name].orEmpty()
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
.sortedByDescending { it.reportedTimeUtcSec }
val streakCount = if (dayReports.isEmpty()) 0 else {
val newestStatus = dayReports.first().report
dayReports.takeWhile { it.report == newestStatus }.size
}
SatDay(
dateLabel = labels[d],
slots = slots.subList(d * 12, (d + 1) * 12),
streakCount = streakCount
)
}
SatStatus(name = name, days = days)
}
@@ -84,6 +84,7 @@ class SettingsRepo(
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyStateOfMapGrid = "stateOfMapGrid"
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -546,6 +547,7 @@ class SettingsRepo(
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
@@ -565,6 +567,7 @@ class SettingsRepo(
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
@@ -12,6 +12,10 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class AmSatRepositoryTest {
@@ -75,6 +79,52 @@ class AmSatRepositoryTest {
assertEquals(1, remoteSource.catalogRequests)
assertEquals(1, remoteSource.reportRequests)
}
@Test
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
report("r1", "AO-7", "Heard", nowSec - 3600),
report("r2", "AO-7", "Heard", nowSec - 5400),
report("r3", "AO-7", "Not Heard", nowSec - 7000),
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
report("r4", "AO-7", "Heard", nowSec - 63000),
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
report("r5", "AO-7", "Heard", nowSec - 90000),
report("r6", "AO-7", "Not Heard", nowSec - 100000)
// day 2 无报告 → 0
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
val status = page?.statuses?.single()
assertEquals("AO-7", status?.name)
assertEquals(3, status?.days?.size)
assertEquals(2, status?.days?.get(0)?.streakCount)
assertEquals(1, status?.days?.get(1)?.streakCount)
assertEquals(0, status?.days?.get(2)?.streakCount)
}
@Test
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
val nowSec = System.currentTimeMillis() / 1000
val remoteSource = FakeAmSatRemoteSource(
reportsJson = amSatReportsJson(
report("r1", "AO-7", "Heard", nowSec - 1800),
report("r2", "AO-7", "Heard", nowSec - 3600),
report("r3", "AO-7", "Heard", nowSec - 5400),
report("r4", "AO-7", "Heard", nowSec - 30000)
)
)
val repository = AmSatRepository(remoteSource)
val page = repository.fetchStatus()
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
}
}
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
@@ -83,7 +133,10 @@ private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
cacheField.set(this, page)
}
private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L) : IRemoteSource {
private class FakeAmSatRemoteSource(
private val responseDelayMillis: Long = 0L,
private val reportsJson: String = """{"data":[]}"""
) : IRemoteSource {
var catalogRequests = 0
var reportRequests = 0
@@ -100,8 +153,19 @@ private class FakeAmSatRemoteSource(private val responseDelayMillis: Long = 0L)
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
if (responseDelayMillis > 0L) delay(responseDelayMillis)
reportRequests += 1
return """{"data":[]}"""
return reportsJson
}
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
}
private fun amSatReportsJson(vararg reports: String): String =
"""{"data":[${reports.joinToString(",")}]}"""
private fun report(id: String, name: String, status: String, epochSec: Long): String =
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
private fun isoUtc(epochSec: Long): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(epochSec * 1000))
@@ -20,7 +20,8 @@ data class SatSlot(
/** One satellite day (12 two-hour slots) */
data class SatDay(
val dateLabel: String, // "Aug 4"
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
)
/** One satellite, 3 days of state */
@@ -60,6 +60,10 @@ data class OtherSettings(
val stateOfLightTheme: Boolean,
val stateOfNightMode: Boolean = false,
val stateOfMapGrid: Boolean = false,
/** Grid mode: 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. */
val stateOfMapFirstCall: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
val sstvMode: String = "Auto",
@@ -109,6 +109,7 @@
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_first_call_mode">首通呼号</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -133,6 +133,7 @@
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_first_call_mode">First call</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -52,6 +52,13 @@ class MaidenheadGridOverlay : Overlay() {
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
style = android.graphics.Paint.Style.FILL
color = Color.argb(230, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = android.graphics.Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
@@ -79,6 +86,15 @@ class MaidenheadGridOverlay : Overlay() {
/** Worked gridsquares (4-char, uppercase) to highlight, e.g. {"OL62", "PM95"}. */
var workedGrids: Set<String> = emptySet()
/** 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. */
var showFirstCallLabels: Boolean = false
/** Worked grid -> first callsign worked in it (earliest QSO by time).
* Only read when [showFirstCallLabels] is on. */
var firstCallsByGrid: Map<String, String> = emptyMap()
/**
* Gridsquares the station operated from (4-char, uppercase) — drawn with
* blue 45° stripes (GridMaster-style zebra), e.g. {"OL62", "PM95"}.
@@ -348,6 +364,10 @@ class MaidenheadGridOverlay : Overlay() {
// 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).
// 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).
if (showFirstCallLabels && zoom < LABEL_ZOOM_SUB) return
val showLabels = zoom >= LABEL_ZOOM_SUB || cellLat == FIELD_LAT
if (!showLabels) return
// Estimate on-screen cell height to avoid clutter at low zoom:
@@ -358,8 +378,9 @@ class MaidenheadGridOverlay : Overlay() {
val pixelsPerDegree = Math.abs(y2 - y1)
if (pixelsPerDegree * cellLat < MIN_LABEL_CELL_PX) return
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val activePaint = if (showFirstCallLabels) firstCallPaint else labelPaint
activePaint.textAlign = Paint.Align.CENTER
val fontMetrics = activePaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (row in firstRow..lastRow) {
val lat = row * cellLat
@@ -386,7 +407,17 @@ class MaidenheadGridOverlay : Overlay() {
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
if (showFirstCallLabels) {
// 首通呼号模式: 只有绿格(worked)标注该格第一个通联的呼号,
// 非绿格空着不写网格字符.
if (label in workedGrids) {
firstCallsByGrid[label]?.let { call ->
canvas.drawText(call, (xLeft + xRight) / 2f, yCenter, firstCallPaint)
}
}
} else {
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
}
}
}
}
@@ -215,6 +215,13 @@ private fun MapScreen(
}
m
}
// First callsign worked in each grid (earliest QSO by time), used by the
// "首通呼号" label mode — derived from the same QSO store as the worked fills.
val firstCallsByGrid: Map<String, String> = remember(uiState.workedGridQsos) {
uiState.workedGridQsos.mapNotNull { (grid, qsos) ->
qsos.minByOrNull { it.epochMs }?.let { grid to it.call }
}.toMap()
}
// Selected operated grid for VUCC counting; defaults to the grid with the
// most worked grids. Null when the store carries no per-QSO myGrid data
// (requires a LoTW resync) — then VUCC falls back to the global count.
@@ -337,7 +344,10 @@ private fun MapScreen(
setGridMode(
uiState.isGridMode, workedGrids, uiState.roamedGrids, view,
uiState.stationPosition,
centerOnStation = shouldCenter
centerOnStation = shouldCenter,
// 首通呼号标签只作用于 VUCC 视图(用户要求); "All"/其他奖状不过滤.
showFirstCallLabels = uiState.showFirstCallLabels && selectedAward == AwardType.VUCC,
firstCallsByGrid = firstCallsByGrid
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
@@ -372,13 +382,27 @@ private fun MapScreen(
.padding(8.dp)
)
}
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
// Top-right: grid-mode pill + (grid mode only) the first-call
// label pill, stacked vertically.
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.End
) {
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) }
)
// 首通呼号开关仅 VUCC 视图显示(与 VuccGridSelector 同条件).
if (uiState.isGridMode && selectedAward == AwardType.VUCC) {
FirstCallToggle(
checked = uiState.showFirstCallLabels,
onToggle = { onAction(MapAction.ToggleFirstCallLabels(it)) }
)
}
}
}
}
}
@@ -800,6 +824,40 @@ private fun GridModeToggle(
}
}
/**
* 网格模式下的"首通呼号"开关: 打开后绿格(worked)的标签显示该格第一个通联的
* 呼号, 非绿格不显示任何文字. 仅在网格模式下显示(与网格模式开关叠放).
*/
@Composable
private fun FirstCallToggle(
checked: Boolean,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(percent = 50),
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.map_first_call_mode),
color = ComposeColor.White,
fontSize = 12.sp,
modifier = Modifier.padding(end = 2.dp)
)
Switch(
checked = checked,
onCheckedChange = onToggle,
modifier = Modifier.scale(0.75f)
)
}
}
}
// endregion
// region Map overlay helpers
@@ -843,7 +901,9 @@ private fun setGridMode(
roamedGrids: Set<String>,
mapView: MapView,
stationPosition: GeoPos?,
centerOnStation: Boolean = false
centerOnStation: Boolean = false,
showFirstCallLabels: Boolean = false,
firstCallsByGrid: Map<String, String> = emptyMap()
) {
try {
val gridOverlay = mapView.overlays[OVERLAY_GRID]
@@ -851,6 +911,9 @@ private fun setGridMode(
gridOverlay.isEnabled = gridMode
gridOverlay.workedGrids = workedGrids
gridOverlay.roamedGrids = roamedGrids
// 首通呼号模式: 绿格标注第一个通联的呼号, 非绿格不显示标签.
gridOverlay.showFirstCallLabels = showFirstCallLabels
gridOverlay.firstCallsByGrid = firstCallsByGrid
// 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).
@@ -860,6 +923,8 @@ private fun setGridMode(
isEnabled = gridMode
this.workedGrids = workedGrids
this.roamedGrids = roamedGrids
this.showFirstCallLabels = showFirstCallLabels
this.firstCallsByGrid = firstCallsByGrid
this.ownGrid = ownGridOf(stationPosition)
}
}
@@ -27,6 +27,9 @@ data class MapState(
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
val isGridMode: Boolean = false,
/** Grid mode: label worked (green) cells with the first callsign worked in
* that grid instead of the grid code; non-worked cells get no label. */
val showFirstCallLabels: Boolean = false,
val workedGrids: Set<String> = emptySet(),
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. */
@@ -48,6 +51,7 @@ sealed interface MapAction {
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class ToggleFirstCallLabels(val value: Boolean) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
@@ -79,7 +79,13 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
_uiState.update { it.copy(isUtc = settings.stateOfUtc, isGridMode = settings.stateOfMapGrid) }
_uiState.update {
it.copy(
isUtc = settings.stateOfUtc,
isGridMode = settings.stateOfMapGrid,
showFirstCallLabels = settings.stateOfMapFirstCall
)
}
}
}
viewModelScope.launch {
@@ -105,6 +111,7 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item)
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.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -338,15 +338,16 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
/**
* Day block: colored by the newest reported status among the day's 12 slots
* (gray when none); the number is the TOTAL report count of the whole day
* (sum over all slots), not the count of the newest slot alone.
* (gray when none); the number is the count of the most recent consecutive
* reports sharing that same status (day.streakCount), not the total report
* count of the whole day.
*/
@Composable
private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
val noReportGray = 0xFFC0C0C0L
val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first()
val color = Color(slot.statusColor)
val totalCount = day.slots.sumOf { it.count }
val streakCount = day.streakCount
Box(
modifier = modifier
.height(28.dp)
@@ -355,8 +356,8 @@ private fun DayCell(day: SatDay, modifier: Modifier, onClick: () -> Unit) {
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (totalCount > 0) {
Text(text = totalCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
if (streakCount > 0) {
Text(text = streakCount.toString(), fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
}
+4 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "480"
appVersionCode = "482"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.10"
appVersionName = "4.4.7-ba7opf.11.1"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry
@@ -79,6 +79,8 @@ 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" }
# Real org.json for unit tests (android.jar stub throws "not mocked")
test-json = { module = "org.json:json", version = "20240303" }
androidTest-junit = { module = "androidx.test.ext:junit", version.ref = "androidTest-junit" }
androidTest-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidTest-espresso" }