Skip to content

Commit

Permalink
Update: Type safe compose navigation (#37)
Browse files Browse the repository at this point in the history
* create compose navigation convention plugin

* wire in the compose navigation convention plugin in presentation module + type safe nav

* code cleanup

* updated readme
  • Loading branch information
nisrulz authored Jun 22, 2024
1 parent 91419c8 commit c4a9853
Show file tree
Hide file tree
Showing 11 changed files with 87 additions and 148 deletions.
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ An Offline first Android app to consume the SpaceX Backend API [`https://github.
### Navigation

- [Jetpack Compose Navigation](https://developer.android.com/jetpack/compose/navigation)
- [Type Safe Navigation](https://github.com/nisrulz/android-spacex-app/pull/37)

### Networking

Expand Down
6 changes: 6 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,11 @@ gradlePlugin {
implementationClass =
"com.nisrulz.example.spacexapi.AndroidComposeConventionPlugin"
}

register("androidComposeNav") {
id = "spacexapi.android.compose.navigation"
implementationClass =
"com.nisrulz.example.spacexapi.AndroidComposeNavigationConventionPlugin"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.nisrulz.example.spacexapi

import com.nisrulz.example.spacexapi.ktx.configureAndroidComposeNavigation
import org.gradle.api.Plugin
import org.gradle.api.Project

class AndroidComposeNavigationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.plugin.serialization")
}

configureAndroidComposeNavigation()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ internal fun Project.configureAndroidCompose() = configure<LibraryExtension> {
enableStrongSkippingMode = true
}
}

/**
* Configure Compose Navigation options
*/
internal fun Project.configureAndroidComposeNavigation() = configure<LibraryExtension> {

dependencies {
add("implementation", catalogLibrary("navigation-compose"))
add("implementation", catalogLibrary("kotlinx-serialization-json"))
}
}
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ compose-bom = "2024.06.00"
#endregion

# Navigation
navigationCompose = "2.7.7"
navigationCompose = "2.8.0-beta03"

#region ---- Hilt
hilt = "2.51.1"
Expand Down Expand Up @@ -161,6 +161,7 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
spacexapi-android-application = { id = "spacexapi.android.application", version = "unspecified" }
spacexapi-android-library = { id = "spacexapi.android.library", version = "unspecified" }
spacexapi-android-compose = { id = "spacexapi.android.compose", version = "unspecified" }
spacexapi-android-compose-navigation = { id = "spacexapi.android.compose.navigation", version = "unspecified" }
spacexapi-android-testing = { id = "spacexapi.android.testing", version = "unspecified" }
spacexapi-android-app-hilt = { id = "spacexapi.android.app.hilt", version = "unspecified" }
spacexapi-android-lib-hilt = { id = "spacexapi.android.lib.hilt", version = "unspecified" }
Expand Down
1 change: 1 addition & 0 deletions presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.spacexapi.android.testing)

alias(libs.plugins.spacexapi.android.compose)
alias(libs.plugins.spacexapi.android.compose.navigation)
}
android {
namespace = "${ApplicationInfo.BASE_NAMESPACE}.presentation"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
@Composable
fun BookmarkedLaunchesScreen(
viewModel: BookmarkedLaunchesViewModel = hiltViewModel(),
navigateBack: EmptyCallback = {},
onBackAction: EmptyCallback = {},
navigateToDetails: SingleValueCallback<String> = {}
) {
val snackbarHostState = remember { SnackbarHostState() }
Expand All @@ -35,7 +35,7 @@ fun BookmarkedLaunchesScreen(
// uncompleted processing.
eventFlow.receiveAsFlow().collectLatest { event ->
when (event) {
BookmarkedLaunchesViewModel.UiEvent.NavigateBack -> navigateBack()
BookmarkedLaunchesViewModel.UiEvent.NavigateBack -> onBackAction()
is BookmarkedLaunchesViewModel.UiEvent.NavigateToDetails -> {
navigateToDetails(event.launchId)
}
Expand Down Expand Up @@ -63,8 +63,7 @@ fun BookmarkedLaunchesScreen(
viewModel.navigateBack()
}
} else {
BookmarkedLaunchesListComponent(
state = state,
BookmarkedLaunchesListComponent(state = state,
snackbarHostState = snackbarHostState,
navigateToDetails = {
viewModel.navigateToDetails(it)
Expand All @@ -74,8 +73,7 @@ fun BookmarkedLaunchesScreen(
},
navigateBack = {
viewModel.navigateBack()
}
)
})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,12 @@ import com.nisrulz.example.spacexapi.analytics.InUseAnalytics
import com.nisrulz.example.spacexapi.analytics.trackNavigateToDetail
import com.nisrulz.example.spacexapi.analytics.trackScreenListOfLaunches
import com.nisrulz.example.spacexapi.domain.model.LaunchInfo
import com.nisrulz.example.spacexapi.domain.usecase.GetAllBookmarkedLaunches
import com.nisrulz.example.spacexapi.domain.usecase.GetAllLaunches
import com.nisrulz.example.spacexapi.domain.usecase.ToggleBookmarkLaunchInfo
import com.nisrulz.example.spacexapi.logger.InUseLoggers
import com.nisrulz.example.spacexapi.presentation.features.listoflaunches.ListOfLaunchesViewModel.UiEvent.NavigateToDetails
import com.nisrulz.example.spacexapi.presentation.features.listoflaunches.ListOfLaunchesViewModel.UiEvent.ShowSnackBar
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -23,15 +21,14 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class ListOfLaunchesViewModel
@Inject
constructor(
@Inject constructor(
private val coroutineDispatcher: CoroutineDispatcher,
private val getAllLaunches: GetAllLaunches,
private val bookmarkLaunchInfo: ToggleBookmarkLaunchInfo,
private val getAllBookmarkedLaunches: GetAllBookmarkedLaunches,
private val logger: InUseLoggers,
private val analytics: InUseAnalytics
) : ViewModel() {
Expand All @@ -50,8 +47,7 @@ constructor(

@VisibleForTesting
fun getListOfLaunches() = viewModelScope.launch(coroutineDispatcher) {
getAllLaunches()
.onEach {
getAllLaunches().onEach {
handleListOfLaunches(it)
}.catch {
setError(it.message ?: "Error")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,45 @@ package com.nisrulz.example.spacexapi.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.bookmarkScreen
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.detailsScreen
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.homeScreen
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.navigateBack
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.navigateToBookmarks
import com.nisrulz.example.spacexapi.presentation.navigation.NavigationRoute.navigateToLaunchDetail
import androidx.navigation.toRoute
import com.nisrulz.example.spacexapi.presentation.features.bookmarkedlaunches.BookmarkedLaunchesScreen
import com.nisrulz.example.spacexapi.presentation.features.launchdetail.LaunchDetailScreen
import com.nisrulz.example.spacexapi.presentation.features.listoflaunches.ListOfLaunchesScreen

@Composable
fun AppNavigation() {
val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = NavigationRoute.HOME_ROUTE
navController = navController, startDestination = RouteHome
) {
homeScreen(
onNavigateToDetails = { launchId ->
navController.navigateToLaunchDetail(launchId)
},
onNavigateToBookmarks = {
navController.navigateToBookmarks()
}
)

bookmarkScreen(
onNavigateToDetails = { launchId ->
navController.navigateToLaunchDetail(launchId)
},
onBackAction = {
navController.navigateBack()
}
)

detailsScreen(
onBackAction = {
navController.navigateBack()
}
)
composable<RouteHome> {
ListOfLaunchesScreen(navigateToDetails = { launchId ->
navController.navigate(RouteDetails(launchId))
}, navigateToBookmarks = {
navController.navigate(RouteBookmark)
})

}

composable<RouteBookmark> {
BookmarkedLaunchesScreen(navigateToDetails = { launchId ->
navController.navigate(RouteDetails(launchId))
}, onBackAction = {
navController.navigate(RouteHome)
})

}

composable<RouteDetails> { backStackEntry ->
val id = backStackEntry.toRoute<RouteDetails>().launchId

LaunchDetailScreen(launchId = id, onBackAction = {
navController.navigate(RouteHome)
})

}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,104 +1,13 @@
package com.nisrulz.example.spacexapi.presentation.navigation

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.nisrulz.example.spacexapi.presentation.features.bookmarkedlaunches.BookmarkedLaunchesScreen
import com.nisrulz.example.spacexapi.presentation.features.launchdetail.LaunchDetailScreen
import com.nisrulz.example.spacexapi.presentation.features.listoflaunches.ListOfLaunchesScreen
import kotlinx.serialization.Serializable

/*
Read about Type safety in Navigation Compose:
https://developer.android.com/guide/navigation/design/type-safety
*/
internal object NavigationRoute {
// Home
const val HOME_ROUTE = "list_of_launches"

// Bookmark
private const val BOOKMARK_ROUTE = "bookmarked_launches"
@Serializable
object RouteHome

// Details
private const val NAV_ARG_LAUNCH_ID = "launchId"
private const val DETAILS_ROUTE = "launch_detail/{$NAV_ARG_LAUNCH_ID}"
@Serializable
object RouteBookmark

private fun buildDetailsRouteWithLaunchId(launchId: String) = DETAILS_ROUTE
.replace("{$NAV_ARG_LAUNCH_ID}", launchId)

// Functions
private fun NavBackStackEntry.getArgLaunchId(): String = arguments
?.getString(NAV_ARG_LAUNCH_ID) ?: ""

fun NavGraphBuilder.homeScreen(
onNavigateToDetails: (launchId: String) -> Unit,
onNavigateToBookmarks: () -> Unit
) {
composable(
HOME_ROUTE
) {
ListOfLaunchesScreen(navigateToDetails = { launchId ->
onNavigateToDetails(launchId)
}, navigateToBookmarks = {
onNavigateToBookmarks()
})
}
}

fun NavGraphBuilder.bookmarkScreen(
onNavigateToDetails: (launchId: String) -> Unit,
onBackAction: () -> Unit
) {
composable(
BOOKMARK_ROUTE
) {
BookmarkedLaunchesScreen(
navigateToDetails = { launchId ->
onNavigateToDetails(launchId)
},
navigateBack = onBackAction
)
}
}

fun NavGraphBuilder.detailsScreen(onBackAction: () -> Unit) {
composable(
DETAILS_ROUTE
) { backStackEntry ->
val id = backStackEntry.getArgLaunchId()
if (id.isNotEmpty()) {
LaunchDetailScreen(launchId = id, onBackAction = onBackAction)
}
}
}

fun NavController.navigateToBookmarks() {
this.navigate(BOOKMARK_ROUTE)
}

fun NavController.navigateToLaunchDetail(launchId: String) {
this.navigate(buildDetailsRouteWithLaunchId(launchId))
}

fun NavController.navigateBack() {
this.popBackStack()
}

private fun customFadeIn() = fadeIn(
animationSpec = tween(
300,
easing = LinearEasing
)
)

private fun customFadeOut() = fadeOut(
animationSpec = tween(
300,
easing = LinearEasing
)
)
}
@Serializable
data class RouteDetails(val launchId: String)
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.nisrulz.example.spacexapi.analytics.InUseAnalytics
import com.nisrulz.example.spacexapi.analytics.contract.AnalyticsEvent
import com.nisrulz.example.spacexapi.domain.usecase.GetAllBookmarkedLaunches
import com.nisrulz.example.spacexapi.domain.usecase.GetAllLaunches
import com.nisrulz.example.spacexapi.domain.usecase.ToggleBookmarkLaunchInfo
import com.nisrulz.example.spacexapi.logger.InUseLoggers
Expand All @@ -29,15 +28,13 @@ class ListOfLaunchesViewModelTest {
private lateinit var sut: ListOfLaunchesViewModel
private lateinit var getAllLaunches: GetAllLaunches
private lateinit var bookmarkLaunchInfo: ToggleBookmarkLaunchInfo
private lateinit var getAllBookmarkedLaunches: GetAllBookmarkedLaunches
private lateinit var logger: InUseLoggers
private lateinit var analytics: InUseAnalytics

@Before
fun setup() {
getAllLaunches = mockk()
bookmarkLaunchInfo = mockk()
getAllBookmarkedLaunches = mockk()

logger = mockk {
every { log(any<String>()) } just runs
Expand All @@ -50,7 +47,6 @@ class ListOfLaunchesViewModelTest {
coroutineDispatcher = testDispatcher,
getAllLaunches = getAllLaunches,
bookmarkLaunchInfo = bookmarkLaunchInfo,
getAllBookmarkedLaunches = getAllBookmarkedLaunches,
logger = logger,
analytics = analytics
)
Expand Down

0 comments on commit c4a9853

Please sign in to comment.