Skip to content

Commit 7d5bca3

Browse files
committed
Migrated to Compose Navigation3, updated dependencies
1 parent fb2cd85 commit 7d5bca3

14 files changed

Lines changed: 252 additions & 220 deletions

File tree

app/src/main/java/com/rtbishop/look4sat/MainScreen.kt

Lines changed: 121 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,17 @@ import androidx.compose.animation.core.rememberInfiniteTransition
2525
import androidx.compose.animation.core.tween
2626
import androidx.compose.animation.fadeIn
2727
import androidx.compose.animation.fadeOut
28+
import androidx.compose.animation.togetherWith
2829
import androidx.compose.foundation.background
2930
import androidx.compose.foundation.clickable
3031
import androidx.compose.foundation.layout.Box
3132
import androidx.compose.foundation.layout.Column
3233
import androidx.compose.foundation.layout.Row
3334
import androidx.compose.foundation.layout.Spacer
34-
import androidx.compose.foundation.layout.WindowInsets
3535
import androidx.compose.foundation.layout.fillMaxWidth
3636
import androidx.compose.foundation.layout.padding
3737
import androidx.compose.foundation.layout.size
38-
import androidx.compose.foundation.layout.statusBars
3938
import androidx.compose.foundation.layout.width
40-
import androidx.compose.foundation.layout.windowInsetsPadding
4139
import androidx.compose.foundation.shape.CircleShape
4240
import androidx.compose.material3.Icon
4341
import androidx.compose.material3.MaterialTheme
@@ -58,128 +56,152 @@ import androidx.compose.ui.text.font.FontWeight
5856
import androidx.compose.ui.unit.dp
5957
import androidx.compose.ui.unit.sp
6058
import androidx.lifecycle.compose.collectAsStateWithLifecycle
61-
import androidx.navigation.NavHostController
62-
import androidx.navigation.compose.NavHost
63-
import androidx.navigation.compose.currentBackStackEntryAsState
64-
import androidx.navigation.compose.rememberNavController
59+
import androidx.navigation3.runtime.entryProvider
60+
import androidx.navigation3.runtime.rememberNavBackStack
61+
import androidx.navigation3.ui.NavDisplay
62+
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
6563
import com.rtbishop.look4sat.core.presentation.Screen
6664
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
6765
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
68-
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
69-
import com.rtbishop.look4sat.feature.map.mapDestination
70-
import com.rtbishop.look4sat.feature.passes.passesDestination
71-
import com.rtbishop.look4sat.feature.radar.radarDestination
72-
import com.rtbishop.look4sat.feature.radiocontrol.radioControlDestination
73-
import com.rtbishop.look4sat.feature.satellites.satellitesDestination
74-
import com.rtbishop.look4sat.feature.settings.settingsDestination
66+
import com.rtbishop.look4sat.feature.map.MapDestination
67+
import com.rtbishop.look4sat.feature.passes.PassesDestination
68+
import com.rtbishop.look4sat.feature.radar.RadarDestination
69+
import com.rtbishop.look4sat.feature.radiocontrol.RadioControlDestination
70+
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
71+
import com.rtbishop.look4sat.feature.settings.SettingsDestination
7572

7673
@Composable
77-
fun MainScreen(navController: NavHostController = rememberNavController()) {
78-
val items = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
79-
val currentDestination = navController.currentBackStackEntryAsState().value?.destination?.route
80-
val startDestination = Screen.Passes.route
74+
fun MainScreen() {
75+
val backStack = rememberNavBackStack(Screen.Passes)
76+
val currentKey = backStack.lastOrNull()
77+
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) }
78+
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
79+
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings)
8180

82-
// Observe radio tracking state for the status bar
8381
val context = LocalContext.current
8482
val container = (context.applicationContext as IContainerProvider).getMainContainer()
8583
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
8684

8785
NavigationSuiteScaffold(
8886
navigationSuiteItems = {
89-
items.forEach {
87+
navItems.forEach { screen ->
88+
val isSelected = when (currentKey) {
89+
is Screen.Satellites -> screen is Screen.Satellites
90+
is Screen.Passes -> screen is Screen.Passes
91+
is Screen.Radar -> screen is Screen.Radar
92+
is Screen.Map -> screen is Screen.Map
93+
is Screen.Settings -> screen is Screen.Settings
94+
else -> false
95+
}
9096
item(
91-
icon = { Icon(painterResource(it.iconResId), stringResource(it.titleResId)) },
92-
label = { Text(stringResource(it.titleResId)) },
93-
selected = currentDestination?.contains(it.route) ?: false,
97+
icon = { Icon(painterResource(screen.iconResId), stringResource(screen.titleResId)) },
98+
label = { Text(stringResource(screen.titleResId)) },
99+
selected = isSelected,
94100
onClick = {
95-
if (currentDestination?.contains(it.route) ?: false) return@item
96-
navController.navigate(it.route) {
97-
popUpTo(startDestination) { saveState = false }
98-
launchSingleTop = true
99-
restoreState = false
100-
}
101-
})
101+
if (isSelected) return@item
102+
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
103+
if (screen !is Screen.Passes) backStack.add(screen)
104+
}
105+
)
102106
}
103-
}, navigationSuiteColors = NavigationSuiteDefaults.colors(
107+
},
108+
navigationSuiteColors = NavigationSuiteDefaults.colors(
104109
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
105-
), layoutType = when {
110+
),
111+
layoutType = when {
106112
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
107113
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
108114
else -> NavigationSuiteType.ShortNavigationBarMedium
109115
}
110116
) {
111117
Column {
112-
NavHost(
113-
navController = navController,
114-
startDestination = startDestination,
115-
enterTransition = { fadeIn(animationSpec = tween(350)) },
116-
exitTransition = { fadeOut(animationSpec = tween(350)) },
117-
modifier = Modifier.weight(1f)
118-
) {
119-
satellitesDestination { navController.navigateUp() }
120-
passesDestination { catNum: Int, aosTime: Long ->
121-
val radarRoute = "${Screen.Radar.route}?catNum=${catNum}&aosTime=${aosTime}"
122-
navController.navigate(radarRoute)
123-
}
124-
radarDestination(
125-
navigateUp = { navController.navigateUp() },
126-
navigateToRadioControl = { catNum, aosTime ->
127-
val route = "${Screen.RadioControl.route}?catNum=$catNum&aosTime=$aosTime"
128-
navController.navigate(route)
129-
}
130-
)
131-
radioControlDestination { navController.navigateUp() }
132-
mapDestination()
133-
settingsDestination()
134-
}
135-
136-
// Radio tracking status banner (above bottom navigation)
137-
if (trackingState.isActive) {
138-
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
139-
val alpha by infiniteTransition.animateFloat(
140-
initialValue = 1f, targetValue = 0.4f,
141-
animationSpec = infiniteRepeatable(
142-
animation = tween(1000, easing = LinearEasing),
143-
repeatMode = RepeatMode.Reverse
144-
), label = "pulseAlpha"
145-
)
146-
Row(
147-
verticalAlignment = Alignment.CenterVertically,
148-
modifier = Modifier
149-
.fillMaxWidth()
150-
.background(MaterialTheme.colorScheme.primaryContainer)
151-
.clickable {
152-
val pass = trackingState.currentPass
153-
if (pass != null) {
154-
val route = "${Screen.RadioControl.route}?catNum=${pass.catNum}&aosTime=${pass.aosTime}"
155-
navController.navigate(route)
118+
NavDisplay(
119+
backStack = backStack,
120+
modifier = Modifier.weight(1f),
121+
onBack = navigateBack,
122+
transitionSpec = { fadeTransition },
123+
popTransitionSpec = { fadeTransition },
124+
predictivePopTransitionSpec = { fadeTransition },
125+
entryProvider = entryProvider {
126+
entry<Screen.Satellites> {
127+
SatellitesDestination(navigateUp = navigateBack)
128+
}
129+
entry<Screen.Passes> {
130+
PassesDestination { catNum, aosTime ->
131+
backStack.add(Screen.Radar(catNum, aosTime))
156132
}
157133
}
158-
.padding(horizontal = 12.dp, vertical = 6.dp)
159-
) {
160-
Box(
161-
modifier = Modifier
162-
.size(8.dp)
163-
.clip(CircleShape)
164-
.background(Color(0xFF4CAF50).copy(alpha = alpha))
165-
)
166-
Spacer(modifier = Modifier.width(8.dp))
167-
Text(
168-
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
169-
fontSize = 13.sp,
170-
fontWeight = FontWeight.Medium,
171-
color = MaterialTheme.colorScheme.onPrimaryContainer,
172-
modifier = Modifier.weight(1f)
173-
)
174-
val txOk = if (trackingState.txConnected) "TX" else ""
175-
val rxOk = if (trackingState.rxConnected) "RX" else ""
176-
Text(
177-
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
178-
fontSize = 12.sp,
179-
color = MaterialTheme.colorScheme.onPrimaryContainer
134+
entry<Screen.Radar> { route ->
135+
RadarDestination(
136+
catNum = route.catNum,
137+
aosTime = route.aosTime,
138+
navigateUp = navigateBack,
139+
navigateToRadioControl = { catNum, aosTime ->
140+
backStack.add(Screen.RadioControl(catNum, aosTime))
141+
}
142+
)
143+
}
144+
entry<Screen.RadioControl> { route ->
145+
RadioControlDestination(
146+
catNum = route.catNum,
147+
aosTime = route.aosTime,
148+
navigateUp = navigateBack
149+
)
150+
}
151+
entry<Screen.Map> {
152+
MapDestination()
153+
}
154+
entry<Screen.Settings> {
155+
SettingsDestination()
156+
}
157+
}
158+
)
159+
// Radio tracking status banner
160+
if (trackingState.isActive) {
161+
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
162+
val alpha by infiniteTransition.animateFloat(
163+
initialValue = 1f, targetValue = 0.4f,
164+
animationSpec = infiniteRepeatable(
165+
animation = tween(1000, easing = LinearEasing),
166+
repeatMode = RepeatMode.Reverse
167+
), label = "pulseAlpha"
180168
)
169+
Row(
170+
verticalAlignment = Alignment.CenterVertically,
171+
modifier = Modifier
172+
.fillMaxWidth()
173+
.background(MaterialTheme.colorScheme.primaryContainer)
174+
.clickable {
175+
val pass = trackingState.currentPass
176+
if (pass != null) {
177+
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime))
178+
}
179+
}
180+
.padding(horizontal = 12.dp, vertical = 6.dp)
181+
) {
182+
Box(
183+
modifier = Modifier
184+
.size(8.dp)
185+
.clip(CircleShape)
186+
.background(Color(0xFF4CAF50).copy(alpha = alpha))
187+
)
188+
Spacer(modifier = Modifier.width(8.dp))
189+
Text(
190+
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
191+
fontSize = 13.sp,
192+
fontWeight = FontWeight.Medium,
193+
color = MaterialTheme.colorScheme.onPrimaryContainer,
194+
modifier = Modifier.weight(1f)
195+
)
196+
val txOk = if (trackingState.txConnected) "TX" else ""
197+
val rxOk = if (trackingState.rxConnected) "RX" else ""
198+
Text(
199+
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
200+
fontSize = 12.sp,
201+
color = MaterialTheme.colorScheme.onPrimaryContainer
202+
)
203+
}
181204
}
182205
}
183-
} // end Column
184206
}
185207
}

build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/ApplicationPlugin.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ internal class ApplicationPlugin : Plugin<Project> {
3838
implementation(project(":feature:satellites"))
3939
implementation(project(":feature:settings"))
4040
implementation(libs.androidx.core.splashscreen)
41+
implementation(libs.compose.material3.adaptive)
42+
implementation(libs.compose.navigation3)
4143
androidTestImplementation(libs.bundles.androidTest)
4244
}
4345
}

build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/CorePresentationPlugin.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ import org.gradle.kotlin.dsl.dependencies
2424
@Suppress("Unused")
2525
internal class CorePresentationPlugin : Plugin<Project> {
2626
override fun apply(target: Project) = with(target) {
27+
applyPlugin(libs.plugins.kotlin.serialization)
2728
setupAndroidLib()
2829
setupCompose()
2930
setupKotlin()
3031
dependencies {
3132
implementation(project(":core:domain"))
3233
implementation(libs.androidx.core.splashscreen)
34+
implementation(libs.kotlin.serialization)
35+
implementation(libs.compose.material3.adaptive)
3336
}
3437
}
3538
}
Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
1+
/*
2+
* Look4Sat. Amateur radio satellite tracker and pass predictor.
3+
* Copyright (C) 2019-2026 Arty Bishop and contributors.
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
118
package com.rtbishop.look4sat.core.presentation
219

3-
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
4-
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
5-
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
6-
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
7-
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
8-
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
9-
data object RadioControl : Screen("radiocontrol", R.drawable.ic_radios, R.string.nav_radiocontrol)
20+
import androidx.navigation3.runtime.NavKey
21+
import kotlinx.serialization.Serializable
22+
23+
@Serializable
24+
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
25+
26+
@Serializable
27+
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
28+
29+
@Serializable
30+
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
31+
32+
@Serializable
33+
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar)
34+
35+
@Serializable
36+
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0)
37+
38+
@Serializable
39+
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
40+
41+
@Serializable
42+
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
1043
}

feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,12 @@ import androidx.lifecycle.LifecycleEventObserver
6060
import androidx.lifecycle.compose.LocalLifecycleOwner
6161
import androidx.lifecycle.compose.collectAsStateWithLifecycle
6262
import androidx.lifecycle.viewmodel.compose.viewModel
63-
import androidx.navigation.NavGraphBuilder
64-
import androidx.navigation.compose.composable
6563
import com.rtbishop.look4sat.core.domain.predict.GeoPos
6664
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
6765
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
6866
import com.rtbishop.look4sat.core.presentation.IconCard
6967
import com.rtbishop.look4sat.core.presentation.NextPassRow
7068
import com.rtbishop.look4sat.core.presentation.R
71-
import com.rtbishop.look4sat.core.presentation.Screen
7269
import com.rtbishop.look4sat.core.presentation.TimerRow
7370
import com.rtbishop.look4sat.core.presentation.TopBar
7471
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
@@ -110,13 +107,15 @@ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
110107
}
111108
private val iconCache = LruCache<String, Drawable>(128)
112109

113-
fun NavGraphBuilder.mapDestination() {
114-
composable(Screen.Map.route) {
115-
val viewModel = viewModel(MapViewModel::class.java, factory = MapViewModel.Factory)
116-
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
117-
val mapView = rememberMapViewWithLifecycle()
118-
MapScreen(uiState, viewModel::onAction, mapView)
119-
}
110+
@Composable
111+
fun MapDestination() {
112+
val viewModel = viewModel(
113+
modelClass = MapViewModel::class.java,
114+
factory = MapViewModel.Factory
115+
)
116+
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
117+
val mapView = rememberMapViewWithLifecycle()
118+
MapScreen(uiState, viewModel::onAction, mapView)
120119
}
121120

122121
@Composable

0 commit comments

Comments
 (0)