Compare commits

...
Author SHA1 Message Date
atsunatsu cd3eded77c chore: bump version to 4.4.6-ba7opf.9.11 2026-09-08 06:03:35 +08:00
atsunatsu 7d7f8f1eda feat(map): expanded QSO list — drop ordinal prefix, add per-QSO date
Expanded per-callsign rows previously read '2 · FO-29 · CW · V/U · 11:30Z'
(ordinal + summary, no date). Now each expanded line is
'yyyy-MM-dd · FO-29 · CW · V/U · 11:30Z' — the first QSO's date already
lives in the header row, so only subsequent QSOs carry a date prefix.
2026-09-08 05:57:27 +08:00
atsunatsu 11b46c6710 feat(map): tap worked cells from GRID_ZOOM_SUB (lines visible), labels from zoom 6.5
- Tap-to-show-QSOs now works as soon as 4-char grid lines appear (zoom 6),
  not only when 4-char labels show (was zoom 7). Selection outline already
  used GRID_ZOOM_SUB, so feedback is immediate.
- 4-char labels now appear from zoom 6.5 (was 7.0), so the sub-square name
  shows shortly after the lines appear instead of a full zoom level later.
2026-09-08 05:54:06 +08:00
atsunatsu da1facac64 fix(map): world-repeat copies no longer repaint in place — alpha stacking made worked cells brighter and opaque
Pixel analysis of the user's screenshot: worked-cell green measured RGB(57,160,74)
against background RGB(16,16,15), which matches alpha≈178 — i.e. the alpha=90
fill painted ~3 times in place. Root cause: at low zoom colRepeats>0 and every
turn loop shifted the cell by dLon=turn*360°, but projectionToX() normalizes
longitude back into [-180,180), so all turns collapsed onto the SAME screen x
and each cell/line/label was painted 2*colRepeats+1 times at one spot.

Fix: keep the turn offset as a pixel shift (turn * worldWidthPx) on the x
coordinate in ALL turn loops (worked fills at both zooms, vertical lines,
horizontal lines, labels). World-repeat copies now land on their true screen
positions instead of stacking on top of each other.
2026-09-08 05:46:08 +08:00
atsunatsu cb1f341ed9 fix(map): field-zoom grid lines no longer cut through worked cells — root cause of 'green too bright'
The previous fixes only handled sub-square zoom lines. At field zoom the 20°x10°
grid lines were drawn unconditionally: cellLabel(zoom<6) returns 2-char fields
which never match the 4-char workedGrids set, so every field line crossed every
worked 2°x1° square. Bright-yellow (alpha 160) lines through the green fill is
what read as 'green got brighter' — the fill color itself never changed.

Now both line passes iterate the finer 2°x1° sub-grid (forced 4-char labels via
GRID_ZOOM_SUB) and skip segments bordering worked cells, at field zoom and
sub-square zoom alike. Revert the alpha softening (c2e496c4) — the fill was
never the problem; also restores worked-cell labels (7afce7ee revert).
2026-09-08 05:28:12 +08:00
atsunatsu c2e496c406 fix(map): soften worked-cell fill at sub-square zoom, restore cell labels
Reverting the label-skip from 7afce7ee: worked cells keep their 4-char
labels. The 'green too bright' report is an area effect, not an overlay
overdraw — the same alpha=90 fill that reads as a small patch at field zoom
covers half the viewport at sub-square zoom. Use a softer fill (alpha 50)
for the sub-square branch, field zoom unchanged.
2026-09-08 05:02:05 +08:00
atsunatsu 7afce7ee68 fix(map): skip labels on worked cells — bright-yellow 4-char text on green fill read as 'green too bright'
The previous fix skipped grid LINES through worked cells, but each worked cell
still got a bright-yellow (alpha 220 + black shadow) 4-char label drawn dead
center on the green fill at zoom>=7. Field zoom never shows this because 2-char
labels sit at field centers and almost never overlap a worked 2x1 square. Now
worked cells stay completely label-free, matching the field-zoom look.
2026-09-08 04:47:43 +08:00
atsunatsu 86048ee1fd fix(lotw): run report fetch on Dispatchers.IO — main-thread blocking caused ANR/crash
fetchReportBody is a synchronous HttpURLConnection call (up to 30s connect +
120s read) but was invoked directly from a Main-dispatcher viewModelScope
coroutine: the whole UI froze for the duration of the request, then Android
killed the app with an ANR dialog. Wrap both fetch methods in
withContext(Dispatchers.IO). Bug present since the endpoint was introduced.
2026-09-08 04:32:21 +08:00
4 changed files with 116 additions and 51 deletions

No files matched your search

@@ -45,20 +45,24 @@ import javax.net.ssl.SSLException
class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onFailure = { toResult(it) }
)
}
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onFailure = { toResult(it) }
)
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): LoTWResult = fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onFailure = { toResult(it) }
)
}
internal fun toResult(e: Throwable): LoTWResult = when (e) {
is CredentialsException -> LoTWResult.BadCredentials
@@ -142,8 +142,13 @@ class MaidenheadGridOverlay : Overlay() {
if (yTop == null || yBottom == null) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
// World-repeat copies: keep the turn offset in pixels
// (colRepeats is 0 at this zoom today, but the same
// alpha-stacking fix applies if it ever becomes > 0).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
@@ -153,14 +158,22 @@ class MaidenheadGridOverlay : Overlay() {
} else {
for (grid in workedGrids) {
val cell = gridCellBounds(grid) ?: continue
// World-repeat copies: a 360° turn shifts the cell by a
// full world width in pixels. projectionToX() normalizes
// longitude back into [-180,180), so WITHOUT adding the
// turn*worldWidthPx offset every copy lands on the SAME
// screen x and the cell gets painted 2*colRepeats+1 times
// in place — alpha stacks and the green turns brighter and
// more opaque. Keep the world-width offset on the x.
for (turn in -colRepeats..colRepeats) {
// Shift the cell by whole world turns to cover repeats.
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 xLeft = projectionToX(projection, cell.lonLeft + dLon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, cell.lonRight + dLon, centerLon, worldWidthPx) ?: 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, workedPaint)
@@ -169,15 +182,19 @@ class MaidenheadGridOverlay : Overlay() {
}
}
// Vertical lines (meridians), segmented per cell row so that worked
// grid cells stay line-free (same look as field-zoom fills: green
// patches without square grid lines). Only relevant at sub-square
// zoom; at field zoom cellLabel() yields 2-char fields which never
// match the 4-char workedGrids, so all lines draw.
// Vertical lines (meridians), segmented per sub-square row so that
// worked grid cells stay line-free at BOTH zoom levels. At sub-square
// zoom cellLat == SUB_SQUARE_LAT so each segment is one cell edge; at
// field zoom a single 10° cell row spans ten 1° sub-rows, and the
// worked cells are 2°x1° squares, so we must iterate the finer
// sub-square grid to decide which field-line segments to skip.
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon, centerLon, worldWidthPx)
if (x == null) continue
// World-repeat copies: keep the turn offset in pixels, otherwise
// every turn normalizes to the same x and the line paints over
// itself 2*colRepeats+1 times (alpha stacking).
val xBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val x = xBase + turn * worldWidthPx.toFloat()
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
@@ -185,18 +202,33 @@ class MaidenheadGridOverlay : Overlay() {
if (topLatCell > 90.0) continue
val yTop = projectionToY(projection, topLatCell) ?: continue
val yBottom = projectionToY(projection, lat) ?: continue
// This meridian is a border of the cell on its right (col)
// and of the cell on its left (col-1); skip the segment if
// either cell is a worked grid.
val workedHere =
cellLabel(lat, lon, zoom) in workedGrids ||
cellLabel(lat, lon - cellLon, zoom) in workedGrids
if (workedHere) continue
canvas.drawLine(x, yTop, x, yBottom, linePaint)
if (cellLon == SUB_SQUARE_LON && cellLat == SUB_SQUARE_LAT) {
// Sub-square zoom: one segment == one cell edge.
val workedHere =
cellLabel(lat, lon, zoom) in workedGrids ||
cellLabel(lat, lon - cellLon, zoom) in workedGrids
if (workedHere) continue
canvas.drawLine(x, yTop, x, yBottom, linePaint)
} else {
// Field zoom: split the 10° row into 1° sub-rows and skip
// the segments bordering a worked 2°x1° square.
for (sub in 0 until (cellLat / SUB_SQUARE_LAT).toInt()) {
val subLat = lat + sub * SUB_SQUARE_LAT
val ySubTop = projectionToY(projection, subLat + SUB_SQUARE_LAT) ?: continue
val ySubBottom = projectionToY(projection, subLat) ?: continue
// Force 4-char labels: at field zoom cellLabel(zoom)
// returns 2-char fields which never match workedGrids.
val workedHere =
cellLabel(subLat, lon, GRID_ZOOM_SUB) in workedGrids ||
cellLabel(subLat, lon - SUB_SQUARE_LON, GRID_ZOOM_SUB) in workedGrids
if (workedHere) continue
canvas.drawLine(x, ySubTop, x, ySubBottom, linePaint)
}
}
}
}
// Horizontal lines (parallels), segmented per cell column so worked
// cells have no lines through them either.
// Horizontal lines (parallels), segmented per sub-square column so
// worked cells have no lines through them at either zoom level.
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat <= -90.0 || lat >= 90.0) continue
@@ -204,16 +236,38 @@ class MaidenheadGridOverlay : Overlay() {
if (y == null) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
// World-repeat copies: keep the turn offset in pixels (same
// alpha-stacking fix as the vertical lines above).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
// This parallel borders the cell above (row) and below
// (row-1); skip the segment if either is worked.
val workedHere =
cellLabel(lat, lon, zoom) in workedGrids ||
cellLabel(lat - cellLat, lon, zoom) in workedGrids
if (workedHere) continue
canvas.drawLine(xLeft, y, xRight, y, linePaint)
if (cellLon == SUB_SQUARE_LON && cellLat == SUB_SQUARE_LAT) {
// Sub-square zoom: one segment == one cell edge.
val workedHere =
cellLabel(lat, lon, zoom) in workedGrids ||
cellLabel(lat - cellLat, lon, zoom) in workedGrids
if (workedHere) continue
canvas.drawLine(xLeft, y, xRight, y, linePaint)
} else {
// Field zoom: split the 20° field column into 2° sub-columns
// and skip the segments bordering a worked 2°x1° square.
for (sub in 0 until (cellLon / SUB_SQUARE_LON).toInt()) {
val subLon = lon + sub * SUB_SQUARE_LON
val xSubLeftBase = projectionToX(projection, subLon, centerLon, worldWidthPx) ?: continue
val xSubRightBase = projectionToX(projection, subLon + SUB_SQUARE_LON, centerLon, worldWidthPx) ?: continue
val xSubLeft = xSubLeftBase + turn * worldWidthPx.toFloat()
val xSubRight = xSubRightBase + turn * worldWidthPx.toFloat()
if (xSubRight < 0f || xSubLeft > canvas.width) continue
// Force 4-char labels (see vertical lines above).
val workedHere =
cellLabel(lat, subLon, GRID_ZOOM_SUB) in workedGrids ||
cellLabel(lat - SUB_SQUARE_LAT, subLon, GRID_ZOOM_SUB) in workedGrids
if (workedHere) continue
canvas.drawLine(xSubLeft, y, xSubRight, y, linePaint)
}
}
}
}
@@ -269,8 +323,12 @@ class MaidenheadGridOverlay : Overlay() {
if (yBottom < 0f || yTop > canvas.height) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
// World-repeat copies: keep the turn offset in pixels (same
// alpha-stacking fix as the lines above).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
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)
@@ -357,8 +415,8 @@ class MaidenheadGridOverlay : Overlay() {
// 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 7+ = lines + names).
const val LABEL_ZOOM_SUB = 7.0
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
const val LABEL_ZOOM_SUB = 6.5
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
}
@@ -153,7 +153,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
if (!isGridMode || p == null) return false
val zoom = mapView.zoomLevelDouble
if (zoom < MaidenheadGridOverlay.LABEL_ZOOM_SUB) return false
// Allow tapping worked cells as soon as the 4-char grid LINES
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
if (grid !in workedGrids) return false
selectedGrid = grid
@@ -345,12 +347,13 @@ private fun WorkedGridCallRow(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 3.dp)
)
// Subsequent QSOs in this grid, revealed on expand.
// Subsequent QSOs in this grid, revealed on expand. No ordinal prefix;
// each line carries its own date (the first QSO's date already sits in
// the header row).
if (expanded && callQsos.size > 1) {
callQsos.drop(1).forEachIndexed { index, qso ->
val ordinal = "${index + 2}"
callQsos.drop(1).forEach { qso ->
Text(
text = "$ordinal " + qsoSummary(qso, isUtc),
text = formatDate(qso.epochMs, isUtc) + " · " + qsoSummary(qso, isUtc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 3.dp)
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "460"
appVersionCode = "462"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.9"
appVersionName = "4.4.6-ba7opf.9.11"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry