mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1871c05002 | ||
|
|
e9f58f76f6 | ||
|
|
800494b978 | ||
|
|
f1091acbbd | ||
|
|
99bf3d34a9 | ||
|
|
1dd185971f | ||
|
|
fe85703e56 | ||
|
|
caddba5b81 |
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)
|
||||
|
||||
+14
-4
@@ -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,
|
||||
|
||||
+6
-1
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+114
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
+13
-2
@@ -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}") }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user