Skip to content

Commit

Permalink
[Cherry Pick] Use one NavHost (with a single navigation scaffold) (#102)
Browse files Browse the repository at this point in the history
* Deduplicate lifecycle runtime compose

* kotlin 2.0 and compose gradle plugin

* update test & other versions

* Call navigate instead of recomposing HavHost

Previously, the app had a NavHost but didn't actually call navigate.
NavHost's startDestination was read from external state (the currently
selected nav item), which led to some undesirable behaviors.
- NavHost itself was recomposed when selecting a nav item.
- Pressing back always exited the app because you were always at the
  start destination.

Now the NavHost has a stable startDestination, and nav item state is
driven by the NavHostController's current destination. Selecting a nav
item calls navigate, and the user always goes back through the
startDestination before exiting the app.

* Use type-safe navigation

* Make a package for navigation routes

* Migrate to type safe routes for main navhost

* Push Scaffold down to each top-level destination

* Extract nav suite scaffold to another composable

* Move ChatList, move and rename HomeViewModel

* Move Settings to its own package

* Remove Home and put its destinations in Main NavHost

* Remove Home composable and rename file

* Add NavigationSuiteScaffold around NavHost

* Change navigation layout type based on current destination
  • Loading branch information
jdkoren authored and ashnohe committed Dec 9, 2024
1 parent 1b4b758 commit 2c43e3e
Show file tree
Hide file tree
Showing 17 changed files with 529 additions and 459 deletions.
6 changes: 3 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ksp)
alias(libs.plugins.secrets)
}
Expand Down Expand Up @@ -63,9 +65,6 @@ android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
Expand Down Expand Up @@ -107,6 +106,7 @@ dependencies {

implementation(libs.activity.compose)
implementation(libs.navigation.compose)
implementation(libs.kotlinx.serialization.json)

implementation(libs.accompanist.painter)
implementation(libs.accompanist.permissions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.google.android.samples.socialite.awaitNotEmpty
import com.google.android.samples.socialite.repository.createTestRepository
import com.google.android.samples.socialite.ui.home.chatlist.ChatListViewModel
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class HomeViewModelTest {
class ChatListViewModelTest {

@Test
fun initialize() = runTest {
val viewModel = HomeViewModel(createTestRepository())
val viewModel = ChatListViewModel(createTestRepository())
viewModel.chats.test {
assertThat(awaitNotEmpty()).hasSize(4)
}
Expand Down
239 changes: 109 additions & 130 deletions app/src/main/java/com/google/android/samples/socialite/ui/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
Expand All @@ -34,18 +29,22 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavType
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute
import com.google.android.samples.socialite.model.extractChatId
import com.google.android.samples.socialite.ui.camera.Camera
import com.google.android.samples.socialite.ui.camera.Media
import com.google.android.samples.socialite.ui.camera.MediaType
import com.google.android.samples.socialite.ui.chat.ChatScreen
import com.google.android.samples.socialite.ui.home.Home
import com.google.android.samples.socialite.ui.home.chatlist.ChatList
import com.google.android.samples.socialite.ui.home.settings.Settings
import com.google.android.samples.socialite.ui.home.timeline.Timeline
import com.google.android.samples.socialite.ui.navigation.Route
import com.google.android.samples.socialite.ui.navigation.SocialiteNavSuite
import com.google.android.samples.socialite.ui.photopicker.navigation.navigateToPhotoPicker
import com.google.android.samples.socialite.ui.photopicker.navigation.photoPickerScreen
import com.google.android.samples.socialite.ui.player.VideoPlayerScreen
Expand All @@ -69,156 +68,136 @@ fun MainNavigation(
val activity = LocalContext.current as Activity
val navController = rememberNavController()

navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, _: Bundle? ->
navController.addOnDestinationChangedListener { _: NavController, destination: NavDestination, _: Bundle? ->
// Lock the layout of the Camera screen to portrait so that the UI layout remains
// constant, even on orientation changes. Note that the camera is still aware of
// orientation, and will assign the correct edge as the bottom of the photo or video.
if (navDestination.route?.contains("camera") == true) {
if (destination.hasRoute<Route.Camera>()) {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
} else {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}

NavHost(
SocialiteNavSuite(
navController = navController,
startDestination = "home",
popExitTransition = {
scaleOut(
targetScale = 0.9f,
transformOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 0.5f),
)
},
popEnterTransition = {
EnterTransition.None
},
modifier = modifier,
) {
composable(
route = "home",
NavHost(
navController = navController,
startDestination = Route.ChatsList,
popExitTransition = {
scaleOut(
targetScale = 0.9f,
transformOrigin = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 0.5f),
)
},
popEnterTransition = {
EnterTransition.None
}
) {
Home(
modifier = Modifier.fillMaxSize(),
onChatClicked = { chatId -> navController.navigate("chat/$chatId") },
)
}
composable(
route = "chat/{chatId}?text={text}",
arguments = listOf(
navArgument("chatId") { type = NavType.LongType },
navArgument("text") { defaultValue = "" },
),
deepLinks = listOf(
navDeepLink {
action = Intent.ACTION_VIEW
uriPattern = "https://socialite.google.com/chat/{chatId}"
},
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
val text = backStackEntry.arguments?.getString("text")
ChatScreen(
chatId = chatId,
foreground = true,
onBackPressed = { navController.popBackStack() },
onCameraClick = { navController.navigate("chat/$chatId/camera") },
onPhotoPickerClick = { navController.navigateToPhotoPicker(chatId) },
onVideoClick = { uri -> navController.navigate("videoPlayer?uri=$uri") },
prefilledText = text,
modifier = Modifier.fillMaxSize(),
)
}
composable(
route = "chat/{chatId}/camera",
arguments = listOf(
navArgument("chatId") { type = NavType.LongType },
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
Camera(
onMediaCaptured = { capturedMedia: Media? ->
when (capturedMedia?.mediaType) {
MediaType.PHOTO -> {
navController.popBackStack()
}
composable<Route.ChatsList> {
ChatList(
onChatClicked = { chatId -> navController.navigate(Route.ChatThread(chatId)) },
modifier = Modifier.fillMaxSize(),
)
}

MediaType.VIDEO -> {
navController.navigate("videoEdit?uri=${capturedMedia.uri}&chatId=$chatId")
}
composable<Route.Timeline> {
Timeline(Modifier.fillMaxSize())
}

composable<Route.Settings> {
Settings(Modifier.fillMaxSize())
}

composable<Route.ChatThread>(
deepLinks = listOf(
navDeepLink {
action = Intent.ACTION_VIEW
uriPattern = "https://socialite.google.com/chat/{chatId}"
},
),
) { backStackEntry ->
val route: Route.ChatThread = backStackEntry.toRoute()
val chatId = route.chatId
ChatScreen(
chatId = chatId,
foreground = true,
onBackPressed = { navController.popBackStack() },
onCameraClick = { navController.navigate(Route.Camera(chatId)) },
onPhotoPickerClick = { navController.navigateToPhotoPicker(chatId) },
onVideoClick = { uri -> navController.navigate(Route.VideoPlayer(uri)) },
prefilledText = route.text,
modifier = Modifier.fillMaxSize(),
)
}

composable<Route.Camera> { backStackEntry ->
val route: Route.Camera = backStackEntry.toRoute()
val chatId = route.chatId
Camera(
onMediaCaptured = { capturedMedia: Media? ->
when (capturedMedia?.mediaType) {
MediaType.PHOTO -> {
navController.popBackStack()
}

else -> {
// No media to use.
navController.popBackStack()
MediaType.VIDEO -> {
navController.navigate(
Route.VideoEdit(
chatId,
capturedMedia.uri.toString(),
),
)
}

else -> {
// No media to use.
navController.popBackStack()
}
}
}
},
chatId = chatId,
modifier = Modifier.fillMaxSize(),
)
}
},
modifier = Modifier.fillMaxSize(),
)
}

// Invoke PhotoPicker to select photo or video from device gallery
photoPickerScreen(
onPhotoPicked = navController::popBackStack,
)

composable(
route = "videoEdit?uri={videoUri}&chatId={chatId}",
arguments = listOf(
navArgument("videoUri") { type = NavType.StringType },
navArgument("chatId") { type = NavType.LongType },
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
val videoUri = backStackEntry.arguments?.getString("videoUri") ?: ""
VideoEditScreen(
chatId = chatId,
uri = videoUri,
onCloseButtonClicked = { navController.popBackStack() },
navController = navController,
)
}
composable(
route = "videoPlayer?uri={videoUri}",
arguments = listOf(
navArgument("videoUri") { type = NavType.StringType },
),
) { backStackEntry ->
val videoUri = backStackEntry.arguments?.getString("videoUri") ?: ""
VideoPlayerScreen(
uri = videoUri,
onCloseButtonClicked = { navController.popBackStack() },
// Invoke PhotoPicker to select photo or video from device gallery
photoPickerScreen(
onPhotoPicked = navController::popBackStack,
)

composable<Route.VideoEdit> { backStackEntry ->
val route: Route.VideoEdit = backStackEntry.toRoute()
val chatId = route.chatId
val videoUri = route.uri
VideoEditScreen(
chatId = chatId,
uri = videoUri,
onCloseButtonClicked = { navController.popBackStack() },
navController = navController,
)
}

composable<Route.VideoPlayer> { backStackEntry ->
val route: Route.VideoPlayer = backStackEntry.toRoute()
val videoUri = route.uri
VideoPlayerScreen(
uri = videoUri,
onCloseButtonClicked = { navController.popBackStack() },
)
}
}
}

if (shortcutParams != null) {
val chatId = extractChatId(shortcutParams.shortcutId)
val text = shortcutParams.text
navController.navigate("chat/$chatId?text=$text")
navController.navigate(Route.ChatThread(chatId, text))
}
}

data class ShortcutParams(
val shortcutId: String,
val text: String,
)

object AnimationConstants {
private const val ENTER_MILLIS = 250
private const val EXIT_MILLIS = 250

val enterTransition = fadeIn(
animationSpec = tween(
durationMillis = ENTER_MILLIS,
easing = FastOutLinearInEasing,
),
)

val exitTransition = fadeOut(
animationSpec = tween(
durationMillis = EXIT_MILLIS,
easing = FastOutSlowInEasing,
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import kotlinx.coroutines.asExecutor
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Camera(
chatId: Long,
onMediaCaptured: (Media?) -> Unit,
modifier: Modifier = Modifier,
viewModel: CameraViewModel = hiltViewModel(),
Expand All @@ -86,8 +85,6 @@ fun Camera(
),
)

viewModel.setChatId(chatId)

val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current

Expand All @@ -97,8 +94,8 @@ fun Camera(
val windowInfoTracker = WindowInfoTracker.getOrCreate(context)
windowInfoTracker.windowLayoutInfo(context).collect { newLayoutInfo ->
try {
val foldingFeature = newLayoutInfo?.displayFeatures
?.firstOrNull { it is FoldingFeature } as FoldingFeature
val foldingFeature = newLayoutInfo.displayFeatures
.filterIsInstance<FoldingFeature>().firstOrNull()
isLayoutUnfolded = (foldingFeature != null)
} catch (e: Exception) {
// If there was an issue detecting a foldable in the open position, default
Expand Down
Loading

0 comments on commit 2c43e3e

Please sign in to comment.