diff --git a/feature/radar/build.gradle.kts b/feature/radar/build.gradle.kts index dcec5fd3..00ad0e59 100644 --- a/feature/radar/build.gradle.kts +++ b/feature/radar/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { implementation(project(":feature:mutual")) implementation(project(":feature:cw")) implementation("androidx.constraintlayout:constraintlayout:2.2.1") -} \ No newline at end of file + testImplementation(libs.test.junit4) + testImplementation(libs.test.coroutines) +} diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index 3e0f134c..484a1fcf 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -28,6 +28,7 @@ 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 @@ -35,6 +36,7 @@ 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 @@ -45,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 @@ -88,8 +95,84 @@ private enum class RadarPage(val title: String) { Sstv("SSTV") } -/** Compact pager strip height in split-screen / small windows. */ -private val COMPACT_PAGER_HEIGHT = 132.dp +/** + * 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) { @@ -187,16 +270,21 @@ private fun RadarScreen( // 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 - // In split-screen / small windows the vertical space is scarce: let the - // radar card fill ALL remaining space so the square plot is as large as - // possible, and give the pager a compact fixed strip — its pages are all - // scrollable, so nothing gets clipped. Full-screen layouts keep the 1:1 split. - val compact = isVertical && maxHeight < 480.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) @@ -212,9 +300,16 @@ private fun RadarScreen( } } if (isVertical) { - if (compact) { - RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) - PagerCard(uiState, onAction, logViewModel, requestMicPermission, Modifier.height(COMPACT_PAGER_HEIGHT)) + 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)) @@ -229,18 +324,118 @@ private fun RadarScreen( } } +/** + * 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 PagerCard( +private fun RadarFillArea( uiState: RadarState, + trackB: List, + 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(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 { val hasCalculatorPage = remember(uiState.transceivers.transmitters) { uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder) } - val pages = remember(hasCalculatorPage) { + return remember(hasCalculatorPage) { buildList { add(RadarPage.Transceivers) if (hasCalculatorPage) add(RadarPage.Calculator) @@ -248,7 +443,23 @@ private fun PagerCard( add(RadarPage.Sstv) } } - val pagerState = rememberPagerState(pageCount = { pages.size }) +} + +@Composable +private fun PagerCard( + uiState: RadarState, + onAction: (RadarAction) -> Unit, + logViewModel: LogViewModel, + requestMicPermission: () -> Unit, + modifier: Modifier = Modifier, + startPage: RadarPage? = null, + onCollapse: (() -> Unit)? = null +) { + 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) { @@ -259,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() } ) } } @@ -309,7 +534,8 @@ private fun RadarCard( uiState: RadarState, trackB: List = emptyList(), trackBPosition: OrbitalPos? = null, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + overlayStrip: Boolean = false ) { val satellitePos = uiState.orbitalPos val shouldAnimateBorder = satellitePos?.aboveHorizon == true && satellitePos.eclipsed @@ -352,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, diff --git a/feature/radar/src/test/java/com/rtbishop/look4sat/feature/radar/RadarFillLayoutTest.kt b/feature/radar/src/test/java/com/rtbishop/look4sat/feature/radar/RadarFillLayoutTest.kt new file mode 100644 index 00000000..df06924f --- /dev/null +++ b/feature/radar/src/test/java/com/rtbishop/look4sat/feature/radar/RadarFillLayoutTest.kt @@ -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 . + */ +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) + } +}