Compare commits

...
Author SHA1 Message Date
atsunatsu 1871c05002 build: release version 4.4.7-ba7opf.17.8 (531) 2026-09-27 21:26:16 +08:00
atsunatsu e9f58f76f6 feat(radar): keep the full-screen circle width share in small windows
The radar circle is a square, so it is limited by the shorter side of
the card it is drawn in: the small-window layout gave the radar card a
height of ~156dp in a 411x450 window, which shrank the circle to ~36% of
the page width while the full-screen layout shows ~84%.

- measure the real window with BoxWithConstraints instead of trusting the
  window size class, which can still report the full screen while the app
  runs in a split/floating window
- when the classic 1:1 split would give the radar less than 75% of the
  page width, use a fill layout: the radar takes the largest square that
  fits, a usable pager block is reserved under it while that keeps the
  circle at its full-screen share, otherwise the pager collapses into a
  48dp translucent tab strip with a tappable panel
- keep the normal two-row top area in the vertical layout: TimerRow and
  NextPassRow both carry weight(1f) internally, so putting them in a
  single row splits that row into two 50% blocks
- extract the geometry into pure Dp functions (useFillRadarLayout,
  radarFillSizes) covered by unit tests
2026-09-27 21:26:12 +08:00
atsunatsu 800494b978 feat(radar): fill radar in split-screen via measured window size
The previous compact-mode trigger used currentWindowAdaptiveInfo(),
which reports the full-screen size class in split-screen / multi-window,
so the radar never enlarged there. Measure the actual window bounds with
BoxWithConstraints: compact (radar weight 1f fills all space, pager fixed
132dp strip) when maxWidth < 600dp && maxHeight < 480dp.

build: release version 4.4.7-ba7opf.17.6 (529)
2026-09-27 20:13:13 +08:00
atsunatsu f1091acbbd build: release version 4.4.7-ba7opf.17.4 (527) 2026-09-27 18:32:28 +08:00
atsunatsu 99bf3d34a9 feat(radar): enlarge radar in split-screen/small windows
When the window is vertical (narrow) and too short for split-screen or a
small floating window, give the radar card 1.6/0.6 weight instead of the
usual 1:1 so the square radar plot stays large. The pager card shrinks
but all its pages are scrollable, so nothing is clipped. Full-screen
vertical layout (enough height) is unchanged.
2026-09-27 18:32:24 +08:00
atsunatsu 1dd185971f fix(lotw): precise uploaded marking; surface skipped/unavailable counts
P2 — markUploaded now only marks the records that actually made it
into the accepted TQ8 batch (preview.submittedIds), never the whole
candidate list. Previously, un-signable records skipped by prepare()
were still marked UP after any accepted upload, falsely showing them
as uploaded.

P4 — surface why records did not upload:
- When audit finds nothing pending, distinguish unavailable records
  (invalid call/date — check logbook) from unknown-result records
  (not retried automatically) instead of a blanket 'no pending'.
- Upload preview dialogs (Log tab + logbook) show a breakdown line:
  N already uploaded/duplicate · N unknown result · N un-uploadable.
2026-09-27 17:46:21 +08:00
atsunatsu fe85703e56 fix(lotw): decouple QSO records from certificate station; skip un-signable records
- Remove LOCATION_MISMATCH check: the recorded myGrid never enters the
  TQ8 (the tSTATION grid is signed from the current certificate station),
  so a portable/ADIF-imported record with a different grid must not block
  upload. Logging and uploading are now fully decoupled from the
  certificate location.
- Fingerprint now covers the CONTACT only (call, date, band, mode,
  satellite, freq), excluding station fields: changing grid/zones/county/
  IOTA no longer invalidates ledger entries, so already-uploaded contacts
  are not re-queued and rejected as duplicates.
- prepare(): catch un-signable contacts per-record instead of aborting
  the whole batch; count them as skipped and upload the rest.
- audit(): count lotwUploaded records as uploaded (ledger-loss fallback).
2026-09-27 17:35:34 +08:00
atsunatsu caddba5b81 fix(settings): load LoTW certificate status at startup
The LoTW upload card read uiState.lotwCertificate which was only
populated when the config dialog was opened (LoadLoTWUploadStatus).
On first entry to the settings screen it stayed null, showing
'certificate not imported' even though a certificate was stored.
Load certificate + station once in init so the card is correct
from the first frame.
2026-09-27 17:07:02 +08:00
11 changed files with 483 additions and 68 deletions

No files matched your search

@@ -24,14 +24,6 @@ internal class LoTWSigner(private val config: LoTWConfig) {
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
fail(LoTWProblem.QSO_DATE, call)
}
val grids = buildList {
addAll(station.getValue("GRIDSQUARE").split(',').map(String::trim))
station["MY_VUCC_GRIDS"]?.split(',')?.map(String::trim)?.let(::addAll)
}.filter(String::isNotBlank)
if (record.myGrid.isNotBlank() && grids.none { grid ->
val local = record.myGrid.trim().uppercase(Locale.US)
grid.startsWith(local) || local.startsWith(grid)
}) fail(LoTWProblem.LOCATION_MISMATCH, call)
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
val fields = linkedMapOf(
"BAND" to config.band(record.band, record.txFrequencyHz, true),
@@ -46,8 +38,12 @@ internal class LoTWSigner(private val config: LoTWConfig) {
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
).filterValues { it.isNotBlank() }
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
// deliberately excluded: changing the station location must not change
// the fingerprint, otherwise previously-uploaded contacts would lose
// their ledger entry and be re-uploaded (and rejected as duplicates).
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
station.toSortedMap().entries.joinToString("") { field(it.key, it.value) } +
fields.entries.joinToString("") { field(it.key, it.value) }
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
return LoTWContact(record, fields, signData, hash)
@@ -167,7 +167,7 @@ class LoTWUploadRepository internal constructor(
coroutineContext.ensureActive()
when {
record.status != QsoStatus.COMPLETE -> unavailable++
record.lotwReceived -> uploaded++
record.lotwReceived || record.lotwUploaded -> uploaded++
else -> {
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
@@ -196,11 +196,19 @@ class LoTWUploadRepository internal constructor(
val ledger = ledger()
var skipped = 0
var unknown = 0
var unavailable = 0
val unique = hashSetOf<String>()
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
coroutineContext.ensureActive()
if (record.status != QsoStatus.COMPLETE || (record.lotwReceived && !resubmit)) { skipped++; return@mapNotNull null }
val contact = signing.signer.contact(record, signing.key, signing.location, now())
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
val contact = try {
signing.signer.contact(record, signing.key, signing.location, now())
} catch (_: LoTWOperationException) {
// One un-signable record must not abort the whole batch:
// skip it, count it, and let the rest upload.
unavailable++
return@mapNotNull null
}
val previous = ledger[contact.fingerprint]
when {
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
@@ -214,7 +222,9 @@ class LoTWUploadRepository internal constructor(
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
unknown
unknown,
unavailable,
contacts.map { it.record.id }
)
if (contacts.isNotEmpty()) pending = Pending(
preview,
@@ -66,7 +66,12 @@ data class LoTWUploadPreview(
val firstUtc: String,
val lastUtc: String,
val contacts: List<String>,
val unknownSkipped: Int = 0
val unknownSkipped: Int = 0,
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
val unavailableSkipped: Int = 0,
/** Ids of the records that actually made it into this TQ8 batch. Only these
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
val submittedIds: List<Long> = emptyList()
)
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
+3 -1
View File
@@ -10,4 +10,6 @@ dependencies {
implementation(project(":feature:mutual"))
implementation(project(":feature:cw"))
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
}
testImplementation(libs.test.junit4)
testImplementation(libs.test.coroutines)
}
@@ -294,6 +294,14 @@ private fun UploadPreviewDialog(
}.parse(it).time }.getOrDefault(System.currentTimeMillis())
}))
}Z", fontSize = 13.sp)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -160,11 +160,18 @@ class LogViewModel(
val pending = all.filter { !it.lotwConfirmed && it.status == QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(busy = false, message = "No pending QSOs to upload") }
val msg = when {
audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(busy = false, message = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastUploadedIds = pending.map { it.id }
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastUploadedIds = preview.submittedIds
_uiState.update { it.copy(busy = false, preview = preview) }
} catch (e: LoTWOperationException) {
_uiState.update { it.copy(busy = false, message = "Upload unavailable: ${e.reason}") }
@@ -28,14 +28,18 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
@@ -43,19 +47,24 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
@@ -74,7 +83,6 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.formatFrequency
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
@@ -87,6 +95,85 @@ private enum class RadarPage(val title: String) {
Sstv("SSTV")
}
/**
* Always-visible translucent pager tab strip drawn over the radar in small windows.
*/
private val COMPACT_TAB_STRIP_HEIGHT = 48.dp
/**
* Smallest pager block worth reserving below the radar before falling back to the
* overlaid tab strip + panel. Below this the pager would be unusable anyway.
*/
private val COMPACT_MIN_PAGER_HEIGHT = 96.dp
/**
* Vertical chrome the classic layout spends outside the radar: two 48dp top bar rows
* plus three 6dp row gaps (bar/bar/radar/pager).
*/
private val CLASSIC_VERTICAL_CHROME = 114.dp
/**
* The radar circle is 0.95 x min(cardWidth, cardHeight), so a card that is wider than it
* is tall always shows a small circle with empty bands on the sides. Full screen the radar
* card ends up ~0.88 x cardWidth tall (circle ~84% of the page width). Once the classic
* 1:1 split on a vertical window would give the radar less than this share of its own
* width, the fill layout takes over so the circle keeps the full-screen width proportion
* instead of shrinking with the window height.
*/
private const val FILL_RADAR_SPLIT_RATIO = 0.75f
/**
* Smallest radar side (as a share of the page width) worth keeping a reserved pager block for.
* 0.88 x 0.95 = ~0.84 = the circle's share of the page width full screen, so the pager is only
* preserved while reserving it costs the circle nothing against the full-screen proportion.
*/
private const val MIN_RADAR_SHARE_WITH_PAGER = 0.88f
/** Vertical gap kept between the stacked cards. */
private val ROW_GAP = 6.dp
/**
* True when a vertical window is too short for the classic 1:1 split to keep the radar circle
* at its full-screen share of the page width. The split gives the radar
* `(height - chrome) / 2`, so a short window caps the circle by that height instead of by the
* page width.
*/
internal fun useFillRadarLayout(isVertical: Boolean, maxWidth: Dp, maxHeight: Dp): Boolean =
isVertical && (maxHeight - CLASSIC_VERTICAL_CHROME) / 2 < maxWidth * FILL_RADAR_SPLIT_RATIO
/**
* Geometry of the fill layout: the square the radar keeps for itself (largest square that fits,
* so the circle spans the page width like it does full screen) and the space left for the pager.
* When that space is too small to be usable the pager is overlaid on the plot instead.
*/
internal data class RadarFillSizes(
val radarSide: Dp,
val pagerSpace: Dp,
val pagerOverlaid: Boolean
)
internal fun radarFillSizes(maxWidth: Dp, maxHeight: Dp): RadarFillSizes {
// Preferred: keep a usable pager block under the radar square. That is only worth it while
// the circle still reaches its full-screen share of the page width.
val radarWithPager = minOf(maxWidth, maxHeight - COMPACT_MIN_PAGER_HEIGHT - ROW_GAP)
if (radarWithPager >= maxWidth * MIN_RADAR_SHARE_WITH_PAGER) {
return RadarFillSizes(
radarSide = radarWithPager,
pagerSpace = maxHeight - radarWithPager - ROW_GAP,
pagerOverlaid = false
)
}
// Window too short for both: the radar takes the largest square it can and the pager is
// folded into the overlaid tab strip.
val radarSide = minOf(maxWidth, maxHeight)
val pagerSpace = maxHeight - radarSide - ROW_GAP
return RadarFillSizes(
radarSide = radarSide,
pagerSpace = pagerSpace,
pagerOverlaid = pagerSpace < COMPACT_MIN_PAGER_HEIGHT
)
}
@Composable
fun RadarDestination(navigateUp: () -> Unit) {
val context = LocalContext.current
@@ -172,37 +259,188 @@ private fun RadarScreen(
time = it.time
)
}
Column(
BoxWithConstraints(
modifier = Modifier
.layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
.keepScreenOn()
) {
val isVertical = isVerticalLayout()
if (isVertical) {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
// Measure the ACTUAL window constraints instead of the window size
// class: currentWindowAdaptiveInfo() often reports the full-screen
// size in split-screen / multi-window, so the compact branch never
// triggered there. maxHeight/maxWidth are the real window bounds.
val isVertical = maxWidth < 600.dp
// The classic vertical layout gives the radar a 1:1 share of the height while also
// paying for two top bar rows. In split-screen / small windows that leaves a card that
// is wider than it is tall, so the circle is capped by the height and stops filling the
// page width. Below the full-screen proportion the fill layout takes over: one top bar
// row, the radar keeps the largest square it can, the pager yields (reserved block when
// the window is tall enough, overlaid tab strip + panel when it is not).
val fillRadar = useFillRadarLayout(isVertical, maxWidth, maxHeight)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isVertical) {
// Normal vertical top area: the timer card keeps its own row and the pass card
// gets a full-width row of its own. Never merge them into one row — both cards
// carry weight(1f), so side by side they split the width into two halves.
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
}
}
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else {
TopBar {
IconCard(action = navigateUp, resId = R.drawable.ic_back)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
if (isVertical) {
if (fillRadar) {
RadarFillArea(
uiState = uiState,
trackB = trackB,
trackBPosition = trackBPosition,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
modifier = Modifier.weight(1f)
)
} else {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
}
}
if (isVertical) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
} else {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.weight(1f))
}
}
/**
* Radar area for split-screen / small windows. The radar takes the largest square the area
* can hold so the circle spans the same share of the width it does full screen, and the
* pager yields: a real block below the square when there is room, otherwise a translucent
* tab strip overlaid on the plot that opens the pager panel on top of it.
*/
@Composable
private fun RadarFillArea(
uiState: RadarState,
trackB: List<OrbitalPos>,
trackBPosition: OrbitalPos?,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val sizes = radarFillSizes(maxWidth, maxHeight)
// Hoisted out of the nested Box scope: the BoxWithConstraints receiver is not
// implicitly reachable inside it.
val pagerPanelHeight = maxHeight * 0.72f
if (!sizes.pagerOverlaid) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(ROW_GAP)
) {
RadarCard(uiState, trackB, trackBPosition, Modifier.height(sizes.radarSide).fillMaxWidth())
PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(sizes.pagerSpace))
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
RadarCard(uiState, trackB, trackBPosition, Modifier.fillMaxSize(), overlayStrip = true)
CompactPagerOverlay(
uiState = uiState,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
panelHeight = pagerPanelHeight,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
}
/**
* Collapsed pager for short windows: a translucent row of tab names over the bottom of the
* radar plot; picking one opens the full pager panel on top of the plot, collapsible again
* with the chevron in its tab row.
*/
@Composable
private fun CompactPagerOverlay(
uiState: RadarState,
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
panelHeight: Dp,
modifier: Modifier = Modifier
) {
val pages = rememberRadarPages(uiState)
var openPage by remember { mutableStateOf<RadarPage?>(null) }
val currentPage = openPage
if (currentPage == null) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
shape = MaterialTheme.shapes.small,
modifier = modifier
.fillMaxWidth()
.height(COMPACT_TAB_STRIP_HEIGHT)
) {
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
pages.forEach { page ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clickable { openPage = page }
) {
Text(
text = page.title,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
} else {
PagerCard(
uiState = uiState,
onAction = onAction,
logViewModel = logViewModel,
requestMicPermission = requestMicPermission,
modifier = modifier
.fillMaxWidth()
.height(panelHeight),
startPage = currentPage,
onCollapse = { openPage = null }
)
}
}
/** Page list of the pager card, shared by the inline pager and the compact overlay panel. */
@Composable
private fun rememberRadarPages(uiState: RadarState): List<RadarPage> {
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
return remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
}
@@ -213,20 +451,15 @@ private fun PagerCard(
onAction: (RadarAction) -> Unit,
logViewModel: LogViewModel,
requestMicPermission: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
startPage: RadarPage? = null,
onCollapse: (() -> Unit)? = null
) {
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
}
val pages = remember(hasCalculatorPage) {
buildList {
add(RadarPage.Transceivers)
if (hasCalculatorPage) add(RadarPage.Calculator)
add(RadarPage.Log)
add(RadarPage.Sstv)
}
}
val pagerState = rememberPagerState(pageCount = { pages.size })
val pages = rememberRadarPages(uiState)
val pagerState = rememberPagerState(
initialPage = startPage?.let { pages.indexOf(it) }?.coerceAtLeast(0) ?: 0,
pageCount = { pages.size }
)
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(pages.size) {
@@ -237,12 +470,26 @@ private fun PagerCard(
ElevatedCard(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
pages.forEachIndexed { index, page ->
Tab(
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
Row(verticalAlignment = Alignment.CenterVertically) {
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
modifier = if (onCollapse != null) Modifier.weight(1f) else Modifier
) {
pages.forEachIndexed { index, page ->
Tab(
selected = selectedTabIndex == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
}
onCollapse?.let { collapse ->
Text(
text = "▼",
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 12.dp)
.clickable { collapse() }
)
}
}
@@ -287,7 +534,8 @@ private fun RadarCard(
uiState: RadarState,
trackB: List<OrbitalPos> = emptyList(),
trackBPosition: OrbitalPos? = null,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
overlayStrip: Boolean = false
) {
val satellitePos = uiState.orbitalPos
val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed
@@ -330,19 +578,25 @@ private fun RadarCard(
sunPosition = uiState.sunPosition,
moonPosition = uiState.moonPosition,
)
PositionOverlay(position)
PositionOverlay(position, overlayStrip)
}
}
}
}
@Composable
private fun PositionOverlay(position: OrbitalPos) {
private fun PositionOverlay(position: OrbitalPos, overlayStrip: Boolean = false) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 6.dp, vertical = 4.dp)
// Keep the bottom labels clear of the overlaid pager tab strip in small windows.
.padding(
start = 6.dp,
end = 6.dp,
top = 4.dp,
bottom = if (overlayStrip) COMPACT_TAB_STRIP_HEIGHT + 4.dp else 4.dp
)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
@@ -0,0 +1,114 @@
/*
* 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.feature.radar
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.min
/**
* Small-window radar sizing.
*
* The plotted circle is `0.95 x min(cardWidth, cardHeight)`, so a radar card that is wider than
* it is tall caps the circle by its height and shows empty bands on the sides. Full screen the
* classic 1:1 split leaves the radar card ~0.88 x cardWidth tall, i.e. the circle spans ~84% of
* the page width. These tests pin the split-screen / small-window layout to the same width share
* instead of letting the circle shrink with the window height.
*/
class RadarFillLayoutTest {
/** Circle diameter as a share of the page width (radar canvas rule: 0.95 * min(width, height)). */
private fun circleShare(width: Dp, height: Dp, sizes: RadarFillSizes): Float {
val availableHeight = if (sizes.pagerOverlaid) height else sizes.radarSide
return 0.95f * min(width.value, availableHeight.value) / width.value
}
@Test
fun fullScreenVerticalKeepsClassicSplit() {
// 411 x 867dp full screen: the 1:1 split already gives the circle ~87% of the width,
// so the full-screen layout must not change.
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 411.dp, maxHeight = 867.dp))
assertFalse(useFillRadarLayout(isVertical = true, maxWidth = 393.dp, maxHeight = 804.dp))
}
@Test
fun splitScreenAndSmallWindowsUseFillLayout() {
assertTrue(useFillRadarLayout(true, 411.dp, 426.dp)) // portrait split-screen half
assertTrue(useFillRadarLayout(true, 411.dp, 500.dp)) // floating small window
assertTrue(useFillRadarLayout(true, 411.dp, 676.dp)) // 70/30 split
assertTrue(useFillRadarLayout(true, 393.dp, 560.dp))
}
@Test
fun wideWindowsKeepSideBySideLayout() {
assertFalse(useFillRadarLayout(isVertical = false, maxWidth = 700.dp, maxHeight = 400.dp))
}
@Test
fun fillLayoutKeepsCircleAtFullScreenWidthShare() {
// Previously these rendered the circle at ~36% of the page width.
val cases = listOf(
411.dp to 426.dp,
411.dp to 500.dp,
393.dp to 560.dp,
411.dp to 676.dp
)
cases.forEach { (width, height) ->
val share = circleShare(width, height, radarFillSizes(width, height))
assertTrue("circle is only ${share * 100}% of the page width at ${width.value}x${height.value}",
share >= 0.84f)
}
}
@Test
fun classicSplitWouldHaveSqueezedTheCircle() {
// Regression guard documenting why the fill layout exists: the old compact branch left
// a 411 x 156dp card, i.e. a circle at 36% of the page width, in the same split window.
val classicCompactCardHeight = 426.dp - 96.dp - 132.dp - 18.dp
val classicCompactShare = 0.95f * classicCompactCardHeight.value / 411f
assertTrue(classicCompactShare < 0.45f)
}
@Test
fun pagerIsOverlaidOnlyWhenAReservedBlockWouldBeUseless() {
val shortWindow = radarFillSizes(411.dp, 426.dp)
assertTrue(shortWindow.pagerOverlaid)
assertTrue(shortWindow.radarSide == 411.dp)
val tallWindow = radarFillSizes(411.dp, 676.dp)
assertFalse(tallWindow.pagerOverlaid)
assertEquals(411f, tallWindow.radarSide.value, 0.01f)
assertEquals(259f, tallWindow.pagerSpace.value, 0.01f)
}
@Test
fun pagerBlockIsKeptWhileTheCircleStaysAtTheFullScreenShare() {
// 411 x 468dp: reserving 96dp for the pager still leaves the circle at ~85% of the page
// width, so the pager stays as a real block instead of folding into the overlay strip.
val sizes = radarFillSizes(411.dp, 468.dp)
assertFalse(sizes.pagerOverlaid)
assertEquals(366f, sizes.radarSide.value, 0.01f)
assertEquals(96f, sizes.pagerSpace.value, 0.01f)
val share = circleShare(411.dp, 468.dp, sizes)
assertTrue("circle is only ${share * 100}% of the page width", share >= 0.84f)
}
}
@@ -145,6 +145,14 @@ private fun LogbookUploadPreviewDialog(
"${preview.count} QSO(s) · ${preview.firstUtc} – ${preview.lastUtc}",
fontSize = 13.sp
)
if (preview.skipped > 0 || preview.unknownSkipped > 0 || preview.unavailableSkipped > 0) {
val parts = buildList {
if (preview.skipped > 0) add("${preview.skipped} already uploaded/duplicate")
if (preview.unknownSkipped > 0) add("${preview.unknownSkipped} unknown result")
if (preview.unavailableSkipped > 0) add("${preview.unavailableSkipped} un-uploadable")
}
Text(parts.joinToString(" · "), fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
Text(preview.contacts.joinToString("\n") { it }, fontSize = 12.sp, maxLines = 8)
}
},
@@ -163,6 +163,10 @@ class SettingsViewModel(
_uiState.update { it.copy(logbookRecords = records) }
}
}
// Load the LoTW upload certificate + station once at startup so the
// settings card reflects the real state on first frame (previously it
// stayed "not imported" until the config dialog was opened).
loadLoTWUploadStatus()
}
@@ -363,11 +367,18 @@ class SettingsViewModel(
val pending = all.filter { !it.lotwConfirmed && it.status == com.rtbishop.look4sat.core.domain.logbook.QsoStatus.COMPLETE }
val audit = lotwUploadRepository.audit(pending)
if (audit.pending == 0) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "No pending QSOs to upload") }
val msg = when {
audit.unavailable > 0 -> "${audit.unavailable} QSO(s) can't be uploaded (invalid call/date — check the logbook)"
audit.unknown > 0 -> "${audit.unknown} QSO(s) had an unknown upload result — not retried automatically"
else -> "No pending QSOs to upload"
}
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = msg) }
return@launch
}
val preview = lotwUploadRepository.prepare(pending, false)
lastLogbookUploadIds = pending.map { it.id }
// Only the records that actually made it into the TQ8 may be
// marked uploaded later — never the whole candidate list.
lastLogbookUploadIds = preview.submittedIds
_uiState.update { it.copy(logbookUploadBusy = false, logbookPreview = preview) }
} catch (e: com.rtbishop.look4sat.core.domain.repository.LoTWOperationException) {
_uiState.update { it.copy(logbookUploadBusy = false, logbookUploadMessage = "Upload unavailable: ${e.reason}") }
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "526"
appVersionCode = "531"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.7-ba7opf.17.3"
appVersionName = "4.4.7-ba7opf.17.8"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry