From 5fe4b89bbb61f757aecf9d4731dee272918361e3 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 28 Apr 2026 13:37:35 +0900 Subject: [PATCH 1/5] Migration to Navigation3 --- AdaptiveJetStream/benchmark/build.gradle.kts | 4 +- AdaptiveJetStream/gradle/libs.versions.toml | 22 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- AdaptiveJetStream/jetstream/build.gradle.kts | 8 +- .../jetstream/src/main/AndroidManifest.xml | 4 +- .../java/com/google/jetstream/MainActivity.kt | 2 - .../com/google/jetstream/presentation/App.kt | 367 ++++++++++--- .../app/AppLayoutSceneDecoratorStrategy.kt | 300 +++++++++++ .../jetstream/presentation/app/AppState.kt | 104 ---- .../jetstream/presentation/app/Callbacks.kt | 50 -- .../jetstream/presentation/app/Destination.kt | 181 +++++++ .../app/JetStreamAppNavigation.kt | 469 +++++++++++++++++ .../presentation/app/KeyboardShortcuts.kt | 79 --- .../app/NavigationComponentType.kt | 41 -- .../presentation/app/NavigationTree.kt | 140 ----- .../SearchButton.kt | 13 +- .../AppWithNavigationSuiteScaffold.kt | 142 ----- .../NavigationSuiteItems.kt | 82 --- .../withNavigationSuiteScaffold/TopAppBar.kt | 93 ---- .../AppWithSpatialNavigation.kt | 215 -------- .../AppWithTopBarNavigation.kt | 146 ------ .../app/withTopBarNavigation/TopBar.kt | 247 --------- .../presentation/components/MoviesRow.kt | 4 +- .../components/feature/EngagementMode.kt | 14 +- .../components/shim/ModifierKeys.kt | 6 + .../jetstream/presentation/screens/Screens.kt | 131 ----- .../CategoryMovieListScreenViewModel.kt | 37 +- .../RememberFilteredMovieGridColumns.kt | 49 +- .../presentation/screens/home/HomeScreen.kt | 39 +- .../MovieDetailsScreenViewModel.kt | 39 +- .../screens/profile/ProfileScreen.kt | 483 ++++-------------- .../screens/profile/compoents/AboutSection.kt | 122 ----- .../compoents/AccountsSelectionItem.kt | 153 ++---- .../compoents/HelpAndSupportSection.kt | 147 ------ .../profile/compoents/LanguageSection.kt | 116 ----- .../compoents/ProfileScreenLayoutType.kt | 48 -- .../profile/compoents/ProfileSectionTitle.kt | 36 ++ .../profile/compoents/SearchHistorySection.kt | 111 ---- .../profile/compoents/SelectionItem.kt | 74 +++ .../screens/profile/compoents/SettingItem.kt | 107 ++++ .../profile/compoents/SubtitlesSection.kt | 129 ----- .../screens/profile/section/AboutSection.kt | 108 ++++ .../{compoents => section}/AccountsSection.kt | 88 +--- .../profile/section/HelpAndSupportSection.kt | 69 +++ .../profile/section/LanguageSection.kt | 55 ++ .../profile/section/SearchHistorySection.kt | 61 +++ .../profile/section/SubtitlesSection.kt | 100 ++++ .../videoPlayer/VideoPlayerScreenViewModel.kt | 38 +- .../presentation/theme/JetStreamTokens.kt | 2 + .../jetstream/presentation/theme/Size.kt | 58 +-- .../components/ComponentScreenshotTests.kt | 1 - .../screens/profile/ProfileScreenshotTests.kt | 12 +- 52 files changed, 2201 insertions(+), 2947 deletions(-) create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppState.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Callbacks.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/KeyboardShortcuts.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationComponentType.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationTree.kt rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/{withNavigationSuiteScaffold => }/SearchButton.kt (67%) delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/AppWithNavigationSuiteScaffold.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/NavigationSuiteItems.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/TopAppBar.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withSpatialNavigation/AppWithSpatialNavigation.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/AppWithTopBarNavigation.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/TopBar.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AboutSection.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/HelpAndSupportSection.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/LanguageSection.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreenLayoutType.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SearchHistorySection.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SubtitlesSection.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => section}/AccountsSection.kt (58%) create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt diff --git a/AdaptiveJetStream/benchmark/build.gradle.kts b/AdaptiveJetStream/benchmark/build.gradle.kts index db535039..c6c81621 100644 --- a/AdaptiveJetStream/benchmark/build.gradle.kts +++ b/AdaptiveJetStream/benchmark/build.gradle.kts @@ -33,11 +33,11 @@ kotlin { configure { namespace = "com.google.jetstream.benchmark" - compileSdk = 35 + compileSdk = 37 defaultConfig { minSdk = 28 - targetSdk = 35 + targetSdk = 37 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR" diff --git a/AdaptiveJetStream/gradle/libs.versions.toml b/AdaptiveJetStream/gradle/libs.versions.toml index 503ceed4..55be3c63 100644 --- a/AdaptiveJetStream/gradle/libs.versions.toml +++ b/AdaptiveJetStream/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] activity-compose = "1.13.0" -android-gradle-plugin = "9.1.1" -android-test-plugin = "9.1.1" +android-gradle-plugin = "9.2.0" +android-test-plugin = "9.2.0" androidx-baselineprofile = "1.5.0-alpha05" benchmark-macro-junit4 = "1.4.1" coil-compose = "2.7.0" -compose-bom = "2026.03.01" -compose-latest = "1.11.0-rc01" +compose-bom = "2026.04.01" +compose-latest = "1.12.0-alpha01" concurrent-futures-ktx = "1.3.0" core-ktx = "1.18.0" core-splashscreen = "1.2.0" @@ -14,20 +14,22 @@ hilt-navigation-compose = "1.3.0" hilt-android = "2.59.2" junit = "1.3.0" junit4 = "4.13.2" -kotlin-android = "2.3.20" +kotlin-android = "2.3.21" kotlinx-coroutines = "1.10.2" -kotlinx-serialization = "1.10.0" +kotlinx-serialization = "1.11.0" ksp = "2.3.2" lifecycle-runtime-ktx = "2.10.0" material3-adaptive = "1.2.0" material3-adaptive-navigation = "1.4.0" +material3-adaptive-navigation3 = "1.3.0-alpha10" media3 = "1.10.0" -navigation-compose = "2.9.7" +navigation-compose = "2.9.8" +navigation3 = "1.1.1" profileinstaller = "1.4.1" uiautomator = "2.3.0" rules = "1.7.0" window = "1.5.1" -xr = "1.0.0-alpha12" +xr = "1.0.0-alpha13" xr-material3 = "1.0.0-alpha16" screenshot = "0.0.1-alpha14" robolectric = "4.16.1" @@ -55,6 +57,7 @@ androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-runtime-ktx" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle-runtime-ktx" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3-adaptive" } androidx-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "material3-adaptive" } @@ -64,6 +67,9 @@ androidx-media3-session = { module = "androidx.media3:media3-session", version.r androidx-media3-ui = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } +androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3-adaptive-navigation3" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } diff --git a/AdaptiveJetStream/gradle/wrapper/gradle-wrapper.properties b/AdaptiveJetStream/gradle/wrapper/gradle-wrapper.properties index 78dfb562..5b59ea8e 100644 --- a/AdaptiveJetStream/gradle/wrapper/gradle-wrapper.properties +++ b/AdaptiveJetStream/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/AdaptiveJetStream/jetstream/build.gradle.kts b/AdaptiveJetStream/jetstream/build.gradle.kts index 0b54b04a..b424639b 100644 --- a/AdaptiveJetStream/jetstream/build.gradle.kts +++ b/AdaptiveJetStream/jetstream/build.gradle.kts @@ -43,12 +43,12 @@ kotlin { configure { namespace = "com.google.jetstream" // Needed for latest androidx snapshot build - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.google.jetstream" minSdk = 28 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "1.0" @@ -195,6 +195,10 @@ dependencies { // Compose Navigation implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.material3.adaptive.navigation3) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) // Coil implementation(libs.coil.compose) diff --git a/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml b/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml index 6ed00781..c6ef7503 100644 --- a/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml +++ b/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml @@ -80,9 +80,9 @@ https://www.apache.org/licenses/LICENSE-2.0 android:defaultHeight="540dp" android:gravity="center" /> - + android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />--> Unit, modifier: Modifier = Modifier, - appState: AppState = rememberAppState(), ) { - val navController = rememberNavController() + val backStack = rememberNavBackStack(Destination.Home) + val appNavigation = selectAppNavigation() + var isTopbarVisible by rememberSaveable { mutableStateOf(true) } - val navigationComponentType = rememberNavigationComponentType() - - val keyboardShortcuts = - rememberKeyboardShortcuts( - onSelectScreen = { screen -> - if (appState.selectedScreen != screen) { - navController.navigate(screen()) - } - }, - ) - - LaunchedEffect(Unit) { - navController.addOnDestinationChangedListener { _, destination, _ -> - appState.updateSelectedScreen(destination) - } - } - - LaunchedEffect(navigationComponentType) { - appState.updateNavigationComponentType(navigationComponentType) - } - - // The main content that is displayed on every screen - val mainContent = @Composable { padding: PaddingValues -> - NavigationTree( - navController = navController, - isTopBarVisible = appState.isTopBarVisible, - modifier = Modifier.padding(padding), - onScroll = { updateTopBarVisibility(appState, it) }, + Surface { + NavDisplay( + backStack = backStack, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + sceneStrategies = + listOf( + rememberListDetailSceneStrategy(), + ), + sceneDecoratorStrategies = + listOf( + rememberAppLayoutSceneDecorator( + navigation = { + appNavigation.Navigation( + current = backStack.lastOrNull() as? Destination, + onNavigation = backStack::add, + isVisible = isTopbarVisible, + ) + }, + subNavigation = { + appNavigation.SubNavigation( + current = backStack.lastOrNull() as? Destination, + onNavigation = backStack::add, + ) + }, + ), + ), + entryProvider = + entryProvider { + entry( + metadata = Destination.Home.navEntryMetadata(), + ) { + HomeScreen( + onMovieClick = { + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + goToVideoPlayer = { + backStack.add(Destination.VideoPlayer(movieId = it.id)) + }, + onScroll = { + isTopbarVisible = it + }, + isTopBarVisible = true, + ) + } + entry( + metadata = Destination.Categories.navEntryMetadata(), + ) { + CategoriesScreen( + onCategoryClick = { + backStack.add(Destination.CategoryMovieList(categoryId = it)) + }, + ) + } + entry( + metadata = Destination.Movies.navEntryMetadata(), + ) { + MoviesScreen( + onMovieClick = { + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = { + isTopbarVisible = it + }, + isTopBarVisible = true, + ) + } + entry( + metadata = Destination.Shows.navEntryMetadata(), + ) { + ShowsScreen( + onTVShowClick = { + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = { + isTopbarVisible = it + }, + isTopBarVisible = true, + ) + } + entry( + metadata = Destination.Favourites.navEntryMetadata(), + ) { + FavouritesScreen( + onMovieClick = { + backStack.add(Destination.MovieDetails(movieId = it)) + }, + onScroll = { + isTopbarVisible = it + }, + isTopBarVisible = true, + ) + } + entry( + metadata = Destination.Search.navEntryMetadata(), + ) { + SearchScreen( + onMovieClick = { + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = { + isTopbarVisible = it + }, + ) + } + entry( + metadata = Destination.MovieDetails.navEntryMetadata(), + ) { destination -> + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(destination.movieId) + }, + ) + MovieDetailsScreen( + goToMoviePlayer = { + backStack.add(Destination.VideoPlayer(movieId = it.id)) + }, + refreshScreenWithNewMovie = { + backStack.removeLastOrNull() + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + onBackPressed = backStack::removeLastOrNull, + movieDetailsScreenViewModel = viewModel, + ) + } + entry( + metadata = Destination.CategoryMovieList.navEntryMetadata(), + ) { + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(it.categoryId) + }, + ) + CategoryMovieListScreen( + onBackPressed = backStack::removeLastOrNull, + onMovieSelected = { + backStack.add(Destination.MovieDetails(movieId = it.id)) + }, + categoryMovieListScreenViewModel = viewModel, + ) + } + entry( + metadata = Destination.VideoPlayer.navEntryMetadata(), + ) { destination -> + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(destination.movieId) + }, + ) + VideoPlayerScreen( + onBackPressed = backStack::removeLastOrNull, + videoPlayerScreenViewModel = viewModel, + ) + } + entry( + metadata = + Destination.Profile.navEntryMetadata { + AboutSection() + }, + ) { + ProfileScreen( + onDestinationSelected = { + backStack.add(it) + }, + ) + } + entry( + metadata = Destination.About.navEntryMetadata(), + ) { + AboutSection() + } + entry( + metadata = Destination.Accounts.navEntryMetadata(), + ) { + AccountsSection() + } + entry( + metadata = Destination.Subtitles.navEntryMetadata(), + ) { + var isSubtitleChecked by rememberSaveable { mutableStateOf(false) } + SubtitlesSection(isSubtitlesChecked = isSubtitleChecked) { + isSubtitleChecked = it + } + } + entry( + metadata = Destination.Language.navEntryMetadata(), + ) { + var selectedLanguageIndex by rememberSaveable { + mutableIntStateOf(0) + } + LanguageSection(selectedIndex = selectedLanguageIndex) { + selectedLanguageIndex = it + } + } + entry( + metadata = Destination.SearchHistory.navEntryMetadata(), + ) { + SearchHistorySection() + } + entry( + metadata = Destination.HelpAndSupport.navEntryMetadata(), + ) { + HelpAndSupportSection() + } + }, + modifier = modifier, ) } +} - when (navigationComponentType) { - NavigationComponentType.Spatial -> { - // Android XR 3D environment, also known as Full Space mode. - AppWithSpatialNavigation( - appState = appState, - navController = navController, - keyboardShortcuts = keyboardShortcuts, - modifier = modifier, - ) { paddingValues -> - mainContent(paddingValues) +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun Destination.navEntryMetadata( + detailsPlaceHolder: @Composable ThreePaneScaffoldScope.() -> Unit = {}, +): Map { + return metadata { + put(Destination.MetadataKey, presentationType) + } + + when (presentationType) { + PresentationType.ListDetailChild -> { + ListDetailSceneStrategy.detailPane() } - } - NavigationComponentType.TopBar -> { - // TV, Automotive, Large windows on desktop and XR 2D environment (Home space mode). - AppWithTopBarNavigation( - appState = appState, - navController = navController, - keyboardShortcuts = keyboardShortcuts, - onActivityBackPressed = onActivityBackPressed, - modifier = modifier, - ) { paddingValues -> - mainContent(paddingValues) + PresentationType.ListDetailParent -> { + ListDetailSceneStrategy.listPane( + detailPlaceholder = detailsPlaceHolder, + ) } - } - else -> { - // All other form factors (phone, tablet, foldable etc). - AppWithNavigationSuiteScaffold( - appState = appState, - navController = navController, - keyboardShortcuts = keyboardShortcuts, - modifier = modifier, - ) { paddingValues -> - mainContent(paddingValues) + else -> { + mapOf() } } +} + +private fun Destination.MovieDetails.Companion.navEntryMetadata(): Map { + return metadata { + put(Destination.MetadataKey, presentationType) + } +} + +private fun Destination.CategoryMovieList.Companion.navEntryMetadata(): Map { + return metadata { + put(Destination.MetadataKey, presentationType) + } +} + +private fun Destination.VideoPlayer.Companion.navEntryMetadata(): Map { + return metadata { + put(Destination.MetadataKey, presentationType) } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt new file mode 100644 index 00000000..97e3fe9a --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt @@ -0,0 +1,300 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.app + +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.Grid +import androidx.compose.foundation.layout.GridConfigurationScope +import androidx.compose.foundation.layout.GridTrackSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.get +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneDecoratorStrategy +import androidx.navigation3.scene.SceneDecoratorStrategyScope +import androidx.xr.compose.material3.ExperimentalMaterial3XrApi +import androidx.xr.compose.spatial.Subspace +import androidx.xr.compose.subspace.MovePolicy +import androidx.xr.compose.subspace.ResizePolicy +import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.unit.DpVolumeSize +import com.google.jetstream.presentation.components.feature.EngagementMode +import com.google.jetstream.presentation.components.feature.LocalEngagementMode +import com.google.jetstream.presentation.theme.JetStreamTokens + +/** + * Strategy for decorating a scene based on its presentation type. + */ +class AppLayoutSceneDecoratorStrategy( + val engagementMode: EngagementMode, + val subNavigation: @Composable () -> Unit = {}, + val navigation: @Composable () -> Unit = {}, +) : SceneDecoratorStrategy { + override fun SceneDecoratorStrategyScope.decorateScene( + scene: Scene, + ): Scene { + val presentationType = scene.metadata[Destination.MetadataKey] + + // If the presentation type is Overlay, do not decorate the scene + return when { + engagementMode == EngagementMode.Spatial -> { + SpatialAppLayoutSceneDecorator( + scene = scene, + navigation = navigation, + subNavigation = subNavigation, + ) + } + + presentationType == PresentationType.Overlay -> { + scene + } + + else -> { + // Otherwise, decorate the scene with a layout that includes navigation + AppLayoutSceneDecorator( + scene = scene, + navigation = navigation, + subNavigation = subNavigation, + ) + } + } + } +} + +private class SpatialAppLayoutSceneDecorator( + val scene: Scene, + val subNavigation: @Composable () -> Unit = {}, + val navigation: @Composable () -> Unit = {}, +) : Scene { + override val key: Any + get() = scene.key + override val entries: List> + get() = scene.entries + override val previousEntries: List> + get() = scene.previousEntries + + @OptIn(ExperimentalMaterial3XrApi::class) + override val content: @Composable (() -> Unit) = { + + val resizePolicy = + remember { + ResizePolicy( + minimumSize = DpVolumeSize.from(JetStreamTokens.LeanbackWindowSize), + ) + } + val dragPolicy = remember { MovePolicy() } + val presentationType = scene.metadata[Destination.MetadataKey] + + val isNavigationVisible = presentationType != PresentationType.Overlay + + Subspace { + SpatialPanel( + resizePolicy = resizePolicy, + dragPolicy = dragPolicy, + ) { + Surface { + MainPanel( + isNavigationVisible = isNavigationVisible, + navigation = subNavigation, + content = scene.content, + ) + } + } + if (isNavigationVisible) { + navigation() + } + } + } + + @Composable + private fun MainPanel( + isNavigationVisible: Boolean, + navigation: @Composable () -> Unit, + content: @Composable () -> Unit, + ) { + Column { + if (isNavigationVisible) { + navigation() + } + content() + } + } +} + +/** + * A scene decorator that wraps the content of a scene with navigation components. + * It uses a Grid layout to position navigation, sub-navigation, and the main content. + */ +private class AppLayoutSceneDecorator( + val scene: Scene, + val subNavigation: @Composable () -> Unit = {}, + val navigation: @Composable () -> Unit = {}, +) : Scene { + override val key: Any + get() = scene.key + override val entries: List> + get() = scene.entries + override val previousEntries: List> + get() = scene.previousEntries + + @OptIn(ExperimentalGridApi::class) + override val content: @Composable (() -> Unit) = { + val layout = selectLayout() + val subNavigationArea = layout.subNavigation + + Grid( + config = layout.config, + ) { + Box( + modifier = + Modifier.gridItem( + column = layout.navigation.column, + row = layout.navigation.row, + rowSpan = layout.navigation.rowSpan, + columnSpan = layout.navigation.columnSpan, + ), + ) { + navigation() + } + if (subNavigationArea != null) { + Box( + modifier = + Modifier.gridItem( + column = subNavigationArea.column, + row = subNavigationArea.row, + rowSpan = subNavigationArea.rowSpan, + columnSpan = subNavigationArea.columnSpan, + ), + ) { + subNavigation() + } + } + scene.content() + } + } + + /** + * Selects the appropriate layout based on the current EngagementMode. + */ + @OptIn(ExperimentalGridApi::class) + @Composable + private fun selectLayout(): AppLayout { + return when (LocalEngagementMode.current) { + is EngagementMode.Compact -> AppLayout.NavigationBar + EngagementMode.Leanback -> AppLayout.TopBar + else -> AppLayout.NavigationRail + } + } +} + +// ToDo: Update with named-area. + +/** + * Interface defining the grid configuration and areas for different app layouts. + */ +@OptIn(ExperimentalGridApi::class) +private sealed interface AppLayout { + val config: GridConfigurationScope.() -> Unit + val navigation: GridArea + val subNavigation: GridArea? + val content: GridArea + + /** + * Layout using a bottom navigation bar, typically for Compact. + */ + object NavigationBar : AppLayout { + override val config: GridConfigurationScope.() -> Unit = { + column(GridTrackSize.MinMax(350.dp, 1.fr)) + row(GridTrackSize.Auto) + row(GridTrackSize.MinMax(200.dp, 1.fr)) + row(GridTrackSize.Auto) + } + override val navigation = GridArea(row = -1, column = 1) + override val subNavigation = GridArea(row = 1, column = 1) + override val content = GridArea(row = 2, column = 1) + } + + /** + * Layout using a side navigation rail, typically for Medium. + */ + object NavigationRail : AppLayout { + override val config: GridConfigurationScope.() -> Unit + get() = { + column(GridTrackSize.Auto) + column(GridTrackSize.MinMax(200.dp, 1.fr)) + row(GridTrackSize.Auto) + row(GridTrackSize.MinMax(200.dp, 1.fr)) + rowGap(8.dp) + } + override val navigation = GridArea(column = 1, row = 1, rowSpan = 2, columnSpan = 1) + override val subNavigation = GridArea(column = 2, row = 1) + override val content = GridArea(column = 2, row = 2) + } + + /** + * Layout using a top navigation bar, typically for Leanback. + */ + object TopBar : AppLayout { + override val config: GridConfigurationScope.() -> Unit = { + column(1f) + row(GridTrackSize.Auto) + row(GridTrackSize.MinMax(300.dp, 1.fr)) + } + override val navigation: GridArea = GridArea(row = 1, column = 1) + override val subNavigation: GridArea? = null + override val content: GridArea = GridArea(row = 2, column = 1) + } +} + +private data class GridArea( + val row: Int = 0, + val column: Int = 0, + val rowSpan: Int = 1, + val columnSpan: Int = 1, +) + +private fun DpVolumeSize.Companion.from(dpSize: DpSize): DpVolumeSize { + return DpVolumeSize(width = dpSize.width, height = dpSize.height, depth = 0.dp) +} + +/** + * Remembers an [AppLayoutSceneDecoratorStrategy] with the given navigation components. + */ +@Composable +fun rememberAppLayoutSceneDecorator( + subNavigation: @Composable () -> Unit = {}, + navigation: @Composable () -> Unit = {}, +): AppLayoutSceneDecoratorStrategy { + val engagementMode = LocalEngagementMode.current + return remember(navigation, subNavigation, engagementMode) { + AppLayoutSceneDecoratorStrategy( + navigation = navigation, + subNavigation = subNavigation, + engagementMode = engagementMode, + ) + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppState.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppState.kt deleted file mode 100644 index 2cf21e20..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppState.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.navigation.NavDestination -import com.google.jetstream.presentation.screens.Screens - -class AppState internal constructor( - initialTopBarVisibility: Boolean = true, - initialScreen: Screens = Screens.Home, -) { - var isTopBarVisible by mutableStateOf(initialTopBarVisibility) - private set - - var selectedScreen by mutableStateOf(initialScreen) - private set - - var isTopBarFocused by mutableStateOf(false) - private set - - var isNavigationVisible by mutableStateOf(true) - private set - - private var navigationComponentType - by mutableStateOf(NavigationComponentType.NavigationSuiteScaffold) - - fun showTopBar() { - isTopBarVisible = true - } - - fun hideTopBar() { - isTopBarVisible = false - } - - fun updateTopBarFocusState(hasFocus: Boolean) { - isTopBarFocused = hasFocus - } - - fun updateSelectedScreen(screen: Screens) { - selectedScreen = screen - updateNavigationVisibility() - } - - fun updateSelectedScreen(destination: NavDestination) { - updateSelectedScreen(destination.route ?: Screens.Home.name) - } - - fun updateNavigationComponentType(type: NavigationComponentType) { - navigationComponentType = type - updateNavigationVisibility() - } - - private fun updateNavigationVisibility() { - isNavigationVisible = selectedScreen.shouldShowNavigation(navigationComponentType) - } - - private fun updateSelectedScreen(destination: String) { - val screen = Screens.tryFrom(destination) ?: error("Could not find screen from $destination") - updateSelectedScreen(screen) - } - - private fun snapshot(): Pair { - return isTopBarVisible to selectedScreen.toIndex() - } - - companion object { - val Saver = - Saver>( - save = { it.snapshot() }, - restore = { - val screen = Screens.fromIndex(it.second) ?: Screens.Home - AppState(it.first, screen) - }, - ) - } -} - -@Composable -fun rememberAppState( - initialIsVisibility: Boolean = true, - initialScreen: Screens = Screens.Home, -) = rememberSaveable(saver = AppState.Saver) { - AppState(initialIsVisibility, initialScreen) -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Callbacks.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Callbacks.kt deleted file mode 100644 index 4758004a..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Callbacks.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app - -import androidx.navigation.NavController -import androidx.navigation.NavHostController -import com.google.jetstream.data.entities.Movie -import com.google.jetstream.presentation.screens.Screens - -internal fun NavHostController.openMovieDetailScreen(movie: Movie) = - openMovieDetailsScreen(movieId = movie.id) - -internal fun NavHostController.openMovieDetailsScreen(movieId: String) { - navigate( - Screens.MovieDetails.withArgs(movieId), - ) -} - -internal fun NavController.openVideoPlayer(movieId: String) { - navigate(Screens.VideoPlayer.withArgs(movieId)) -} - -internal fun NavController.openCategoryMovieList() = { categoryId: String -> - navigate( - Screens.CategoryMovieList.withArgs(categoryId), - ) -} - -// TODO: Could this be refactored to be a method on AppState? -internal fun updateTopBarVisibility(appState: AppState, updatedVisibility: Boolean) { - if (updatedVisibility) { - appState.showTopBar() - } else { - appState.hideTopBar() - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt new file mode 100644 index 00000000..97194baf --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.app + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.Support +import androidx.compose.material.icons.filled.Translate +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey +import com.google.jetstream.R +import kotlinx.serialization.Serializable + +// ToDo: update names to read the resource file +@Serializable +sealed class Destination( + val presentationType: PresentationType = PresentationType.SinglePane, +) : NavKey { + open val name: String + @Composable get() { + return "" + } + open val icon: Painter? + @Composable get() { + return null + } + + @Serializable + data object Home : Destination() { + override val name: String @Composable get() = "Home" + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_home) + } + + @Serializable + data object Categories : Destination() { + override val name: String @Composable get() = "Categories" + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_category) + } + + @Serializable + data object Movies : Destination() { + override val name: String @Composable get() = "Movies" + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_movies) + } + + @Serializable + data object Shows : Destination() { + override val name: String @Composable get() = "Shows" + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_shows) + } + + @Serializable + data object Favourites : Destination() { + override val name: String @Composable get() = "Favourites" + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_favorites) + } + + @Serializable + data object Search : Destination() { + override val name: String @Composable get() = "Search" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) + } + + @Serializable + data object Profile : Destination(presentationType = PresentationType.ListDetailParent) { + override val name: String @Composable get() = "Profile" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) + } + + @Serializable + data class CategoryMovieList(val categoryId: String) : Destination() { + companion object { + val presentationType = PresentationType.SinglePane + } + } + + @Serializable + data class MovieDetails(val movieId: String) : Destination() { + companion object { + val presentationType = PresentationType.Overlay + } + } + + @Serializable + data class VideoPlayer(val movieId: String) : + Destination(presentationType = PresentationType.Overlay) { + companion object { + val presentationType = PresentationType.Overlay + } + } + + @Serializable + data object About : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "About" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Info) + } + + @Serializable + data object Accounts : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "Accounts" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) + } + + @Serializable + data object Subtitles : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "Subtitles" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Subtitles) + } + + @Serializable + data object Language : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "Language" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Translate) + } + + @Serializable + data object SearchHistory : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "Search history" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) + } + + @Serializable + data object HelpAndSupport : Destination(presentationType = PresentationType.ListDetailChild) { + override val name: String @Composable get() = "Help and Support" + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Support) + } + + companion object { + val MetadataKey = object : NavMetadataKey {} + val RootDestinations: List + get() { + return listOf( + Home, + Categories, + Movies, + Shows, + Favourites, + ) + } + + val ProfileSettings: List + get() { + return listOf( + About, + Accounts, + Subtitles, + Language, + SearchHistory, + HelpAndSupport, + ) + } + } +} + +enum class PresentationType { + SinglePane, + ListDetailParent, + ListDetailChild, + Overlay, +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt new file mode 100644 index 00000000..2ef31117 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt @@ -0,0 +1,469 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.app + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.ExperimentalFlexBoxApi +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.FlexBox +import androidx.compose.foundation.layout.FlexDirection +import androidx.compose.foundation.layout.FlexJustifyContent +import androidx.compose.foundation.layout.Grid +import androidx.compose.foundation.layout.GridTrackSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.fillSize +import androidx.compose.foundation.style.styleable +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.xr.compose.material3.ExperimentalMaterial3XrApi +import androidx.xr.compose.material3.NavigationRail +import androidx.xr.compose.platform.LocalSession +import androidx.xr.scenecore.scene +import com.google.jetstream.R +import com.google.jetstream.presentation.components.feature.EngagementMode +import com.google.jetstream.presentation.components.feature.LocalEngagementMode +import com.google.jetstream.presentation.theme.LocalContentPadding + +/** + * Interface defining the navigation components for the JetStream app. + * Different implementations are used based on the device's engagement mode. + */ +interface JetStreamAppNavigation { + /** + * Displays secondary navigation elements like logos, search, and profile buttons. + */ + @Composable + fun SubNavigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean = true, + ) + + /** + * Displays the primary navigation elements to switch between main destinations. + */ + @Composable + fun Navigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean = true, + ) +} + +/** + * Default navigation implementation for mobile and tablet devices. + * It adapts between a horizontal bar and a vertical rail based on engagement mode. + */ +object DefaultNavigation : JetStreamAppNavigation { + @OptIn(ExperimentalGridApi::class, ExperimentalFoundationStyleApi::class) + @Composable + override fun SubNavigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + val contentPadding = LocalContentPadding.current + + Grid( + config = { + row(1f) + column(GridTrackSize.Auto) + column(1.fr) + column(GridTrackSize.Auto) + column(GridTrackSize.Auto) + }, + modifier = + Modifier + .padding(start = contentPadding.start, end = contentPadding.end) + .height(80.dp), + ) { + JetStreamLogo( + modifier = + Modifier + .styleable { + alpha(0.75f) + } + .gridItem(alignment = Alignment.Center), + ) + SearchButton( + onClick = { onNavigation(Destination.Search) }, + modifier = Modifier.gridItem(column = -2), + ) + UserAvatar( + selected = current == Destination.Profile, + onClick = { onNavigation(Destination.Profile) }, + modifier = + Modifier.gridItem(column = -1), + ) + } + } + + @OptIn(ExperimentalFlexBoxApi::class, ExperimentalFoundationStyleApi::class) + @Composable + override fun Navigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + val direction = + when (LocalEngagementMode.current) { + is EngagementMode.Compact -> FlexDirection.Row + else -> FlexDirection.Column + } + + FlexBox( + config = { + direction(direction) + justifyContent(FlexJustifyContent.Center) + gap(4.dp) + }, + modifier = + Modifier.styleable { + fillSize() + }, + ) { + RootDestinations( + current = current, + onNavigation = onNavigation, + ) + if (LocalEngagementMode.current == EngagementMode.Enclosed) { + EnableSpatialUiButton() + } + } + } + + @Composable + fun RootDestinations( + current: Destination?, + onNavigation: (Destination) -> Unit, + ) { + Destination.RootDestinations.forEach { destination -> + NavigationRailItem( + selected = destination == current, + onClick = { onNavigation(destination) }, + icon = { + val painter = destination.icon + if (painter != null) { + Icon( + painter = painter, + contentDescription = destination.name, + modifier = Modifier.size(24.dp), + ) + } + }, + label = { + Text(text = destination.name) + }, + ) + } + } + + @Composable + fun EnableSpatialUiButton(modifier: Modifier = Modifier) { + val session = LocalSession.current + NavigationRailItem( + selected = false, + onClick = { + session?.scene?.requestFullSpaceMode() + }, + icon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_expand_content), + modifier = Modifier.size(48.dp), + contentDescription = stringResource(R.string.home_space_mode), + tint = MaterialTheme.colorScheme.primary, + ) + }, + label = { + Text( + stringResource(R.string.full_space_mode), + color = MaterialTheme.colorScheme.primary, + ) + }, + modifier = modifier, + ) + } +} + +/** + * Navigation implementation optimized for Leanback engagement mode, using a top bar. + */ +object TopBarNavigation : JetStreamAppNavigation { + @Composable + override fun SubNavigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + } + + @Composable + override fun Navigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + // Slide in from top animation for the top bar + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + ) { + Topbar( + current = current, + onTabClicked = onNavigation, + onTabFocused = onNavigation, + ) + } + } +} + +object SpatialNavigation : JetStreamAppNavigation { + @Composable + override fun SubNavigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + DefaultNavigation.SubNavigation( + current = current, + onNavigation = onNavigation, + isVisible = isVisible, + ) + } + + @OptIn(ExperimentalMaterial3XrApi::class) + @Composable + override fun Navigation( + current: Destination?, + onNavigation: (Destination) -> Unit, + isVisible: Boolean, + ) { + NavigationRail { + DefaultNavigation.RootDestinations( + current = current, + onNavigation = onNavigation, + ) + DisableSpatialUiButton() + } + } + + @Composable + private fun DisableSpatialUiButton( + modifier: Modifier = Modifier, + ) { + val session = LocalSession.current + if (session != null) { + NavigationRailItem( + selected = false, + onClick = { session.scene.requestHomeSpaceMode() }, + icon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_collapse_content), + modifier = Modifier.size(48.dp), + contentDescription = stringResource(R.string.home_space_mode), + tint = MaterialTheme.colorScheme.primary, + ) + }, + label = { + Text( + stringResource(R.string.home_space_mode), + color = MaterialTheme.colorScheme.primary, + ) + }, + modifier = modifier, + ) + } + } +} + +/** + * Selects the appropriate [JetStreamAppNavigation] implementation based on the current engagement mode. + */ +@Composable +fun selectAppNavigation(): JetStreamAppNavigation { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + when (engagementMode) { + EngagementMode.Spatial -> { + SpatialNavigation + } + + EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> { + TopBarNavigation + } + + else -> { + DefaultNavigation + } + } + } +} + +@OptIn( + ExperimentalFlexBoxApi::class, + ExperimentalFoundationStyleApi::class, + ExperimentalGridApi::class, +) +@Composable +private fun Topbar( + current: Destination?, + onTabFocused: (Destination) -> Unit, + onTabClicked: (Destination) -> Unit, + modifier: Modifier = Modifier, +) { + val contentPadding = LocalContentPadding.current + val focusRequester = remember { FocusRequester() } + + Grid( + config = { + row(1f) + column(GridTrackSize.Auto) + column(GridTrackSize.MinMax(100.dp, 1.fr)) + column(0.2.fr) + column(GridTrackSize.Auto) + gap(8.dp) + }, + modifier = + modifier + .styleable { + contentPaddingStart(contentPadding.start) + contentPaddingEnd(contentPadding.end) + } + .focusRestorer(fallback = focusRequester) + .focusGroup(), + ) { + UserAvatar( + selected = current == Destination.Profile, + onClick = { onTabFocused(Destination.Profile) }, + modifier = + Modifier + .styleable { + size(32.dp) + } + .gridItem(alignment = Alignment.Center), + ) + TabRow( + current = current, + onTabClicked = onTabClicked, + onTabFocused = onTabFocused, + modifier = + Modifier + .gridItem(alignment = Alignment.CenterStart) + .focusRequester(focusRequester), + ) + JetStreamLogo(modifier = Modifier.gridItem(column = -1, alignment = Alignment.Center)) + } +} + +@Composable +private fun TabRow( + current: Destination?, + onTabFocused: (Destination) -> Unit, + onTabClicked: (Destination) -> Unit, + modifier: Modifier = Modifier, +) { + val textStyle = + MaterialTheme.typography.titleSmall.copy( + color = LocalContentColor.current, + ) + val selectedTabIndex = currentTabIndex(current) + + val focusRequesterList = + remember { + List(Destination.RootDestinations.size + 1) { + FocusRequester() + } + } + + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + divider = {}, + modifier = + modifier + .focusRestorer(fallback = focusRequesterList[selectedTabIndex]) + .focusGroup(), + ) { + Destination.RootDestinations.forEachIndexed { index, destination -> + Tab( + selected = destination == current, + onClick = { + onTabClicked(destination) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + onTabFocused(destination) + } + } + .focusRequester(focusRequesterList[index]), + ) { + Text(text = destination.name, style = textStyle) + } + } + Tab( + selected = Destination.Search == current, + onClick = { onTabClicked(Destination.Search) }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + onTabFocused(Destination.Search) + } + } + .focusRequester(focusRequesterList.last()), + ) { + Icon( + painter = Destination.Search.icon, + contentDescription = Destination.Search.name, + ) + } + } +} + +private fun currentTabIndex(current: Destination?): Int { + val index = Destination.RootDestinations.indexOf(current) + return when { + index > -1 -> index + current == Destination.Search -> Destination.RootDestinations.size + else -> 0 + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/KeyboardShortcuts.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/KeyboardShortcuts.kt deleted file mode 100644 index 82b628b3..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/KeyboardShortcuts.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.input.key.Key -import com.google.jetstream.presentation.components.KeyboardShortcut -import com.google.jetstream.presentation.components.ModifierKey -import com.google.jetstream.presentation.screens.Screens - -// TODO: Consider associating the keys and modifier keys with each Screen directly, rather than -// having this separate list -@Composable -fun rememberKeyboardShortcuts( - onSelectScreen: (Screens) -> Unit, -): List = - remember { - listOf( - KeyboardShortcut( - key = Key.Comma, - modifierKeys = setOf(ModifierKey.Ctrl), - action = { onSelectScreen(Screens.Profile) }, - ), - KeyboardShortcut( - key = Key.P, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Profile) }, - ), - KeyboardShortcut( - key = Key.H, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Home) }, - ), - KeyboardShortcut( - key = Key.C, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Categories) }, - ), - KeyboardShortcut( - key = Key.M, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Movies) }, - ), - KeyboardShortcut( - key = Key.T, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Shows) }, - ), - KeyboardShortcut( - key = Key.F, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Favourites) }, - ), - KeyboardShortcut( - key = Key.Slash, - action = { onSelectScreen(Screens.Search) }, - ), - KeyboardShortcut( - key = Key.S, - modifierKeys = setOf(ModifierKey.Ctrl, ModifierKey.Alt), - action = { onSelectScreen(Screens.Search) }, - ), - ) - } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationComponentType.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationComponentType.kt deleted file mode 100644 index f9a64f74..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationComponentType.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.google.jetstream.presentation.components.feature.EngagementMode -import com.google.jetstream.presentation.components.feature.LocalEngagementMode - -enum class NavigationComponentType { - NavigationSuiteScaffold, - TopBar, - Spatial, -} - -@Composable -fun rememberNavigationComponentType(): NavigationComponentType { - val engagementMode = LocalEngagementMode.current - - return remember(LocalEngagementMode) { - when (engagementMode) { - EngagementMode.Spatial -> NavigationComponentType.Spatial - EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> NavigationComponentType.TopBar - else -> NavigationComponentType.NavigationSuiteScaffold - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationTree.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationTree.kt deleted file mode 100644 index 343b0ae6..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationTree.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import com.google.jetstream.data.entities.Movie -import com.google.jetstream.presentation.screens.Screens.Categories -import com.google.jetstream.presentation.screens.Screens.CategoryMovieList -import com.google.jetstream.presentation.screens.Screens.Favourites -import com.google.jetstream.presentation.screens.Screens.Home -import com.google.jetstream.presentation.screens.Screens.MovieDetails -import com.google.jetstream.presentation.screens.Screens.Movies -import com.google.jetstream.presentation.screens.Screens.Profile -import com.google.jetstream.presentation.screens.Screens.Search -import com.google.jetstream.presentation.screens.Screens.Shows -import com.google.jetstream.presentation.screens.Screens.VideoPlayer -import com.google.jetstream.presentation.screens.categories.CategoriesScreen -import com.google.jetstream.presentation.screens.categories.CategoryMovieListScreen -import com.google.jetstream.presentation.screens.categories.categoryMovieListScreenArguments -import com.google.jetstream.presentation.screens.favourites.FavouritesScreen -import com.google.jetstream.presentation.screens.home.HomeScreen -import com.google.jetstream.presentation.screens.moviedetails.MovieDetailsScreen -import com.google.jetstream.presentation.screens.moviedetails.movieDetailsScreenArguments -import com.google.jetstream.presentation.screens.movies.MoviesScreen -import com.google.jetstream.presentation.screens.profile.ProfileScreen -import com.google.jetstream.presentation.screens.search.SearchScreen -import com.google.jetstream.presentation.screens.shows.ShowsScreen -import com.google.jetstream.presentation.screens.videoPlayer.VideoPlayerScreen - -@Composable -fun NavigationTree( - navController: NavHostController, - modifier: Modifier = Modifier, - isTopBarVisible: Boolean = true, - onScroll: (Boolean) -> Unit = {}, -) { - NavHost( - navController = navController, - startDestination = Home(), - modifier = modifier.fillMaxSize(), - ) { - composable( - route = CategoryMovieList(), - arguments = categoryMovieListScreenArguments, - ) { - CategoryMovieListScreen( - onBackPressed = navController::navigateUp, - onMovieSelected = { movie -> navController.openMovieDetailScreen(movie) }, - ) - } - composable( - route = MovieDetails(), - arguments = movieDetailsScreenArguments, - ) { - MovieDetailsScreen( - goToMoviePlayer = { movieDetails -> - navController.openVideoPlayer(movieDetails.id) - }, - refreshScreenWithNewMovie = { movie -> - navController.navigate( - MovieDetails.withArgs(movie.id), - ) { - popUpTo(MovieDetails()) { - inclusive = true - } - } - }, - onBackPressed = navController::navigateUp, - ) - } - composable(route = VideoPlayer()) { - VideoPlayerScreen( - onBackPressed = navController::navigateUp, - ) - } - composable(Profile()) { - ProfileScreen() - } - composable(Home()) { - HomeScreen( - onMovieClick = { movie -> navController.openMovieDetailsScreen(movie.id) }, - goToVideoPlayer = { movie: Movie -> navController.openVideoPlayer(movie.id) }, - onScroll = onScroll, - isTopBarVisible = isTopBarVisible, - ) - } - composable(Categories()) { - CategoriesScreen( - onCategoryClick = navController.openCategoryMovieList(), - onScroll = onScroll, - ) - } - composable(Movies()) { - MoviesScreen( - onMovieClick = { movie -> navController.openMovieDetailScreen(movie) }, - onScroll = onScroll, - isTopBarVisible = isTopBarVisible, - ) - } - composable(Shows()) { - ShowsScreen( - onTVShowClick = { movie -> navController.openMovieDetailScreen(movie) }, - onScroll = onScroll, - isTopBarVisible = isTopBarVisible, - ) - } - composable(Favourites()) { - FavouritesScreen( - onMovieClick = navController::openMovieDetailsScreen, - onScroll = onScroll, - isTopBarVisible = isTopBarVisible, - ) - } - composable(Search()) { - SearchScreen( - onMovieClick = { movie -> navController.openMovieDetailsScreen(movie.id) }, - onScroll = onScroll, - ) - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/SearchButton.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt similarity index 67% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/SearchButton.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt index ff6fe433..b6efe277 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/SearchButton.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt @@ -14,25 +14,18 @@ * limitations under the License. */ -package com.google.jetstream.presentation.app.withNavigationSuiteScaffold +package com.google.jetstream.presentation.app -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.screens.Screens @Composable fun SearchButton( modifier: Modifier = Modifier, - icon: ImageVector = Screens.Search.tabIcon ?: Icons.Default.Search, - contentDescription: String? = - StringConstants.Composable.ContentDescription.DashboardSearchButton, onClick: () -> Unit = {}, ) { IconButton( @@ -40,8 +33,8 @@ fun SearchButton( modifier = modifier, ) { Icon( - icon, - contentDescription = contentDescription, + painter = Destination.Search.icon, + contentDescription = StringConstants.Composable.ContentDescription.DashboardSearchButton, tint = LocalContentColor.current, ) } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/AppWithNavigationSuiteScaffold.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/AppWithNavigationSuiteScaffold.kt deleted file mode 100644 index a2bfd848..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/AppWithNavigationSuiteScaffold.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withNavigationSuiteScaffold - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold -import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import com.google.jetstream.presentation.app.AppState -import com.google.jetstream.presentation.components.KeyboardShortcut -import com.google.jetstream.presentation.components.feature.EngagementMode -import com.google.jetstream.presentation.components.feature.LocalEngagementMode -import com.google.jetstream.presentation.components.handleKeyboardShortcuts -import com.google.jetstream.presentation.screens.Screens - -@Composable -fun AppWithNavigationSuiteScaffold( - appState: AppState, - navController: NavHostController, - modifier: Modifier = Modifier, - keyboardShortcuts: List = emptyList(), - content: @Composable (PaddingValues) -> Unit, -) { - val isEnclosed = LocalEngagementMode.current == EngagementMode.Enclosed - - NavigationSuiteScaffoldLayout( - keyboardShortcuts = keyboardShortcuts, - modifier = modifier.fillMaxSize(), - isNavigationVisible = appState.isNavigationVisible, - navigationItems = { - AdaptiveAppNavigationItems( - currentScreen = appState.selectedScreen, - screens = Screens.mainNavigationScreens, - onSelectScreen = { screen -> - if (screen != appState.selectedScreen) { - navController.navigate(screen()) - } - }, - ) - if (isEnclosed) { - RequestFullSpaceModeItem() - } - }, - content = content, - topBar = { - // TODO: This is specific to XR home-space mode - val topBarPaddingTop = - remember(isEnclosed) { - if (isEnclosed) { - 32.dp - } else { - 0.dp - } - } - - AnimatedVisibility( - visible = appState.isNavigationVisible && appState.isTopBarVisible, - enter = slideInVertically(), - exit = slideOutVertically(), - ) { - TopAppBar( - modifier = - Modifier - .padding( - start = 24.dp, - end = 24.dp, - top = topBarPaddingTop, - ) - .onFocusChanged { appState.updateTopBarFocusState(it.hasFocus) }, - selectedScreen = appState.selectedScreen, - showScreen = { screen -> - if (screen != appState.selectedScreen) { - navController.navigate(screen()) - } - }, - ) - } - }, - ) -} - -@Composable -fun NavigationSuiteScaffoldLayout( - isNavigationVisible: Boolean, - modifier: Modifier = Modifier, - keyboardShortcuts: List = emptyList(), - navigationItems: @Composable () -> Unit, - content: @Composable ((padding: PaddingValues) -> Unit), - topBar: @Composable () -> Unit, -) { - Surface { - val navigationSuiteScaffoldState = rememberNavigationSuiteScaffoldState() - LaunchedEffect(key1 = isNavigationVisible) { - if (isNavigationVisible) { - navigationSuiteScaffoldState.show() - } else { - navigationSuiteScaffoldState.hide() - } - } - NavigationSuiteScaffold( - modifier = modifier.handleKeyboardShortcuts(keyboardShortcuts), - state = navigationSuiteScaffoldState, - navigationItemVerticalArrangement = Arrangement.Center, - navigationItems = navigationItems, - ) { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = topBar, - ) { paddingValues -> - content(paddingValues) - } - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/NavigationSuiteItems.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/NavigationSuiteItems.kt deleted file mode 100644 index c678d48c..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/NavigationSuiteItems.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withNavigationSuiteScaffold - -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.xr.compose.platform.LocalSpatialConfiguration -import androidx.xr.compose.platform.SpatialConfiguration -import com.google.jetstream.R -import com.google.jetstream.presentation.screens.Screens - -@Composable -fun AdaptiveAppNavigationItems( - currentScreen: Screens, - screens: List, - onSelectScreen: (Screens) -> Unit, -) { - screens.forEach { screen -> - NavigationSuiteItem( - selected = screen == currentScreen, - onClick = { - onSelectScreen(screen) - }, - label = { Text(screen.name, color = MaterialTheme.colorScheme.primary) }, - icon = { - Icon( - imageVector = ImageVector.vectorResource(screen.navIcon), - modifier = Modifier.size(24.dp), - contentDescription = screen.name, - tint = MaterialTheme.colorScheme.primary, - ) - }, - ) - } -} - -@Composable -fun RequestFullSpaceModeItem( - spatialConfiguration: SpatialConfiguration = LocalSpatialConfiguration.current, -) { - NavigationSuiteItem( - selected = false, - onClick = spatialConfiguration::requestFullSpaceMode, - icon = { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_expand_content), - modifier = Modifier.size(24.dp), - contentDescription = stringResource(R.string.full_space_mode), - tint = MaterialTheme.colorScheme.primary, - ) - }, - label = { - Text( - stringResource(R.string.full_space_mode), - color = MaterialTheme.colorScheme.primary, - ) - }, - ) -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/TopAppBar.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/TopAppBar.kt deleted file mode 100644 index cc409238..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withNavigationSuiteScaffold/TopAppBar.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withNavigationSuiteScaffold - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.unit.dp -import com.google.jetstream.presentation.app.JetStreamLogo -import com.google.jetstream.presentation.app.UserAvatar -import com.google.jetstream.presentation.screens.Screens - -@Composable -fun TopAppBar( - selectedScreen: Screens, - showScreen: (Screens) -> Unit, - modifier: Modifier = Modifier, -) { - val (avatar, search) = remember { FocusRequester.createRefs() } - - /* - * When the row becomes focussed, automatically focus either the search or profile - * composables depending on the current screen. - * - * TODO: This could be refactored to take a list of - * navigation items rather than a hardcoded list - */ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .focusProperties { - onEnter = { - when (selectedScreen) { - Screens.Profile -> { - avatar.requestFocus() - } - - Screens.Search -> { - search.requestFocus() - } - - else -> {} - } - } - } - .focusGroup(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - JetStreamLogo( - modifier = - Modifier - .alpha(0.75f) - .padding(start = 8.dp), - ) - Spacer(modifier.weight(1f)) - SearchButton( - modifier = Modifier.focusRequester(search), - onClick = { - showScreen(Screens.Search) - }, - ) - UserAvatar( - modifier = Modifier.focusRequester(avatar), - selected = selectedScreen == Screens.Profile, - onClick = { showScreen(Screens.Profile) }, - ) - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withSpatialNavigation/AppWithSpatialNavigation.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withSpatialNavigation/AppWithSpatialNavigation.kt deleted file mode 100644 index b6d88882..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withSpatialNavigation/AppWithSpatialNavigation.kt +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withSpatialNavigation - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRailItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import androidx.xr.compose.material3.ExperimentalMaterial3XrApi -import androidx.xr.compose.material3.NavigationRail -import androidx.xr.compose.platform.LocalSpatialConfiguration -import androidx.xr.compose.platform.SpatialConfiguration -import androidx.xr.compose.spatial.Subspace -import androidx.xr.compose.subspace.MovePolicy -import androidx.xr.compose.subspace.ResizePolicy -import androidx.xr.compose.subspace.SpatialPanel -import androidx.xr.compose.subspace.layout.SubspaceModifier -import androidx.xr.compose.subspace.layout.height -import androidx.xr.compose.subspace.layout.width -import androidx.xr.compose.unit.DpVolumeSize -import com.google.jetstream.R -import com.google.jetstream.presentation.app.AppState -import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.TopAppBar -import com.google.jetstream.presentation.components.KeyboardShortcut -import com.google.jetstream.presentation.components.handleKeyboardShortcuts -import com.google.jetstream.presentation.screens.Screens - -@Composable -fun AppWithSpatialNavigation( - appState: AppState, - navController: NavHostController, - keyboardShortcuts: List, - modifier: Modifier, - content: @Composable ((padding: PaddingValues) -> Unit), -) { - SpatialNavigationLayout( - selectedScreen = appState.selectedScreen, - isNavigationVisible = appState.isNavigationVisible, - isTopBarVisible = appState.isTopBarVisible, - onShowScreen = { screen -> - navController.navigate(screen()) - }, - onTopBarFocusChanged = { appState.updateTopBarFocusState(it) }, - containerColor = appState.selectedScreen.xrContainerColor(), - modifier = - modifier - .fillMaxSize() - .handleKeyboardShortcuts(keyboardShortcuts), - ) { paddingValues -> - content(paddingValues) - } -} - -@OptIn(ExperimentalMaterial3XrApi::class) -@Composable -fun SpatialNavigationLayout( - selectedScreen: Screens, - isNavigationVisible: Boolean, - isTopBarVisible: Boolean, - onShowScreen: (Screens) -> Unit, - onTopBarFocusChanged: (Boolean) -> Unit, - containerColor: Color, - modifier: Modifier = Modifier, - content: @Composable (PaddingValues) -> Unit, -) { - val resizePolicy = - remember { - ResizePolicy(minimumSize = DpVolumeSize(800.dp, 800.dp, 0.dp)) - } - val dragPolicy = - remember { - MovePolicy() - } - - Subspace { - SpatialPanel( - resizePolicy = resizePolicy, - dragPolicy = dragPolicy, - modifier = SubspaceModifier.width(1280.dp).height(900.dp), - ) { - Scaffold( - topBar = { - AnimatedVisibility( - visible = isTopBarVisible, - enter = slideInVertically(), - exit = slideOutVertically(), - ) { - TopAppBar( - selectedScreen = selectedScreen, - showScreen = { onShowScreen(it) }, - modifier = - Modifier - .padding( - start = 24.dp, - end = 24.dp, - top = 32.dp, - ) - .onFocusChanged { onTopBarFocusChanged(it.hasFocus) }, - ) - } - }, - containerColor = containerColor, - modifier = modifier, - ) { padding -> - content(padding) - } - AnimatedVisibility(isNavigationVisible) { - NavigationInObiter( - screens = Screens.mainNavigationScreens, - currentScreen = selectedScreen, - ) { - onShowScreen(it) - } - } - } - } -} - -@Composable -private fun NavigationInObiter( - screens: List, - currentScreen: Screens, - spatialConfiguration: SpatialConfiguration = LocalSpatialConfiguration.current, - onScreenSelected: (Screens) -> Unit = {}, -) { - NavigationRailInObiter( - screens = screens, - currentScreen = currentScreen, - spatialConfiguration = spatialConfiguration, - onScreenSelected = onScreenSelected, - ) -} - -@OptIn(ExperimentalMaterial3XrApi::class) -@Composable -private fun NavigationRailInObiter( - screens: List, - currentScreen: Screens, - spatialConfiguration: SpatialConfiguration = LocalSpatialConfiguration.current, - onScreenSelected: (Screens) -> Unit = {}, -) { - NavigationRail { - screens.forEach { screen -> - val isSelected = screen == currentScreen - NavigationRailItem( - selected = isSelected, - onClick = { - if (!isSelected) { - onScreenSelected(screen) - } - }, - icon = { - Icon( - imageVector = ImageVector.vectorResource(screen.navIcon), - contentDescription = screen.name, - tint = MaterialTheme.colorScheme.primary, - ) - }, - label = { - Text(screen.name, color = MaterialTheme.colorScheme.primary) - }, - ) - } - NavigationRailItem( - selected = false, - onClick = spatialConfiguration::requestHomeSpaceMode, - icon = { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_collapse_content), - modifier = Modifier.size(48.dp), - contentDescription = stringResource(R.string.home_space_mode), - tint = MaterialTheme.colorScheme.primary, - ) - }, - label = { - Text( - stringResource(R.string.home_space_mode), - color = MaterialTheme.colorScheme.primary, - ) - }, - ) - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/AppWithTopBarNavigation.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/AppWithTopBarNavigation.kt deleted file mode 100644 index 5bb36ad2..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/AppWithTopBarNavigation.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withTopBarNavigation - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import com.google.jetstream.presentation.app.AppState -import com.google.jetstream.presentation.components.KeyboardShortcut -import com.google.jetstream.presentation.components.handleKeyboardShortcuts -import com.google.jetstream.presentation.components.onBackButtonPressed -import com.google.jetstream.presentation.screens.Screens - -@Composable -fun AppWithTopBarNavigation( - appState: AppState, - navController: NavHostController, - keyboardShortcuts: List, - onActivityBackPressed: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ((padding: PaddingValues) -> Unit), -) { - Surface { - TopBarWithNavigationLayout( - selectedScreen = appState.selectedScreen, - isNavigationVisible = appState.isNavigationVisible, - isTopBarVisible = appState.isNavigationVisible && appState.isTopBarVisible, - isTopBarFocussed = appState.isTopBarFocused, - onTopBarFocusChanged = { hasFocus -> - appState.updateTopBarFocusState(hasFocus) - }, - onTopBarVisible = { appState.showTopBar() }, - onActivityBackPressed = onActivityBackPressed, - onShowScreen = { screen -> - navController.navigate(screen()) - }, - modifier = modifier.fillMaxSize().handleKeyboardShortcuts(keyboardShortcuts), - ) { - // TODO: This is to keep things consistent with the other layouts, however, - // we should consider whether it's necessary to always apply padding to the - // main content - content(PaddingValues(0.dp)) - } - } -} - -@Composable -fun TopBarWithNavigationLayout( - selectedScreen: Screens, - isNavigationVisible: Boolean, - isTopBarVisible: Boolean, - isTopBarFocussed: Boolean, - onTopBarVisible: () -> Unit, - onTopBarFocusChanged: (Boolean) -> Unit, - onShowScreen: (Screens) -> Unit, - onActivityBackPressed: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val topBar = remember { FocusRequester() } - - Column( - modifier = - modifier.onBackButtonPressed { - when { - // TODO: This logic is difficult to understand and should be refactored - // The VideoPlayer screen doesn't have any navigation - // The MovieDetails screen doesn't have any navigation when it's displayed in a - // TopBar layout. - // These are the only two scenarios where appState.isNavigationVisible is false - !isNavigationVisible -> { - onActivityBackPressed() - } - - // If the top bar isn't visible then show it - my guess is this is to handle - // the case where the user has scrolled down and the top menu has disappeared. - // When testing this on the TV emulator, the app just quits when I tap back. - !isTopBarVisible -> { - onTopBarVisible() - topBar.requestFocus() - } - - // If the top bar isn't focussed then focus it - !isTopBarFocussed -> { - topBar.requestFocus() - } - - // It feels strange to be doing conditional navigation here - selectedScreen != Screens.Home -> { - onShowScreen(Screens.Home) - } - - else -> { - onActivityBackPressed() - } - } - }, - ) { - // TODO: Consider refactoring this into a slot - AnimatedVisibility(isTopBarVisible) { - TopBar( - Screens.tabScreens, - selectedScreen, - { - if (it != selectedScreen) { - onShowScreen(it) - } - }, - modifier = - Modifier - .padding( - vertical = 16.dp, - horizontal = 74.dp, - ) - .focusRequester(topBar) - .onFocusChanged { onTopBarFocusChanged(it.hasFocus) }, - ) - } - content() - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/TopBar.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/TopBar.kt deleted file mode 100644 index a2da2779..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/withTopBarNavigation/TopBar.kt +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.app.withTopBarNavigation - -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalMediaQueryApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1 -import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2 -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.app.JetStreamLogo -import com.google.jetstream.presentation.app.UserAvatar -import com.google.jetstream.presentation.components.feature.EngagementMode -import com.google.jetstream.presentation.components.feature.InputModality -import com.google.jetstream.presentation.components.feature.JetStreamUiMedia -import com.google.jetstream.presentation.components.feature.LocalEngagementMode -import com.google.jetstream.presentation.screens.Screens - -@OptIn(ExperimentalMediaQueryApi::class) -@Composable -internal fun TopBar( - items: List, - selectedScreen: Screens, - onShowScreen: (Screens) -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - val (tabRow, avatar) = remember { FocusRequester.createRefs() } - - val isDpadAvailable = LocalEngagementMode.current == EngagementMode.Leanback - - // TODO: Is this a bug? - // If I run the app on the TV emulator, nothing happens when I click on the top navigation items - val onClickHandler: (Screens) -> Unit = - remember(isDpadAvailable) { - if (isDpadAvailable) { - { focusManager.moveFocus(FocusDirection.Down) } - } else { - { onShowScreen(it) } - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .focusProperties { - onEnter = { - when (selectedScreen) { - Screens.Profile -> avatar.requestFocus() - else -> tabRow.requestFocus() - } - } - } - .focusGroup(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box(modifier = Modifier.padding(8.dp)) { - UserAvatar( - modifier = - Modifier - .size(32.dp) - .semantics { - contentDescription = - StringConstants.Composable.ContentDescription.UserAvatar - } - .focusRequester(avatar), - selected = selectedScreen == Screens.Profile, - onClick = { onShowScreen(Screens.Profile) }, - ) - } - TopBarTabRow( - tabs = items, - selectedScreen = selectedScreen, - onClick = onClickHandler, - onTabSelected = onShowScreen, - modifier = - Modifier - .weight(1f) - .focusRequester(tabRow), - ) - Spacer(modifier.weight(0.1f)) - JetStreamLogo( - modifier = - Modifier - .alpha(0.75f) - .padding(end = 8.dp), - ) - } -} - -@Composable -private fun TopBarTabRow( - tabs: List, - selectedScreen: Screens, - onTabSelected: (Screens) -> Unit, - onClick: (Screens) -> Unit, - modifier: Modifier = Modifier, -) { - val items = - remember(tabs) { - tabs.map { it to FocusRequester() } - } - - var selectedScreenIndex by rememberSaveable(tabs) { - mutableIntStateOf(selectedTabIndex(tabs, selectedScreen, 0)) - } - - selectedScreenIndex = selectedTabIndex(tabs, selectedScreen, selectedScreenIndex) - PrimaryTabRow( - selectedTabIndex = selectedScreenIndex, - divider = {}, - modifier = - modifier - .focusProperties { - onEnter = { - if (selectedScreenIndex < items.size) { - items[selectedScreenIndex].second.requestFocus() - } else { - items[0].second.requestFocus() - } - } - } - .focusGroup(), - ) { - items.forEach { (screen, focusRequester) -> - key(screen) { - TopBarTab( - screen = screen, - selected = selectedScreen == screen, - onClick = { onClick(screen) }, - onSelect = { onTabSelected(screen) }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - } -} - -private fun selectedTabIndex( - tabs: List, - selectedScreen: Screens, - previouslySelectedTabIndex: Int, -): Int { - val index = tabs.indexOf(selectedScreen) % tabs.size - return when { - index < 0 -> previouslySelectedTabIndex - else -> index - } -} - -@Composable -private fun TopBarTab( - screen: Screens, - onClick: () -> Unit, - onSelect: () -> Unit, - modifier: Modifier = Modifier, - selected: Boolean = false, -) { - Tab( - selected = selected, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) { - onSelect() - } - } - .then(modifier), - onClick = onClick, - ) { - TopBarTabContent(screen) - } -} - -@Composable -private fun TopBarTabContent( - screen: Screens, - modifier: Modifier = Modifier, -) { - if (screen.tabIcon != null) { - Icon( - screen.tabIcon, - modifier = - Modifier - .padding(4.dp) - .then(modifier), - contentDescription = - StringConstants.Composable - .ContentDescription.DashboardSearchButton, - tint = LocalContentColor.current, - ) - } else { - Text( - modifier = modifier, - text = screen.name, - style = - MaterialTheme.typography.titleSmall.copy( - color = LocalContentColor.current, - ), - ) - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt index 01983bb2..933876e7 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt @@ -99,7 +99,7 @@ fun MoviesRow( ) { Column( modifier = - modifier + Modifier .styleable(style = style) .focusGroup(), ) { @@ -107,7 +107,7 @@ fun MoviesRow( MovieList( movieList = movieList, contentPadding = LocalContentPadding.current.intoPaddingValues(), - modifier = Modifier.focusRestorer(), + modifier = modifier.focusRestorer(), itemContent = itemContent, ) } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/feature/EngagementMode.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/feature/EngagementMode.kt index a4c156c6..efdd153b 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/feature/EngagementMode.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/feature/EngagementMode.kt @@ -26,8 +26,6 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.ui.ExperimentalMediaQueryApi import androidx.compose.ui.UiMediaScope -import androidx.compose.ui.unit.dp -import androidx.window.core.layout.WindowSizeClass sealed interface EngagementMode { val isBackButtonRequired: Boolean @@ -81,6 +79,12 @@ fun currentEngagementMode(): State { EngagementMode.Enclosed } + windowSizeClass.isIWidthCompact() -> { + EngagementMode.Compact( + isBackButtonRequired = inputModality == InputModality.PointingDevice, + ) + } + viewingDistance == UiMediaScope.ViewingDistance.Medium -> { EngagementMode.Cabin } @@ -95,12 +99,6 @@ fun currentEngagementMode(): State { ) } - windowSizeClass.isIWidthCompact() -> { - EngagementMode.Compact( - isBackButtonRequired = inputModality == InputModality.PointingDevice, - ) - } - else -> { EngagementMode.Medium( isBackButtonRequired = inputModality == InputModality.PointingDevice, diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/ModifierKeys.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/ModifierKeys.kt index ef20feb6..a40afbb7 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/ModifierKeys.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/ModifierKeys.kt @@ -34,16 +34,22 @@ data class ModifierKeys( companion object { /** No modifier keys pressed. */ val None = ModifierKeys() + /** Ctrl key pressed. */ val Ctrl = ModifierKeys(ctrl = true) + /** Shift key pressed. */ val Shift = ModifierKeys(shift = true) + /** Alt key pressed. */ val Alt = ModifierKeys(alt = true) + /** Ctrl and Shift keys pressed. */ val CtrlShift = ModifierKeys(ctrl = true, shift = true) + /** Ctrl and Alt keys pressed. */ val CtrlAlt = ModifierKeys(ctrl = true, alt = true) + /** Ctrl, Shift, and Alt keys pressed. */ val CtrlShiftAlt = ModifierKeys(ctrl = true, shift = true, alt = true) } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt deleted file mode 100644 index a0df059a..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/Screens.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens - -import androidx.annotation.DrawableRes -import androidx.annotation.IntRange -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import com.google.jetstream.R -import com.google.jetstream.data.convert.TryFrom -import com.google.jetstream.presentation.app.NavigationComponentType -import com.google.jetstream.presentation.screens.categories.CategoryMovieListScreen -import com.google.jetstream.presentation.screens.moviedetails.MovieDetailsScreen -import com.google.jetstream.presentation.screens.videoPlayer.VideoPlayerScreen - -/** - * Represents a screen. - * - * @param args - The navigation arguments for the screen. - * @param isTabItem - indicates whether this screen's icon can appear in a tab. - * @param isMainNavigation - indicates whether this is a top level screen that will appear in the - * main navigation area. - * @param tabIcon - The icon to be shown in the tab. Only applicable if this is a tab item. - * @param shouldShowNavigation - A function that indicates whether the navigation area should be - * shown for the given `NavigationComponentType`. - * @param navIcon - The icon to be shown in the navigation area. Only applicable if this is a main - * navigation item. - * // TODO: Remove this XR-specific field if possible - * @param xrContainerColor - Overrides the default screen container color on XR layouts - */ -enum class Screens( - private val args: List? = null, - val isTabItem: Boolean = false, - val isMainNavigation: Boolean = false, - // TODO: Can we remove either tabIcon or navIcon? - val tabIcon: ImageVector? = null, - val shouldShowNavigation: (NavigationComponentType) -> Boolean = { true }, - @DrawableRes val navIcon: Int = 0, - val xrContainerColor: Color? = null, -) { - Profile, - Home(isTabItem = true, isMainNavigation = true, navIcon = R.drawable.ic_home), - Categories(isTabItem = true, isMainNavigation = true, navIcon = R.drawable.ic_category), - Movies(isTabItem = true, isMainNavigation = true, navIcon = R.drawable.ic_movies), - Shows(isTabItem = true, isMainNavigation = true, navIcon = R.drawable.ic_shows), - Favourites(isTabItem = true, isMainNavigation = true, navIcon = R.drawable.ic_favorites), - Search(isTabItem = true, tabIcon = Icons.Default.Search, navIcon = R.drawable.ic_search), - CategoryMovieList(listOf(CategoryMovieListScreen.CATEGORY_ID_BUNDLE_KEY)), - MovieDetails( - args = listOf(MovieDetailsScreen.MOVIE_ID_BUNDLE_KEY), - // Don't show the navigation in the top bar - shouldShowNavigation = { it != NavigationComponentType.TopBar }, - ), - VideoPlayer( - listOf(VideoPlayerScreen.MOVIE_ID_BUNDLE_KEY), - shouldShowNavigation = { false }, - // Workaround to make video player visible. - xrContainerColor = Color.Transparent, - ), - ; - - operator fun invoke(): String { - val argList = StringBuilder() - args?.let { nnArgs -> - nnArgs.forEach { arg -> argList.append("/{$arg}") } - } - return name + argList - } - - fun withArgs(vararg args: Any): String { - val destination = StringBuilder() - args.forEach { arg -> destination.append("/$arg") } - return name + destination - } - - fun toIndex(): Int { - return entries.indexOf(this) - } - - companion object : TryFrom { - fun fromIndex( - @IntRange(from = 0) index: Int, - ): Screens? { - return when { - index < 0 -> null - index >= entries.size -> null - else -> entries[index] - } - } - - override fun tryFrom(from: String): Screens? { - return when (from) { - Profile() -> Profile - Home() -> Home - Categories() -> Categories - Movies() -> Movies - Shows() -> Shows - Favourites() -> Favourites - Search() -> Search - CategoryMovieList() -> CategoryMovieList - MovieDetails() -> MovieDetails - VideoPlayer() -> VideoPlayer - else -> null - } - } - - val tabScreens: List = Screens.entries.filter { it.isTabItem } - val mainNavigationScreens: List = Screens.entries.filter { it.isMainNavigation } - } - - @Composable - fun xrContainerColor(): Color = xrContainerColor ?: MaterialTheme.colorScheme.background -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt index f61114c1..3a4652af 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt @@ -16,40 +16,41 @@ package com.google.jetstream.presentation.screens.categories -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.jetstream.data.entities.MovieCategoryDetails import com.google.jetstream.data.repositories.MovieRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = CategoryMovieListScreenViewModel.Factory::class) class CategoryMovieListScreenViewModel - @Inject + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, + @Assisted categoryId: String, movieRepository: MovieRepository, ) : ViewModel() { val uiState = - savedStateHandle.getStateFlow( - CategoryMovieListScreen.CATEGORY_ID_BUNDLE_KEY, - null, - ).map { id -> - if (id == null) { - CategoryMovieListScreenUiState.Error - } else { + flowOf(categoryId) + .map { id -> val categoryDetails = movieRepository.getMovieCategoryDetails(id) CategoryMovieListScreenUiState.Done(categoryDetails) - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = CategoryMovieListScreenUiState.Loading, - ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CategoryMovieListScreenUiState.Loading, + ) + + @AssistedFactory + interface Factory { + fun create(categoryId: String): CategoryMovieListScreenViewModel + } } sealed interface CategoryMovieListScreenUiState { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt index 25900e2d..493350ab 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt @@ -17,50 +17,23 @@ package com.google.jetstream.presentation.screens.favourites.components import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.window.core.layout.WindowSizeClass -import com.google.jetstream.presentation.app.NavigationComponentType -import com.google.jetstream.presentation.app.rememberNavigationComponentType +import com.google.jetstream.presentation.components.feature.EngagementMode +import com.google.jetstream.presentation.components.feature.LocalEngagementMode @Composable -fun rememberFilteredMoviesGridColumns( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, -): GridCells { - return remember(navigationComponentType, windowSizeClass) { - calculateFilteredMoviesGridColumns(navigationComponentType, windowSizeClass) +fun rememberFilteredMoviesGridColumns(): GridCells { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + engagementMode.filteredMovieGridColumns() } } -private fun calculateFilteredMoviesGridColumns( - navigationComponentType: NavigationComponentType, - windowSizeClass: WindowSizeClass, -): GridCells { - return when (navigationComponentType) { - NavigationComponentType.TopBar -> { - GridCells.Fixed(6) - } - - else -> { - windowSizeClass.filteredMoviesGridColumns() - } - } -} - -private fun WindowSizeClass.filteredMoviesGridColumns(): GridCells { - return when { - isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> { - GridCells.Fixed(6) - } - - isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> { - GridCells.Fixed(4) - } - - else -> { - GridCells.Fixed(3) - } +private fun EngagementMode.filteredMovieGridColumns(): GridCells { + return when (this) { + is EngagementMode.Compact -> GridCells.Fixed(3) + is EngagementMode.Medium -> GridCells.Fixed(4) + else -> GridCells.Fixed(6) } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt index c7b35afc..ae52fe2d 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt @@ -16,6 +16,7 @@ package com.google.jetstream.presentation.screens.home +import android.util.Log import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.focusGroup import androidx.compose.foundation.gestures.LocalBringIntoViewSpec @@ -27,6 +28,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.style.ExperimentalFoundationStyleApi import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -152,7 +155,7 @@ internal fun Catalog( } } -@OptIn(ExperimentalFoundationStyleApi::class) +@OptIn(ExperimentalFoundationStyleApi::class, ExperimentalFoundationApi::class) @Composable private fun Catalog( featuredMovies: List, @@ -168,6 +171,14 @@ private fun Catalog( ) { val contentPadding = LocalContentPadding.current val (carousel, trending, top10, nowPlaying) = remember { FocusRequester.createRefs() } + val bringIntoViewRequesterForTrending = remember { BringIntoViewRequester() } + var isTrendingFocused by remember { mutableStateOf(false) } + + LaunchedEffect(LocalBringIntoViewSpec.current) { + if (isTrendingFocused) { + bringIntoViewRequesterForTrending.bringIntoView() + } + } LazyColumn( state = lazyListState, @@ -200,7 +211,12 @@ private fun Catalog( } .focusGroup(), style = { - contentPadding(start = contentPadding.start, end = contentPadding.end, top = 0.dp, bottom = 0.dp) + contentPadding( + start = contentPadding.start, + end = contentPadding.end, + top = 0.dp, + bottom = 0.dp, + ) }, ) } @@ -209,7 +225,19 @@ private fun Catalog( movieList = trendingMovies, title = StringConstants.Composable.HomeScreenTrendingTitle, onMovieSelected = onMovieClick, - modifier = Modifier.focusRequester(trending), + modifier = + Modifier + .focusRequester(trending) + .bringIntoViewRequester(bringIntoViewRequesterForTrending) + .focusProperties { + onEnter = { + if (requestedFocusDirection == FocusDirection.Up) { + isTrendingFocused = true + } + } + onExit = { isTrendingFocused = false } + } + .focusGroup(), ) } item(contentType = "Top10MoviesList") { @@ -218,7 +246,10 @@ private fun Catalog( onMovieClick = onMovieClick, modifier = Modifier.focusRequester(top10), onExpanded = onExpanded, - onCollapsed = onCollapsed, + onCollapsed = { + onCollapsed() + Log.d("Home", "onCollapsed: $isTrendingFocused") + }, ) } item(contentType = "MoviesRow") { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt index 0041b3b4..ef605791 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt @@ -16,39 +16,40 @@ package com.google.jetstream.presentation.screens.moviedetails -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.data.repositories.MovieRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = MovieDetailsScreenViewModel.Factory::class) class MovieDetailsScreenViewModel - @Inject + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, + @Assisted movieId: String, repository: MovieRepository, ) : ViewModel() { val uiState = - savedStateHandle - .getStateFlow(MovieDetailsScreen.MOVIE_ID_BUNDLE_KEY, null) - .map { id -> - if (id == null) { - MovieDetailsScreenUiState.Error - } else { - val details = repository.getMovieDetails(movieId = id) - MovieDetailsScreenUiState.Done(movieDetails = details) - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = MovieDetailsScreenUiState.Loading, - ) + flowOf(movieId).map { id -> + val details = repository.getMovieDetails(movieId = id) + MovieDetailsScreenUiState.Done(movieDetails = details) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MovieDetailsScreenUiState.Loading, + ) + + @AssistedFactory + interface Factory { + fun create(movieId: String): MovieDetailsScreenViewModel + } } sealed class MovieDetailsScreenUiState { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt index 5fa0cd0b..17f40612 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt @@ -16,426 +16,131 @@ package com.google.jetstream.presentation.screens.profile -import androidx.annotation.FloatRange -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.MutableStyleState +import androidx.compose.foundation.style.Style +import androidx.compose.foundation.style.fillWidth +import androidx.compose.foundation.style.styleable import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController import com.google.jetstream.R -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.screens.profile.compoents.AboutSection -import com.google.jetstream.presentation.screens.profile.compoents.AccountsSection -import com.google.jetstream.presentation.screens.profile.compoents.HelpAndSupportSection -import com.google.jetstream.presentation.screens.profile.compoents.LanguageSection -import com.google.jetstream.presentation.screens.profile.compoents.ProfileScreenLayoutType -import com.google.jetstream.presentation.screens.profile.compoents.ProfileScreens -import com.google.jetstream.presentation.screens.profile.compoents.SearchHistorySection -import com.google.jetstream.presentation.screens.profile.compoents.SubtitlesSection -import com.google.jetstream.presentation.screens.profile.compoents.rememberProfileScreenLayoutType -import com.google.jetstream.presentation.theme.JetStreamTheme +import com.google.jetstream.presentation.app.Destination +import com.google.jetstream.presentation.components.shim.indication.borderIndication +import com.google.jetstream.presentation.components.shim.indication.scaleIndication import com.google.jetstream.presentation.theme.LocalContentPadding -import com.google.jetstream.presentation.theme.Padding -import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationStyleApi::class) @Composable fun ProfileScreen( - @FloatRange(from = 0.0, to = 1.0) sidebarWidthFraction: Float = 0.32f, - profileScreenLayoutType: ProfileScreenLayoutType = rememberProfileScreenLayoutType(), + onDestinationSelected: (Destination) -> Unit, ) { - when (profileScreenLayoutType) { - ProfileScreenLayoutType.FullyExpanded -> { - LargeProfileScreen(sidebarWidthFraction) - } - - ProfileScreenLayoutType.ListDetail -> { - CompactProfileScreen() + LazyColumn( + contentPadding = LocalContentPadding.current.intoPaddingValues(), + ) { + items(Destination.ProfileSettings) { destination -> + SectionListItem( + destination = destination, + onClick = { + onDestinationSelected(destination) + }, + ) } } } +@OptIn(ExperimentalFoundationStyleApi::class) @Composable -private fun LargeProfileScreen( - sidebarWidthFraction: Float, - contentPadding: Padding = LocalContentPadding.current, +private fun SectionListItem( + destination: Destination, + modifier: Modifier = Modifier, + style: Style = Style, + onClick: () -> Unit = {}, ) { - val profileNavController = rememberNavController() - - val backStack by profileNavController.currentBackStackEntryAsState() - - val focusRequester = remember { FocusRequester() } - val focusManager = LocalFocusManager.current - var isLeftColumnFocused by remember { mutableStateOf(false) } - - var selectedLanguageIndex by rememberSaveable { mutableIntStateOf(0) } - var isSubtitlesChecked by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(Unit) { focusRequester.requestFocus() } - - Row( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = contentPadding.start, vertical = contentPadding.top), - ) { - ListPane( - currentDestination = backStack?.destination?.route ?: ProfileScreens.Accounts(), - onSelected = { - profileNavController.navigate(it) { - popUpTo(it) { - inclusive = true - } - launchSingleTop = true - } - }, - modifier = - Modifier - .fillMaxWidth(fraction = sidebarWidthFraction) - .verticalScroll(rememberScrollState()) - .fillMaxHeight() - .onFocusChanged { - isLeftColumnFocused = it.hasFocus - } - .focusRestorer() - .focusGroup(), - verticalArrangement = Arrangement.spacedBy(12.dp), - focusRequester = focusRequester, + val icon = destination.icon + val name = destination.name + val textStyle = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, ) - NavHost( - modifier = - Modifier - .fillMaxSize() - .onPreviewKeyEvent { - when { - it.key == Key.Back && it.type == KeyEventType.KeyUp -> { - while (!isLeftColumnFocused) { - focusManager.moveFocus(FocusDirection.Left) - } - true - } - else -> { - false - } - } - } - .focusGroup(), - navController = profileNavController, - startDestination = ProfileScreens.Accounts(), - builder = { - composable(ProfileScreens.Accounts()) { - AccountsSection() - } - composable(ProfileScreens.About()) { - AboutSection() - } - composable(ProfileScreens.Subtitles()) { - SubtitlesSection( - isSubtitlesChecked = isSubtitlesChecked, - onSubtitleCheckChange = { isSubtitlesChecked = it }, - ) - } - composable(ProfileScreens.Language()) { - LanguageSection( - selectedIndex = selectedLanguageIndex, - onSelectedIndexChange = { selectedLanguageIndex = it }, - ) - } - composable(ProfileScreens.SearchHistory()) { - SearchHistorySection() - } - composable(ProfileScreens.HelpAndSupport()) { - HelpAndSupportSection() - } - }, - ) - } -} - -@Composable -private fun ListPane( - currentDestination: String, - onSelected: (String) -> Unit, - modifier: Modifier = Modifier, - verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(12.dp), - focusRequester: FocusRequester = remember { FocusRequester() }, -) { - Column( - modifier = modifier, - verticalArrangement = verticalArrangement, - ) { - ProfileScreens.entries.forEachIndexed { index, profileScreen -> - key(index) { - ListItem( - trailingContent = { - Icon( - profileScreen.icon, - modifier = - Modifier - .padding(vertical = 2.dp) - .padding(start = 4.dp) - .size(20.dp), - contentDescription = - stringResource( - id = R.string.profile_screen_listItem_icon_content_description, - profileScreen.tabTitle, - ), - ) - }, - headlineContent = { - Text( - text = profileScreen.tabTitle, - style = - MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Medium, - ), - modifier = Modifier.fillMaxWidth(), - ) - }, - modifier = - Modifier - .fillMaxWidth() - .then( - if (index == 0) { - Modifier.focusRequester(focusRequester) - } else { - Modifier - }, - ) - .onFocusChanged { - if (it.isFocused && currentDestination != profileScreen.name) { - onSelected(profileScreen()) - } - } - .clickable { onSelected(profileScreen()) }, - ) - } + val interactionSource = + remember(destination) { + MutableInteractionSource() + } + val styleState = + remember(interactionSource) { + MutableStyleState(interactionSource = interactionSource) } - } -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -private fun CompactProfileScreen() { - val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() - val scope = rememberCoroutineScope() - var lastSelected by remember { mutableStateOf(ProfileScreens.Accounts) } - - var selectedLanguageIndex by rememberSaveable { mutableIntStateOf(0) } - var isSubtitlesChecked by rememberSaveable { mutableStateOf(true) } - NavigableListDetailPaneScaffold( - navigator = scaffoldNavigator, - listPane = { - AnimatedPane { - Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp)) { - ProfileScreens.entries.forEachIndexed { index, profileScreen -> - key(index) { - CompactListItem( - profileScreen = profileScreen, - isSelected = lastSelected == profileScreen, - ) { - scope.launch { - scaffoldNavigator.navigateTo( - ListDetailPaneScaffoldRole.Detail, - profileScreen, - ) - } - lastSelected = profileScreen - } - } - } - } + val defaultStyle = + remember { + Style { + fillWidth() + borderIndication() + scaleIndication() } - }, - detailPane = { - AnimatedPane { - // Show the detail pane content if selected item is available - scaffoldNavigator.currentDestination?.contentKey?.let { profileScreen -> - Column(modifier = Modifier.fillMaxSize()) { - if (scaffoldNavigator.canNavigateBack()) { - CompactListItem( - profileScreen = profileScreen as ProfileScreens, - showBackButton = true, - onClick = { - scope.launch { - scaffoldNavigator.navigateBack() - } - }, - ) - } - when (profileScreen) { - ProfileScreens.Accounts -> { - AccountsSection(numberOfColumns = 1) - } - - ProfileScreens.About -> { - AboutSection() - } - - ProfileScreens.Subtitles -> { - SubtitlesSection( - isSubtitlesChecked = isSubtitlesChecked, - onSubtitleCheckChange = { isSubtitlesChecked = it }, - ) - } - - ProfileScreens.Language -> { - LanguageSection( - selectedIndex = selectedLanguageIndex, - onSelectedIndexChange = { selectedLanguageIndex = it }, - ) - } - - ProfileScreens.SearchHistory -> { - SearchHistorySection() - } + } - ProfileScreens.HelpAndSupport -> { - HelpAndSupportSection() - } - } - } - } + ListItem( + trailingContent = { + if (icon != null) { + Icon( + painter = icon, + modifier = + Modifier.styleable { + externalPaddingTop(2.dp) + externalPaddingBottom(2.dp) + externalPaddingStart(4.dp) + size(20.dp) + }, + contentDescription = + stringResource( + id = R.string.profile_screen_listItem_icon_content_description, + name, + ), + ) } }, - ) -} - -@Composable -private fun CompactListItem( - profileScreen: ProfileScreens, - isSelected: Boolean = false, - showBackButton: Boolean = false, - onClick: () -> Unit = {}, -) { - Row( - modifier = - Modifier - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showBackButton) { - Icon( - Icons.Default.ArrowBackIosNew, + headlineContent = { + Text( + text = name, + style = textStyle, modifier = - Modifier - .padding(vertical = 2.dp) - .padding(start = 4.dp) - .size(36.dp), - contentDescription = - stringResource( - id = R.string.back_content_description, - profileScreen.tabTitle, - ), + Modifier.styleable { + fillWidth() + textStyle(textStyle) + }, ) - Spacer(modifier = Modifier.size(12.dp)) - } - Icon( - profileScreen.icon, - modifier = - Modifier - .padding(vertical = 2.dp) - .padding(start = 4.dp) - .size(36.dp), - contentDescription = - stringResource( - id = R.string.profile_screen_listItem_icon_content_description, - profileScreen.tabTitle, - ), - ) - Spacer(modifier = Modifier.size(12.dp)) - Text( - text = profileScreen.tabTitle, - style = - MaterialTheme.typography.titleLarge.copy( - fontWeight = FontWeight.Medium, - ), - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@TvPreview -@Composable -fun ProfileScreenTvPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.FullyExpanded) - } - } -} - -@FoldablePreview -@Composable -fun ProfileScreenFoldablePreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.ListDetail) - } - } -} - -@PhonePreview -@Composable -fun ProfileScreenPhonePreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.ListDetail) - } - } + }, + modifier = + modifier + .styleable( + styleState = styleState, + defaultStyle, + style, + ) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .semantics { + role = Role.Button + }, + ) } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AboutSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AboutSection.kt deleted file mode 100644 index 0b5e42b6..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AboutSection.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import android.content.Context -import android.content.pm.PackageManager -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme - -@Composable -fun AboutSection(isExpanded: Boolean = true) { - val context = LocalContext.current - val versionNumber = - if (LocalInspectionMode.current) { - "Preview version" - } else { - remember(context) { - context.getVersionNumber() - } - } - - val padding = if (isExpanded) 72.dp else 16.dp - with(StringConstants.Composable.Placeholders) { - Column(modifier = Modifier.padding(horizontal = padding)) { - if (isExpanded) { - Text( - text = AboutSectionTitle, - style = MaterialTheme.typography.headlineSmall, - ) - } - Text( - modifier = - Modifier - .graphicsLayer { alpha = 0.8f } - .padding(top = 16.dp), - text = AboutSectionDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Box( - modifier = - Modifier - .padding(top = 16.dp) - .fillMaxWidth() - .height(2.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.6f)), - ) - Text( - modifier = - Modifier - .graphicsLayer { alpha = 0.6f } - .padding(top = 16.dp), - text = AboutSectionAppVersionTitle, - style = MaterialTheme.typography.labelMedium, - ) - Text( - modifier = Modifier.padding(top = 8.dp), - text = versionNumber, - style = MaterialTheme.typography.labelLarge, - ) - } - } -} - -private fun Context.getVersionNumber(): String { - val packageName = packageName - val metaData = packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA) - return metaData.versionName!! -} - -@PhonePreview -@FoldablePreview -@Composable -fun AboutSectionCompactPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - AboutSection(isExpanded = false) - } - } -} - -@TvPreview -@Composable -fun AboutSectionExpandedPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - AboutSection(isExpanded = true) - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt index eea459f4..25a64c60 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt @@ -16,126 +16,75 @@ package com.google.jetstream.presentation.screens.profile.compoents -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.ExperimentalFlexBoxApi +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.FlexBox +import androidx.compose.foundation.layout.FlexDirection +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.fillSize +import androidx.compose.foundation.style.styleable import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme +import com.google.jetstream.presentation.components.shim.stylable.StylableCard +import com.google.jetstream.presentation.screens.profile.section.AccountsSectionData +@OptIn( + ExperimentalFoundationStyleApi::class, + ExperimentalGridApi::class, + ExperimentalFlexBoxApi::class, +) @Composable fun AccountsSelectionItem( accountsSectionData: AccountsSectionData, modifier: Modifier = Modifier, - isExpanded: Boolean = true, - key: Any? = null, ) { - key(key) { - Surface( + val backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + StylableCard( + modifier = modifier, + enabled = accountsSectionData.onClick != null, + onClick = accountsSectionData.onClick ?: {}, + style = { + background(backgroundColor) + }, + ) { + val value = accountsSectionData.value + + FlexBox( + config = { + direction(FlexDirection.Column) + }, modifier = - modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)) - .clickable(onClick = accountsSectionData.onClick), - shape = MaterialTheme.shapes.extraSmall, + Modifier.styleable { + fillSize() + contentPadding(16.dp) + }, ) { - if (isExpanded) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.Bottom, - ) { - AccountData(accountsSectionData) - } - } else { - Row( + Text( + text = accountsSectionData.title, + style = + MaterialTheme.typography.titleSmall.copy( + fontSize = 15.sp, + ), + ) + if (value != null) { + Text( + text = value, + style = + MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Normal, + ), modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - AccountData(accountsSectionData) - } + Modifier.styleable { + alpha(0.75f) + }, + ) } } } } - -@Composable -private fun AccountData(accountsSectionData: AccountsSectionData) { - Text( - text = accountsSectionData.title, - style = - MaterialTheme.typography.titleSmall.copy( - fontSize = 15.sp, - ), - ) - Spacer(modifier = Modifier.padding(vertical = 2.dp)) - accountsSectionData.value?.let { nnValue -> - Text( - text = nnValue, - style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Normal, - ), - modifier = Modifier.alpha(0.75f), - ) - } -} - -@PhonePreview -@FoldablePreview -@Composable -fun AccountSelectionItemPreview() { - val mockData = - AccountsSectionData( - title = - StringConstants.Composable.Placeholders - .AccountsSelectionViewSubscriptionsTitle, - ) - - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - AccountsSelectionItem(mockData, isExpanded = false) - } - } -} - -@TvPreview -@Composable -fun AccountSelectionItemTvPreview() { - val mockData = - AccountsSectionData( - title = - StringConstants.Composable.Placeholders - .AccountsSelectionViewSubscriptionsTitle, - ) - - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - AccountsSelectionItem(mockData, isExpanded = true) - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/HelpAndSupportSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/HelpAndSupportSection.kt deleted file mode 100644 index e0c4e0cd..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/HelpAndSupportSection.kt +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme - -@Composable -fun HelpAndSupportSection(isExpanded: Boolean = true) { - val padding = if (isExpanded) 72.dp else 16.dp - with(StringConstants.Composable.Placeholders) { - Column(modifier = Modifier.padding(horizontal = padding)) { - if (isExpanded) { - Text( - text = HelpAndSupportSectionTitle, - style = MaterialTheme.typography.headlineSmall, - ) - } - HelpAndSupportSectionItem(title = HelpAndSupportSectionFAQItem) - HelpAndSupportSectionItem(title = HelpAndSupportSectionPrivacyItem) - HelpAndSupportSectionItem( - title = HelpAndSupportSectionContactItem, - value = HelpAndSupportSectionContactValue, - isExpanded = isExpanded, - ) - } - } -} - -@Composable -private fun HelpAndSupportSectionItem( - title: String, - value: String? = null, - isExpanded: Boolean = true, -) { - if (isExpanded || value == null) { - ListItem( - modifier = - Modifier - .padding(top = 16.dp) - .clickable { }, - trailingContent = { - Icon( - Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = - StringConstants - .Composable - .Placeholders - .HelpAndSupportSectionListItemIconDescription, - ) - }, - headlineContent = { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - ), - ) - } else { - ListItem( - modifier = - Modifier - .padding(top = 16.dp) - .clickable { }, - headlineContent = { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - ) - }, - supportingContent = { - Text( - text = value, - style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Normal, - ), - modifier = Modifier.alpha(0.75f).padding(vertical = 4.dp), - ) - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - ), - ) - } -} - -@PhonePreview -@FoldablePreview -@Composable -fun HelpSectionCompactPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - HelpAndSupportSection(isExpanded = false) - } - } -} - -@TvPreview -@Composable -fun HelpSectionExpandedPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - HelpAndSupportSection() - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/LanguageSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/LanguageSection.kt deleted file mode 100644 index 9d8dccce..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/LanguageSection.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.google.jetstream.R -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme - -@Composable -fun LanguageSection( - selectedIndex: Int = 0, - isExpanded: Boolean = true, - onSelectedIndexChange: (currentIndex: Int) -> Unit = {}, -) { - val padding = if (isExpanded) 72.dp else 16.dp - with(StringConstants.Composable.Placeholders) { - LazyColumn(modifier = Modifier.padding(horizontal = padding)) { - if (isExpanded) { - item { - Text( - text = LanguageSectionTitle, - style = MaterialTheme.typography.headlineSmall, - ) - } - } - items(LanguageSectionItems.size) { index -> - ListItem( - modifier = - Modifier - .padding(top = 16.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(), - ) { - onSelectedIndexChange(index) - }, - trailingContent = { - if (selectedIndex == index) { - Icon( - Icons.Default.Check, - contentDescription = - stringResource( - id = - R.string.language_section_listItem_icon_content_description, - LanguageSectionItems[index], - ), - ) - } - }, - headlineContent = { - Text( - text = LanguageSectionItems[index], - style = MaterialTheme.typography.titleMedium, - ) - }, - ) - } - } - } -} - -@PhonePreview -@FoldablePreview -@Composable -fun LanguageScreenCompactPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - LanguageSection(isExpanded = false) { } - } - } -} - -@TvPreview -@Composable -fun LanguageScreenExpandedPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - LanguageSection(isExpanded = true) { } - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreenLayoutType.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreenLayoutType.kt deleted file mode 100644 index 3affde0f..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreenLayoutType.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.Composable -import androidx.window.core.layout.WindowSizeClass -import com.google.jetstream.presentation.app.NavigationComponentType -import com.google.jetstream.presentation.app.rememberNavigationComponentType -import com.google.jetstream.presentation.components.feature.isWidthAtLeastLarge - -enum class ProfileScreenLayoutType { - FullyExpanded, - ListDetail, -} - -@Composable -fun rememberProfileScreenLayoutType( - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), -): ProfileScreenLayoutType { - return when (navigationComponentType) { - NavigationComponentType.TopBar -> { - ProfileScreenLayoutType.FullyExpanded - } - - else -> { - when { - windowSizeClass.isWidthAtLeastLarge() -> ProfileScreenLayoutType.FullyExpanded - else -> ProfileScreenLayoutType.ListDetail - } - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt new file mode 100644 index 00000000..81b1f1dc --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.compoents + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle + +@Composable +fun ProfileSectionTitle( + title: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.headlineSmall, +) { + Text( + text = title, + modifier = modifier, + style = style, + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SearchHistorySection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SearchHistorySection.kt deleted file mode 100644 index ea628105..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SearchHistorySection.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Button -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme - -@Composable -fun SearchHistorySection(isExpanded: Boolean = true) { - val padding = if (isExpanded) 72.dp else 16.dp - with(StringConstants.Composable.Placeholders) { - LazyColumn(modifier = Modifier.padding(horizontal = padding)) { - if (isExpanded) { - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = SearchHistorySectionTitle, - style = MaterialTheme.typography.headlineSmall, - ) - ClearHistoryButton() - } - } - } - items(SampleSearchHistory.size) { index -> - ListItem( - modifier = - Modifier - .padding(top = 8.dp) - .clickable { }, - headlineContent = { - Text( - text = SampleSearchHistory[index], - style = MaterialTheme.typography.titleMedium, - ) - }, - ) - } - if (!isExpanded) { - item { - ClearHistoryButton(modifier = Modifier.fillMaxWidth()) - } - } - } - } -} - -@Composable -private fun ClearHistoryButton(modifier: Modifier = Modifier) { - Button( - onClick = { /* Clear search history */ }, - modifier = modifier.clickable { }, - ) { - Text(text = StringConstants.Composable.Placeholders.SearchHistoryClearAll) - } -} - -@PhonePreview -@FoldablePreview -@Composable -fun SearchHistorySectionCompactPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - SearchHistorySection(isExpanded = false) - } - } -} - -@TvPreview -@Composable -fun SearchHistorySectionExpandedPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - SearchHistorySection(isExpanded = true) - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt new file mode 100644 index 00000000..835b4d55 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.compoents + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.MutableStyleState +import androidx.compose.foundation.style.styleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalFoundationStyleApi::class) +@Composable +fun SelectionItem( + isSelected: Boolean, + name: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + colors: ListItemColors = ListItemDefaults.colors(), +) { + val interactionSource = remember { MutableInteractionSource() } + val styleState = + remember(interactionSource) { MutableStyleState(interactionSource = interactionSource) } + + ListItem( + headlineContent = { + SettingItemValue(name) + }, + trailingContent = { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = "Checked", + ) + } + }, + colors = colors, + modifier = + modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .styleable( + styleState = styleState, + style = { + // ToDo: add hover & focus state + }, + ), + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt new file mode 100644 index 00000000..102ee3eb --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.compoents + +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun SettingItem( + name: String, + value: String, + modifier: Modifier = Modifier, + colors: ListItemColors = SettingItemDefaults.colors, +) { + SettingItem( + title = { SettingItemName(name = name) }, + value = { SettingItemValue(value = value) }, + modifier = modifier, + colors = colors, + ) +} + +@Composable +fun SettingItem( + name: String, + value: @Composable () -> Unit, + modifier: Modifier = Modifier, + colors: ListItemColors = SettingItemDefaults.colors, +) { + SettingItem( + title = { SettingItemName(name = name) }, + value = value, + modifier = modifier, + colors = colors, + ) +} + +@Composable +fun SettingItem( + title: @Composable () -> Unit, + value: @Composable () -> Unit, + modifier: Modifier = Modifier, + colors: ListItemColors = SettingItemDefaults.colors, +) { + ListItem( + modifier = modifier, + colors = colors, + headlineContent = title, + trailingContent = value, + ) +} + +@Composable +fun SettingItemName( + name: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.labelLarge, +) { + Text( + text = name, + style = textStyle, + modifier = modifier, + ) +} + +@Composable +fun SettingItemValue( + value: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, +) { + Text( + text = value, + style = textStyle, + modifier = modifier, + ) +} + +object SettingItemDefaults { + val colors: ListItemColors + @Composable get() = + ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SubtitlesSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SubtitlesSection.kt deleted file mode 100644 index f85c78ed..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SubtitlesSection.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ListItem -import androidx.compose.material3.ListItemDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme - -@Composable -fun SubtitlesSection( - isSubtitlesChecked: Boolean, - isExpanded: Boolean = true, - onSubtitleCheckChange: (isChecked: Boolean) -> Unit, -) { - val padding = if (isExpanded) 72.dp else 16.dp - with(StringConstants.Composable.Placeholders) { - Column(modifier = Modifier.padding(horizontal = padding)) { - if (isExpanded) { - Text( - text = SubtitlesSectionTitle, - style = MaterialTheme.typography.headlineSmall, - ) - } - ListItem( - modifier = - Modifier - .padding(top = 16.dp) - .clickable { - onSubtitleCheckChange(!isSubtitlesChecked) - }, - trailingContent = { - Switch( - checked = isSubtitlesChecked, - onCheckedChange = onSubtitleCheckChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primaryContainer, - checkedTrackColor = MaterialTheme.colorScheme.primary, - ), - ) - }, - headlineContent = { - Text( - text = SubtitlesSectionSubtitlesItem, - style = MaterialTheme.typography.titleMedium, - ) - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - ), - ) - ListItem( - modifier = - Modifier - .padding(top = 16.dp) - .clickable { }, - trailingContent = { - Text( - text = SubtitlesSectionLanguageValue, - style = MaterialTheme.typography.labelLarge, - ) - }, - headlineContent = { - Text( - text = SubtitlesSectionLanguageItem, - style = MaterialTheme.typography.titleMedium, - ) - }, - colors = - ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp), - ), - ) - } - } -} - -@PhonePreview -@FoldablePreview -@Composable -fun SubtitlesSectionCompactPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - SubtitlesSection(isSubtitlesChecked = true, isExpanded = false) { } - } - } -} - -@TvPreview -@Composable -fun SubtitlesSectionExpandedPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - SubtitlesSection(isSubtitlesChecked = true) { } - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt new file mode 100644 index 00000000..2efd62aa --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.section + +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.styleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionAppVersionTitle +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionDescription +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle +import com.google.jetstream.presentation.theme.LocalContentPadding + +@OptIn(ExperimentalFoundationStyleApi::class) +@Composable +fun AboutSection() { + val versionNumber = versionNumber() + + Column( + modifier = + Modifier.padding( + LocalContentPadding.current.intoPaddingValues(), + ), + ) { + ProfileSectionTitle(title = AboutSectionTitle) + Text( + modifier = + Modifier.styleable { + alpha(0.8f) + externalPaddingTop(16.dp) + }, + text = AboutSectionDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box( + modifier = + Modifier + .padding(top = 16.dp) + .fillMaxWidth() + .height(2.dp) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.6f)), + ) + Text( + modifier = + Modifier.styleable { + alpha(0.6f) + externalPaddingTop(16.dp) + }, + text = AboutSectionAppVersionTitle, + style = MaterialTheme.typography.labelMedium, + ) + Text( + modifier = + Modifier.styleable { + externalPaddingTop(8.dp) + }, + text = versionNumber, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun versionNumber( + context: Context = LocalContext.current, + inspectionMode: Boolean = LocalInspectionMode.current, +): String { + return if (inspectionMode) { + "Preview version" + } else { + context.getVersionNumber() + } +} + +private fun Context.getVersionNumber(): String { + val packageName = packageName + val metaData = packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA) + return metaData.versionName!! +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt similarity index 58% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSection.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt index fc320832..866230a2 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt @@ -14,18 +14,14 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.section -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.fillWidth +import androidx.compose.foundation.style.styleable import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue @@ -33,32 +29,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.components.FoldablePreview -import com.google.jetstream.presentation.components.PhonePreview import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.theme.JetStreamTheme +import com.google.jetstream.presentation.screens.profile.compoents.AccountsSectionDeleteDialog +import com.google.jetstream.presentation.screens.profile.compoents.AccountsSelectionItem import com.google.jetstream.presentation.theme.LocalContentPadding -import com.google.jetstream.presentation.theme.Padding @Immutable data class AccountsSectionData( val title: String, val value: String? = null, - val onClick: () -> Unit = {}, + val onClick: (() -> Unit)? = null, ) +@OptIn(ExperimentalFoundationStyleApi::class) @TvPreview @Composable -fun AccountsSection( - numberOfColumns: Int = 2, - contentPadding: Padding = LocalContentPadding.current, -) { +fun AccountsSection() { var showDeleteDialog by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } + + // ToDo: Move the data definition outside of the composable val accountsSectionListItems = remember { listOf( @@ -92,34 +83,20 @@ fun AccountsSection( ), ) } - - LazyVerticalGrid( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = contentPadding.start), - columns = GridCells.Fixed(numberOfColumns), - content = { - val itemModifier = - if (numberOfColumns == 2) { - Modifier - .focusRequester(focusRequester) - .padding(8.dp) - .fillMaxWidth() - .aspectRatio(2f) - } else { - Modifier - } - items(accountsSectionListItems.size) { index -> - AccountsSelectionItem( - modifier = itemModifier, - key = index, - accountsSectionData = accountsSectionListItems[index], - isExpanded = numberOfColumns == 2, - ) - } - }, - ) + LazyColumn( + contentPadding = LocalContentPadding.current.intoPaddingValues(), + ) { + items(accountsSectionListItems) { data -> + AccountsSelectionItem( + modifier = + Modifier.styleable { + fillWidth() + externalPadding(8.dp) + }, + accountsSectionData = data, + ) + } + } AccountsSectionDeleteDialog( showDialog = showDeleteDialog, @@ -127,14 +104,3 @@ fun AccountsSection( modifier = Modifier.width(428.dp), ) } - -@PhonePreview -@FoldablePreview -@Composable -private fun SingleColumnAccountPreview() { - JetStreamTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { - AccountsSection(numberOfColumns = 1) - } - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt new file mode 100644 index 00000000..f5d32ee6 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.util.StringConstants +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionContactItem +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionFAQItem +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionPrivacyItem +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.SettingItem +import com.google.jetstream.presentation.theme.LocalContentPadding + +@Composable +fun HelpAndSupportSection() { + Column( + modifier = Modifier.padding(LocalContentPadding.current.intoPaddingValues()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ProfileSectionTitle(title = HelpAndSupportSectionTitle) + HelpAndSupportSectionItem(name = HelpAndSupportSectionFAQItem) + HelpAndSupportSectionItem(name = HelpAndSupportSectionPrivacyItem) + HelpAndSupportSectionItem(name = HelpAndSupportSectionContactItem) + } +} + +@Composable +private fun HelpAndSupportSectionItem( + name: String, + modifier: Modifier = Modifier, +) { + SettingItem( + name = name, + value = { + Icon( + Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = + StringConstants + .Composable + .Placeholders + .HelpAndSupportSectionListItemIconDescription, + ) + }, + modifier = modifier, + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt new file mode 100644 index 00000000..4668604a --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.LanguageSectionItems +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.LanguageSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.SelectionItem +import com.google.jetstream.presentation.theme.LocalContentPadding + +@Composable +fun LanguageSection( + selectedIndex: Int, + onSelectedIndexChange: (currentIndex: Int) -> Unit, +) { + LazyColumn( + contentPadding = LocalContentPadding.current.intoPaddingValues(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Text( + text = LanguageSectionTitle, + style = MaterialTheme.typography.headlineSmall, + ) + } + + itemsIndexed(LanguageSectionItems) { index, language -> + SelectionItem( + isSelected = selectedIndex == index, + name = language, + onClick = { onSelectedIndexChange(index) }, + ) + } + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt new file mode 100644 index 00000000..a870a759 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.section + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.util.StringConstants +import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.SampleSearchHistory +import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.SettingItemValue +import com.google.jetstream.presentation.theme.LocalContentPadding + +@Composable +fun SearchHistorySection() { + LazyColumn( + contentPadding = LocalContentPadding.current.intoPaddingValues(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + ProfileSectionTitle( + title = StringConstants.Composable.Placeholders.SearchHistorySectionTitle, + ) + } + items(SampleSearchHistory) { history -> + SettingItemValue(history) + } + item { + ClearHistoryButton() + } + } +} + +@Composable +private fun ClearHistoryButton(modifier: Modifier = Modifier) { + Button( + onClick = { /* Clear search history */ }, + modifier = modifier, + ) { + Text(text = StringConstants.Composable.Placeholders.SearchHistoryClearAll) + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt new file mode 100644 index 00000000..1591c69f --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.screens.profile.section + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.styleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.jetstream.data.util.StringConstants +import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.compoents.SettingItem +import com.google.jetstream.presentation.theme.LocalContentPadding + +@OptIn(ExperimentalFoundationStyleApi::class) +@Composable +fun SubtitlesSection( + isSubtitlesChecked: Boolean, + onSubtitleCheckChange: (isChecked: Boolean) -> Unit, +) { + val contentPadding = LocalContentPadding.current + + Column( + modifier = + Modifier.styleable { + contentPaddingStart(contentPadding.start) + contentPaddingEnd(contentPadding.end) + contentPaddingTop(contentPadding.top) + contentPaddingBottom(contentPadding.bottom) + }, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ProfileSectionTitle( + title = StringConstants.Composable.Placeholders.SubtitlesSectionTitle, + ) + SubtitleSwitch(isEnabled = isSubtitlesChecked, onValueChange = onSubtitleCheckChange) + SettingItem( + name = StringConstants.Composable.Placeholders.SubtitlesSectionLanguageItem, + value = StringConstants.Composable.Placeholders.SubtitlesSectionLanguageValue, + ) + } +} + +@OptIn(ExperimentalFoundationStyleApi::class) +@Composable +private fun SubtitleSwitch( + isEnabled: Boolean, + onValueChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + SettingItem( + name = StringConstants.Composable.Placeholders.SubtitlesSectionSubtitlesItem, + value = { + Switch( + checked = isEnabled, + onCheckedChange = null, + colors = + SwitchDefaults.colors( + uncheckedThumbColor = MaterialTheme.colorScheme.surface, + checkedThumbColor = MaterialTheme.colorScheme.surface, + uncheckedTrackColor = MaterialTheme.colorScheme.onSurface, + checkedTrackColor = MaterialTheme.colorScheme.onSurface, + uncheckedBorderColor = MaterialTheme.colorScheme.surface, + checkedBorderColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + modifier = + modifier + .clickable { + onValueChange(!isEnabled) + } + .semantics { + role = Role.Button + }, + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt index 39658803..e8757b9a 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreenViewModel.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Size import androidx.concurrent.futures.await import androidx.core.net.toUri -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.C @@ -38,37 +37,32 @@ import com.google.jetstream.data.entities.Movie import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.data.entities.StereoscopicVisionType import com.google.jetstream.data.repositories.MovieRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = VideoPlayerScreenViewModel.Factory::class) class VideoPlayerScreenViewModel - @Inject + @AssistedInject constructor( - savedStateHandle: SavedStateHandle, + @Assisted movieId: String, private val repository: MovieRepository, @ApplicationContext private val context: Context, ) : ViewModel() { private val playerManager = PlayerManager() private val specifiedMovieDetails = - savedStateHandle - .getStateFlow(VideoPlayerScreen.MOVIE_ID_BUNDLE_KEY, null) - .map { - if (it != null) { - repository.getMovieDetails(movieId = it) - } else { - null - } - } + flowOf(movieId).map { repository.getMovieDetails(movieId = it) } private var isSpatialUiEnabledFlow = MutableStateFlow(false) @@ -134,7 +128,8 @@ class VideoPlayerScreenViewModel null } else { val movieDetails = resolvedMovie.movieDetails - val stereoscopicVisionType = StereoscopicVisionType.select(movieDetails, isSpatialUiEnabled) + val stereoscopicVisionType = + StereoscopicVisionType.select(movieDetails, isSpatialUiEnabled) val nowPlayingInfo = NowPlayingInfo( movieDetails = movieDetails, @@ -198,6 +193,11 @@ class VideoPlayerScreenViewModel isSpatialUiEnabledFlow.emit(isEnabled) } } + + @AssistedFactory + interface Factory { + fun create(movieId: String): VideoPlayerScreenViewModel + } } private class PlayerManager { @@ -231,7 +231,8 @@ private class PlayerManager { p.addMediaItem(nowPlayingInfo.intoMediaItem()) p.addMediaItems( nowPlayingInfo.movieDetails.similarMovies.map { - val stereoscopicVisionType = StereoscopicVisionType.select(it, isSpatialUiEnabled) + val stereoscopicVisionType = + StereoscopicVisionType.select(it, isSpatialUiEnabled) it.intoMediaItem(stereoscopicVisionType) }, ) @@ -380,7 +381,10 @@ private fun StereoscopicVisionType.Companion.select( isSpatialUiEnabled: Boolean, ): StereoscopicVisionType { return if (isSpatialUiEnabled) { - StereoscopicVisionType.select(sources, listOf(StereoscopicVisionType.SideBySide, StereoscopicVisionType.Mono)) + StereoscopicVisionType.select( + sources, + listOf(StereoscopicVisionType.SideBySide, StereoscopicVisionType.Mono), + ) } else { StereoscopicVisionType.Mono } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt index 1597b43f..72ef21e7 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt @@ -65,6 +65,8 @@ object JetStreamTokens { val LandscapeCardSize = DpSize(ImmersiveListCardWidth, ImmersiveListCardWidth * LANDSCAPE_CARD_ASPECT_RATIO) + val LeanbackWindowSize = DpSize(960.dp, 540.dp) + @OptIn(ExperimentalFoundationStyleApi::class) @Composable fun indication(): Style { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt index a0bc2c8b..036299c3 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass -import com.google.jetstream.presentation.app.NavigationComponentType -import com.google.jetstream.presentation.app.rememberNavigationComponentType +import com.google.jetstream.presentation.components.feature.EngagementMode +import com.google.jetstream.presentation.components.feature.LocalEngagementMode import com.google.jetstream.presentation.components.feature.isIWidthCompact import com.google.jetstream.presentation.components.feature.isWidthMedium @@ -41,40 +41,28 @@ val LocalVerticalCardAspectRatio: ProvidableCompositionLocal = 10.5f / 16f } -val LocalHorizontalCardAspectRatio: ProvidableCompositionLocal = - staticCompositionLocalOf { - 16f / 9f - } - val LocalCardWidth: ProvidableCompositionLocal = staticCompositionLocalOf { 126.dp } -val LocalProminentCardSize: ProvidableCompositionLocal = - staticCompositionLocalOf { - DpSize(432.dp, 216.dp) - } - @Composable -fun rememberFeaturedCarouselHeight( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), -): Dp { - return remember(navigationComponentType) { - when (navigationComponentType) { - NavigationComponentType.TopBar -> 324.dp +fun rememberFeaturedCarouselHeight(): Dp { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + when (engagementMode) { + EngagementMode.Leanback -> 324.dp else -> 385.dp } } } @Composable -fun rememberVerticalCardAspectRatio( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), -): Float { - return remember(navigationComponentType) { - when (navigationComponentType) { - NavigationComponentType.TopBar -> 0.65625f +fun rememberVerticalCardAspectRatio(): Float { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + when (engagementMode) { + EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> 0.65625f // 10.5f / 16f else -> 0.67021f @@ -104,24 +92,22 @@ private fun WindowSizeClass.cardWidth(): Dp { } @Composable -fun rememberCategoryGridColumns( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), -): GridCells { - return remember(navigationComponentType) { - when (navigationComponentType) { - NavigationComponentType.TopBar -> GridCells.Fixed(4) +fun rememberCategoryGridColumns(): GridCells { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + when (engagementMode) { + EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> GridCells.Fixed(4) else -> GridCells.Fixed(3) } } } @Composable -fun rememberCategoryCardAspectRatio( - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, -): Float { - return when { - windowSizeClass.isIWidthCompact() -> 1f - windowSizeClass.isWidthMedium() -> 1.4471f +fun rememberCategoryCardAspectRatio(): Float { + val engagementMode = LocalEngagementMode.current + return when (engagementMode) { + is EngagementMode.Compact -> 1f + is EngagementMode.Medium -> 1.4471f else -> 1.7777f // 16:9 } } diff --git a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt index 6471ae2b..69ffb79f 100644 --- a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt +++ b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.unit.dp import com.android.tools.screenshot.PreviewTest import com.google.jetstream.presentation.app.UserAvatar import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.TopAppBar -import com.google.jetstream.presentation.screens.Screens @PreviewTest @Preview diff --git a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/profile/ProfileScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/profile/ProfileScreenshotTests.kt index 06930024..87f42d20 100644 --- a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/profile/ProfileScreenshotTests.kt +++ b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/profile/ProfileScreenshotTests.kt @@ -21,12 +21,12 @@ import androidx.compose.runtime.Composable import com.android.tools.screenshot.PreviewTest import com.google.jetstream.presentation.components.AdaptivePreview import com.google.jetstream.presentation.components.JetStreamPreview -import com.google.jetstream.presentation.screens.profile.compoents.AboutSection -import com.google.jetstream.presentation.screens.profile.compoents.AccountsSection -import com.google.jetstream.presentation.screens.profile.compoents.HelpAndSupportSection -import com.google.jetstream.presentation.screens.profile.compoents.LanguageSection -import com.google.jetstream.presentation.screens.profile.compoents.SearchHistorySection -import com.google.jetstream.presentation.screens.profile.compoents.SubtitlesSection +import com.google.jetstream.presentation.screens.profile.section.AboutSection +import com.google.jetstream.presentation.screens.profile.section.AccountsSection +import com.google.jetstream.presentation.screens.profile.section.HelpAndSupportSection +import com.google.jetstream.presentation.screens.profile.section.LanguageSection +import com.google.jetstream.presentation.screens.profile.section.SearchHistorySection +import com.google.jetstream.presentation.screens.profile.section.SubtitlesSection @PreviewTest @AdaptivePreview From e6c805fc70c7f2de408186c9cfb6a9211dfe2c26 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 1 May 2026 13:26:56 +0900 Subject: [PATCH 2/5] Fix: Featured carousel auto scroll --- .../presentation/components/shim/FeaturedCarousel.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt index e9db7523..7a8dae54 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt @@ -332,11 +332,9 @@ private suspend fun CarouselState.nextItem( itemCount: Int, ) { onScrollFinished { - if (hasNextItem(itemCount)) { - currentCoroutineContext().ensureActive() - val nextItemIndex = currentItem + 1 - animateScrollToItem(nextItemIndex) - } + currentCoroutineContext().ensureActive() + val nextItemIndex = (currentItem + 1) % itemCount + animateScrollToItem(nextItemIndex) } } @@ -409,8 +407,8 @@ internal fun Modifier.carouselNavigation( return onKeyEvent { keyEvent -> when (keyEvent.key) { Key.DirectionLeft - if keyEvent.type == KeyEventType.KeyUp && - keyEvent.modifierKeys() == ModifierKeys.None -> { + if keyEvent.type == KeyEventType.KeyUp && + keyEvent.modifierKeys() == ModifierKeys.None -> { coroutineScope.launch { state.previousItem() } From 0107b22d72e588070e1d8e1c0a2de8e66ef761b3 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 1 May 2026 14:17:05 +0900 Subject: [PATCH 3/5] Code cleanup --- AdaptiveJetStream/gradle/libs.versions.toml | 2 +- .../com/google/jetstream/presentation/App.kt | 11 +++ .../app/AppLayoutSceneDecoratorStrategy.kt | 5 +- .../app/JetStreamAppNavigation.kt | 16 ++++ .../components/shim/FeaturedCarousel.kt | 4 +- .../jetstream/presentation/theme/Size.kt | 14 ++-- .../components/ComponentScreenshotTests.kt | 11 --- .../screens/ScreenScreenshotTests.kt | 81 +------------------ 8 files changed, 43 insertions(+), 101 deletions(-) diff --git a/AdaptiveJetStream/gradle/libs.versions.toml b/AdaptiveJetStream/gradle/libs.versions.toml index 55be3c63..ac476d23 100644 --- a/AdaptiveJetStream/gradle/libs.versions.toml +++ b/AdaptiveJetStream/gradle/libs.versions.toml @@ -29,7 +29,7 @@ profileinstaller = "1.4.1" uiautomator = "2.3.0" rules = "1.7.0" window = "1.5.1" -xr = "1.0.0-alpha13" +xr = "1.0.0-alpha12" xr-material3 = "1.0.0-alpha16" screenshot = "0.0.1-alpha14" robolectric = "4.16.1" diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/App.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/App.kt index a43ae0c7..bd57a22d 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/App.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/App.kt @@ -60,16 +60,25 @@ import com.google.jetstream.presentation.screens.shows.ShowsScreen import com.google.jetstream.presentation.screens.videoPlayer.VideoPlayerScreen import com.google.jetstream.presentation.screens.videoPlayer.VideoPlayerScreenViewModel +/** + * Main entry point for the JetStream application UI. + * This composable sets up the navigation backstack, global navigation components, + * and defines the routing for all screens in the app using Navigation3. + */ @OptIn(ExperimentalFoundationStyleApi::class, ExperimentalMaterial3AdaptiveApi::class) @Composable fun App( modifier: Modifier = Modifier, ) { + // Manages the navigation history, starting at the Home destination val backStack = rememberNavBackStack(Destination.Home) + // Selects the navigation implementation (Rail, Bar, or TopBar) based on device type/mode val appNavigation = selectAppNavigation() + // Tracks visibility of the top bar/navigation rail for hide-on-scroll behavior var isTopbarVisible by rememberSaveable { mutableStateOf(true) } Surface { + // NavDisplay handles the actual swapping of screens based on the backStack NavDisplay( backStack = backStack, entryDecorators = @@ -79,10 +88,12 @@ fun App( ), sceneStrategies = listOf( + // Strategy for List-Detail layouts (e.g., Profile screen) rememberListDetailSceneStrategy(), ), sceneDecoratorStrategies = listOf( + // Custom strategy to wrap screens in the appropriate app-level navigation (Rail/Bar/TopBar) rememberAppLayoutSceneDecorator( navigation = { appNavigation.Navigation( diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt index 97e3fe9a..c274d584 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt @@ -16,7 +16,6 @@ package com.google.jetstream.presentation.app -import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalGridApi @@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.GridConfigurationScope import androidx.compose.foundation.layout.GridTrackSize import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize @@ -270,6 +268,9 @@ private sealed interface AppLayout { } } +/** + * Data structure describing a grid area. + */ private data class GridArea( val row: Int = 0, val column: Int = 0, diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt index 2ef31117..43f1cccc 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt @@ -255,6 +255,9 @@ object TopBarNavigation : JetStreamAppNavigation { } } +/** + * Navigation implementation optimized for the spatial UI. + */ object SpatialNavigation : JetStreamAppNavigation { @Composable override fun SubNavigation( @@ -337,6 +340,10 @@ fun selectAppNavigation(): JetStreamAppNavigation { } } +/** + * Composable representing the top navigation bar used in Leanback/TV layouts. + * It displays the user profile, main navigation tabs, and the app logo in a grid. + */ @OptIn( ExperimentalFlexBoxApi::class, ExperimentalFoundationStyleApi::class, @@ -393,6 +400,10 @@ private fun Topbar( } } +/** + * A horizontal row of tabs for navigating between the app's root destinations. + * Includes support for focus-based navigation and a specialized search tab. + */ @Composable private fun TabRow( current: Destination?, @@ -459,6 +470,11 @@ private fun TabRow( } } +/** + * Determines the currently selected tab index based on the [current] destination. + * Returns the index of the destination in [Destination.RootDestinations], or the + * index of the Search destination if applicable. + */ private fun currentTabIndex(current: Destination?): Int { val index = Destination.RootDestinations.indexOf(current) return when { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt index 7a8dae54..5e8aba97 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt @@ -407,8 +407,8 @@ internal fun Modifier.carouselNavigation( return onKeyEvent { keyEvent -> when (keyEvent.key) { Key.DirectionLeft - if keyEvent.type == KeyEventType.KeyUp && - keyEvent.modifierKeys() == ModifierKeys.None -> { + if keyEvent.type == KeyEventType.KeyUp && + keyEvent.modifierKeys() == ModifierKeys.None -> { coroutineScope.launch { state.previousItem() } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt index 036299c3..657b0a41 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt @@ -23,13 +23,10 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass import com.google.jetstream.presentation.components.feature.EngagementMode import com.google.jetstream.presentation.components.feature.LocalEngagementMode -import com.google.jetstream.presentation.components.feature.isIWidthCompact -import com.google.jetstream.presentation.components.feature.isWidthMedium val LocalFeaturedCarouselHeight: ProvidableCompositionLocal = staticCompositionLocalOf { @@ -96,8 +93,15 @@ fun rememberCategoryGridColumns(): GridCells { val engagementMode = LocalEngagementMode.current return remember(engagementMode) { when (engagementMode) { - EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> GridCells.Fixed(4) - else -> GridCells.Fixed(3) + EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> { + GridCells.Fixed( + 4, + ) + } + + else -> { + GridCells.Fixed(3) + } } } } diff --git a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt index 69ffb79f..7384776f 100644 --- a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt +++ b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.android.tools.screenshot.PreviewTest import com.google.jetstream.presentation.app.UserAvatar -import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.TopAppBar @PreviewTest @Preview @@ -127,13 +126,3 @@ fun UserAvatarScreenshot() { } } } - -@PreviewTest -@Preview -@Composable -fun TopAppBarPreview() { - TopAppBar( - selectedScreen = Screens.Home, - showScreen = {}, - ) -} diff --git a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt index 492abb87..9c05f4a3 100644 --- a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt +++ b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt @@ -25,11 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.android.tools.screenshot.PreviewTest -import com.google.jetstream.presentation.app.AppState -import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.AdaptiveAppNavigationItems -import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.NavigationSuiteScaffoldLayout -import com.google.jetstream.presentation.app.withNavigationSuiteScaffold.TopAppBar -import com.google.jetstream.presentation.app.withTopBarNavigation.TopBarWithNavigationLayout import com.google.jetstream.presentation.components.AdaptivePreview import com.google.jetstream.presentation.components.AutoPreview import com.google.jetstream.presentation.components.DesktopPreview @@ -192,78 +187,4 @@ fun SearchScreenScreenshot() { } } -@PreviewTest -@PhonePreview -@TabletPreview -@FoldablePreview -@Composable -fun NavigationSuiteScaffoldLayoutPreview() { - JetStreamPreview { - Surface { - val appState = AppState() - - NavigationSuiteScaffoldLayout( - isNavigationVisible = true, - navigationItems = { - AdaptiveAppNavigationItems( - currentScreen = Screens.Home, - screens = Screens.entries.filter { it.isMainNavigation }, - onSelectScreen = {}, - ) - }, - content = { padding -> - HomeCatalog( - featuredMovies = TestMovieList, - trendingMovies = TestMovieList, - top10Movies = TestMovieList, - nowPlayingMovies = TestMovieList, - onMovieClick = { _ -> }, - onScroll = { _ -> }, - goToVideoPlayer = { _ -> }, - modifier = Modifier.padding(padding).fillMaxSize(), - ) - }, - topBar = { - TopAppBar( - modifier = - Modifier - .padding( - start = 24.dp, - end = 24.dp, - top = 0.dp, - ), - selectedScreen = appState.selectedScreen, - showScreen = { }, - ) - }, - ) - } - } -} - -@PreviewTest -@DesktopPreview -@TvPreview -@AutoPreview -@Composable -fun TopBarWithNavigationLayoutPreview() { - JetStreamPreview { - Surface { - val appState = AppState() - TopBarWithNavigationLayout( - selectedScreen = appState.selectedScreen, - isNavigationVisible = appState.isNavigationVisible, - isTopBarVisible = appState.isNavigationVisible && appState.isTopBarVisible, - isTopBarFocussed = appState.isTopBarFocused, - onTopBarFocusChanged = { hasFocus -> - appState.updateTopBarFocusState(hasFocus) - }, - onTopBarVisible = { appState.showTopBar() }, - onActivityBackPressed = { }, - onShowScreen = {}, - ) { - Text("Preview content") - } - } - } -} +// ToDo: define screenshots tests for app level layouts. \ No newline at end of file From 014a9bb72ce66ebe47a50a0e0bc5f8df0b0de222 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Fri, 1 May 2026 15:59:45 +0900 Subject: [PATCH 4/5] Remove unnecessary debug logs --- .../google/jetstream/presentation/screens/home/HomeScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt index ae52fe2d..b9ed43dd 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt @@ -16,7 +16,6 @@ package com.google.jetstream.presentation.screens.home -import android.util.Log import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.focusGroup import androidx.compose.foundation.gestures.LocalBringIntoViewSpec @@ -115,7 +114,7 @@ internal fun Catalog( val shouldShowTopBar by remember { derivedStateOf { lazyListState.firstVisibleItemIndex == 0 && - lazyListState.firstVisibleItemScrollOffset < 300 + lazyListState.firstVisibleItemScrollOffset < 300 } } @@ -248,7 +247,6 @@ private fun Catalog( onExpanded = onExpanded, onCollapsed = { onCollapsed() - Log.d("Home", "onCollapsed: $isTrendingFocused") }, ) } From e09c41834aee6e0d8e0b9bcc864d13b03218fac3 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 7 May 2026 21:36:24 +0900 Subject: [PATCH 5/5] Incorporate reviewer suggestions --- AdaptiveJetStream/gradle/libs.versions.toml | 19 +- AdaptiveJetStream/jetstream/build.gradle.kts | 2 +- .../jetstream/src/main/AndroidManifest.xml | 3 - .../java/com/google/jetstream/MainActivity.kt | 13 +- .../com/google/jetstream/presentation/App.kt | 533 +++++++++++------- .../jetstream/presentation/Navigator.kt | 51 ++ .../app/AppLayoutSceneDecoratorStrategy.kt | 115 +++- .../jetstream/presentation/app/Destination.kt | 143 +---- .../app/JetStreamAppNavigation.kt | 45 +- .../presentation/app/NavigationItem.kt | 144 +++++ .../presentation/app/SearchButton.kt | 2 +- .../presentation/components/MoviesRow.kt | 4 +- .../components/ProminentMovieCard.kt | 3 +- .../components/shim/FeaturedCarousel.kt | 12 +- .../components/shim/stylable/StylableCard.kt | 3 +- .../categories/CategoryMovieListScreen.kt | 18 +- .../CategoryMovieListScreenViewModel.kt | 6 +- .../screens/favourites/FavouritesScreen.kt | 4 +- .../RememberFilteredMovieGridColumns.kt | 39 -- .../presentation/screens/home/HomeScreen.kt | 2 +- .../MovieDetailsScreenViewModel.kt | 5 +- .../screens/profile/ProfileScreen.kt | 60 +- .../profile/compoents/ProfileScreens.kt | 43 -- .../AccountsSectionDeleteDialog.kt | 2 +- .../AccountsSectionDialogButton.kt | 2 +- .../AccountsSelectionItem.kt | 2 +- .../ProfileSectionTitle.kt | 2 +- .../SelectionItem.kt | 5 +- .../{compoents => components}/SettingItem.kt | 2 +- .../screens/profile/section/AboutSection.kt | 2 +- .../profile/section/AccountsSection.kt | 4 +- .../profile/section/HelpAndSupportSection.kt | 4 +- .../profile/section/LanguageSection.kt | 2 +- .../profile/section/SearchHistorySection.kt | 4 +- .../profile/section/SubtitlesSection.kt | 4 +- .../screens/videoPlayer/VideoPlayerScreen.kt | 13 +- .../presentation/theme/JetStreamTokens.kt | 14 + .../jetstream/src/main/res/values/strings.xml | 15 + .../screens/ScreenScreenshotTests.kt | 11 +- .../screens/profile/ProfileScreenTest.kt | 70 +-- 40 files changed, 779 insertions(+), 648 deletions(-) create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/Navigator.kt create mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationItem.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt delete mode 100644 AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreens.kt rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/AccountsSectionDeleteDialog.kt (97%) rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/AccountsSectionDialogButton.kt (94%) rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/AccountsSelectionItem.kt (97%) rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/ProfileSectionTitle.kt (93%) rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/SelectionItem.kt (92%) rename AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/{compoents => components}/SettingItem.kt (97%) diff --git a/AdaptiveJetStream/gradle/libs.versions.toml b/AdaptiveJetStream/gradle/libs.versions.toml index ac476d23..a4c3ff3f 100644 --- a/AdaptiveJetStream/gradle/libs.versions.toml +++ b/AdaptiveJetStream/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] activity-compose = "1.13.0" -android-gradle-plugin = "9.2.0" -android-test-plugin = "9.2.0" -androidx-baselineprofile = "1.5.0-alpha05" +android-gradle-plugin = "9.2.1" +android-test-plugin = "9.2.1" +androidx-baselineprofile = "1.5.0-alpha06" benchmark-macro-junit4 = "1.4.1" coil-compose = "2.7.0" -compose-bom = "2026.04.01" -compose-latest = "1.12.0-alpha01" +compose-bom = "2026.05.00" +compose-latest = "1.12.0-alpha02" concurrent-futures-ktx = "1.3.0" core-ktx = "1.18.0" core-splashscreen = "1.2.0" @@ -21,15 +21,15 @@ ksp = "2.3.2" lifecycle-runtime-ktx = "2.10.0" material3-adaptive = "1.2.0" material3-adaptive-navigation = "1.4.0" -material3-adaptive-navigation3 = "1.3.0-alpha10" +material3-adaptive-navigation3 = "1.3.0-beta01" media3 = "1.10.0" -navigation-compose = "2.9.8" navigation3 = "1.1.1" profileinstaller = "1.4.1" uiautomator = "2.3.0" rules = "1.7.0" window = "1.5.1" -xr = "1.0.0-alpha12" +xr = "1.0.0-alpha14" +xr-compose = "1.0.0-alpha13" xr-material3 = "1.0.0-alpha16" screenshot = "0.0.1-alpha14" robolectric = "4.16.1" @@ -66,7 +66,6 @@ androidx-material3-adaptive-navigation-suite = { module = "androidx.compose.mate androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui-compose", version.ref = "media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } -androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3-adaptive-navigation3" } @@ -82,7 +81,7 @@ androidx-rules = { group = "androidx.test", name = "rules", version.ref = "rules androidx-window = { module = "androidx.window:window", version.ref = "window" } androidx-window-core = { module = "androidx.window:window-core", version.ref = "window" } androidx-xr-scenecore = { module = "androidx.xr.scenecore:scenecore", version.ref = "xr" } -androidx-xr-compose = { module = "androidx.xr.compose:compose", version.ref = "xr" } +androidx-xr-compose = { module = "androidx.xr.compose:compose", version.ref = "xr-compose" } androidx-xr-compose-material3 = { module = "androidx.xr.compose.material3:material3", version.ref = "xr-material3" } screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } diff --git a/AdaptiveJetStream/jetstream/build.gradle.kts b/AdaptiveJetStream/jetstream/build.gradle.kts index b424639b..eb2a5b90 100644 --- a/AdaptiveJetStream/jetstream/build.gradle.kts +++ b/AdaptiveJetStream/jetstream/build.gradle.kts @@ -64,6 +64,7 @@ configure { } getByName("release") { isMinifyEnabled = true + isShrinkResources = true signingConfig = signingConfigs.getByName("debug") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -194,7 +195,6 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) // Compose Navigation - implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.material3.adaptive.navigation3) diff --git a/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml b/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml index c6ef7503..e519a6a0 100644 --- a/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml +++ b/AdaptiveJetStream/jetstream/src/main/AndroidManifest.xml @@ -80,9 +80,6 @@ https://www.apache.org/licenses/LICENSE-2.0 android:defaultHeight="540dp" android:gravity="center" /> - ( - metadata = Destination.Home.navEntryMetadata(), - ) { - HomeScreen( - onMovieClick = { - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - goToVideoPlayer = { - backStack.add(Destination.VideoPlayer(movieId = it.id)) - }, - onScroll = { - isTopbarVisible = it - }, - isTopBarVisible = true, - ) - } - entry( - metadata = Destination.Categories.navEntryMetadata(), - ) { - CategoriesScreen( - onCategoryClick = { - backStack.add(Destination.CategoryMovieList(categoryId = it)) - }, - ) - } - entry( - metadata = Destination.Movies.navEntryMetadata(), - ) { - MoviesScreen( - onMovieClick = { - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - onScroll = { - isTopbarVisible = it - }, - isTopBarVisible = true, - ) - } - entry( - metadata = Destination.Shows.navEntryMetadata(), - ) { - ShowsScreen( - onTVShowClick = { - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - onScroll = { - isTopbarVisible = it - }, - isTopBarVisible = true, - ) - } - entry( - metadata = Destination.Favourites.navEntryMetadata(), - ) { - FavouritesScreen( - onMovieClick = { - backStack.add(Destination.MovieDetails(movieId = it)) - }, - onScroll = { - isTopbarVisible = it - }, - isTopBarVisible = true, - ) - } - entry( - metadata = Destination.Search.navEntryMetadata(), - ) { - SearchScreen( - onMovieClick = { - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - onScroll = { - isTopbarVisible = it - }, - ) - } - entry( - metadata = Destination.MovieDetails.navEntryMetadata(), - ) { destination -> - val viewModel = - hiltViewModel( - creationCallback = { factory -> - factory.create(destination.movieId) - }, - ) - MovieDetailsScreen( - goToMoviePlayer = { - backStack.add(Destination.VideoPlayer(movieId = it.id)) - }, - refreshScreenWithNewMovie = { - backStack.removeLastOrNull() - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - onBackPressed = backStack::removeLastOrNull, - movieDetailsScreenViewModel = viewModel, - ) - } - entry( - metadata = Destination.CategoryMovieList.navEntryMetadata(), - ) { - val viewModel = - hiltViewModel( - creationCallback = { factory -> - factory.create(it.categoryId) - }, - ) - CategoryMovieListScreen( - onBackPressed = backStack::removeLastOrNull, - onMovieSelected = { - backStack.add(Destination.MovieDetails(movieId = it.id)) - }, - categoryMovieListScreenViewModel = viewModel, - ) - } - entry( - metadata = Destination.VideoPlayer.navEntryMetadata(), - ) { destination -> - val viewModel = - hiltViewModel( - creationCallback = { factory -> - factory.create(destination.movieId) - }, - ) - VideoPlayerScreen( - onBackPressed = backStack::removeLastOrNull, - videoPlayerScreenViewModel = viewModel, - ) - } - entry( - metadata = - Destination.Profile.navEntryMetadata { - AboutSection() - }, - ) { - ProfileScreen( - onDestinationSelected = { - backStack.add(it) - }, - ) - } - entry( - metadata = Destination.About.navEntryMetadata(), - ) { - AboutSection() - } - entry( - metadata = Destination.Accounts.navEntryMetadata(), - ) { - AccountsSection() - } - entry( - metadata = Destination.Subtitles.navEntryMetadata(), - ) { - var isSubtitleChecked by rememberSaveable { mutableStateOf(false) } - SubtitlesSection(isSubtitlesChecked = isSubtitleChecked) { - isSubtitleChecked = it - } - } - entry( - metadata = Destination.Language.navEntryMetadata(), - ) { - var selectedLanguageIndex by rememberSaveable { - mutableIntStateOf(0) - } - LanguageSection(selectedIndex = selectedLanguageIndex) { - selectedLanguageIndex = it - } - } - entry( - metadata = Destination.SearchHistory.navEntryMetadata(), - ) { - SearchHistorySection() - } - entry( - metadata = Destination.HelpAndSupport.navEntryMetadata(), - ) { - HelpAndSupportSection() - } + homeEntry( + navigator = navigator, + isTopbarVisible = isTopbarVisible, + onUpdateTopbarVisibility = { + isTopbarVisible = it + }, + ) + + categoriesEntry(navigator) + + moviesEntry( + navigator = navigator, + isTopbarVisible = isTopbarVisible, + onUpdateTopbarVisibility = { + isTopbarVisible = it + }, + ) + + showsEntry( + navigator = navigator, + isTopbarVisible = isTopbarVisible, + onUpdateTopbarVisibility = { isTopbarVisible = it }, + ) + + favoritesEntry( + navigator = navigator, + isTopbarVisible = isTopbarVisible, + onUpdateTopbarVisibility = { isTopbarVisible = it }, + ) + + searchEntry( + navigator = navigator, + onUpdateTopbarVisibility = { isTopbarVisible = it }, + ) + + moveDetailsEntry(navigator) + + videoPlayerEntry(navigator) + + profileEntries(navigator) }, - modifier = modifier, ) } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private fun Destination.navEntryMetadata( - detailsPlaceHolder: @Composable ThreePaneScaffoldScope.() -> Unit = {}, -): Map { - return metadata { - put(Destination.MetadataKey, presentationType) - } + - when (presentationType) { - PresentationType.ListDetailChild -> { - ListDetailSceneStrategy.detailPane() - } +private fun EntryProviderScope.homeEntry( + navigator: Navigator, + isTopbarVisible: Boolean, + onUpdateTopbarVisibility: (Boolean) -> Unit, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + HomeScreen( + onMovieClick = { + navigator.navigate(Destination.MovieDetails(movieId = it.id)) + }, + goToVideoPlayer = { + navigator.navigate(Destination.VideoPlayer(movieId = it.id)) + }, + onScroll = onUpdateTopbarVisibility, + isTopBarVisible = isTopbarVisible, + ) + } +} - PresentationType.ListDetailParent -> { - ListDetailSceneStrategy.listPane( - detailPlaceholder = detailsPlaceHolder, - ) - } +private fun EntryProviderScope.categoriesEntry( + navigator: Navigator, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + CategoriesScreen( + onCategoryClick = { + navigator.navigate(Destination.CategoryMovieList(categoryId = it)) + }, + ) + } - else -> { - mapOf() - } - } + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.Overlay) + }, + ) { + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(it.categoryId) + }, + ) + CategoryMovieListScreen( + onBackPressed = navigator::goBack, + onMovieSelected = { movie -> + navigator.navigate(Destination.MovieDetails(movieId = movie.id)) + }, + categoryMovieListScreenViewModel = viewModel, + ) + } } -private fun Destination.MovieDetails.Companion.navEntryMetadata(): Map { - return metadata { - put(Destination.MetadataKey, presentationType) +private fun EntryProviderScope.moviesEntry( + navigator: Navigator, + isTopbarVisible: Boolean, + onUpdateTopbarVisibility: (Boolean) -> Unit, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + MoviesScreen( + onMovieClick = { + navigator.navigate(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = onUpdateTopbarVisibility, + isTopBarVisible = isTopbarVisible, + ) } } -private fun Destination.CategoryMovieList.Companion.navEntryMetadata(): Map { - return metadata { - put(Destination.MetadataKey, presentationType) +private fun EntryProviderScope.showsEntry( + navigator: Navigator, + isTopbarVisible: Boolean, + onUpdateTopbarVisibility: (Boolean) -> Unit, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + ShowsScreen( + onTVShowClick = { + navigator.navigate(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = onUpdateTopbarVisibility, + isTopBarVisible = isTopbarVisible, + ) } } -private fun Destination.VideoPlayer.Companion.navEntryMetadata(): Map { - return metadata { - put(Destination.MetadataKey, presentationType) +private fun EntryProviderScope.moveDetailsEntry( + navigator: Navigator, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.Overlay) + }, + ) { destination -> + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(destination.movieId) + }, + ) + MovieDetailsScreen( + goToMoviePlayer = { + navigator.navigate(Destination.VideoPlayer(movieId = it.id)) + }, + refreshScreenWithNewMovie = { + navigator.goBack() + navigator.navigate(Destination.MovieDetails(movieId = it.id)) + }, + onBackPressed = navigator::goBack, + movieDetailsScreenViewModel = viewModel, + ) + } +} + +private fun EntryProviderScope.favoritesEntry( + navigator: Navigator, + isTopbarVisible: Boolean, + onUpdateTopbarVisibility: (Boolean) -> Unit, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + FavouritesScreen( + onMovieClick = { + navigator.navigate(Destination.MovieDetails(movieId = it)) + }, + onScroll = onUpdateTopbarVisibility, + isTopBarVisible = isTopbarVisible, + ) + } +} + +private fun EntryProviderScope.searchEntry( + navigator: Navigator, + onUpdateTopbarVisibility: (Boolean) -> Unit, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + SearchScreen( + onMovieClick = { + navigator.navigate(Destination.MovieDetails(movieId = it.id)) + }, + onScroll = onUpdateTopbarVisibility, + ) + } +} + +private fun EntryProviderScope.videoPlayerEntry( + navigator: Navigator, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.Overlay) + }, + ) { destination -> + val viewModel = + hiltViewModel( + creationCallback = { factory -> + factory.create(destination.movieId) + }, + ) + VideoPlayerScreen( + onBackPressed = navigator::goBack, + videoPlayerScreenViewModel = viewModel, + ) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +private fun EntryProviderScope.profileEntries( + navigator: Navigator, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailParent) + } + + ListDetailSceneStrategy.listPane( + detailPlaceholder = { + AboutSection() + }, + ), + ) { + ProfileScreen( + onDestinationSelected = navigator::navigate, + ) + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + AboutSection() + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + AccountsSection() + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + var isSubtitleChecked by rememberSaveable { mutableStateOf(false) } + SubtitlesSection(isSubtitlesChecked = isSubtitleChecked) { + isSubtitleChecked = it + } + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + var selectedLanguageIndex by rememberSaveable { + mutableIntStateOf(0) + } + LanguageSection(selectedIndex = selectedLanguageIndex) { + selectedLanguageIndex = it + } + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + SearchHistorySection() + } + + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.ListDetailChild) + } + ListDetailSceneStrategy.detailPane(), + ) { + HelpAndSupportSection() } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/Navigator.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/Navigator.kt new file mode 100644 index 00000000..4bcdbe05 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/Navigator.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.google.jetstream.presentation.app.Destination + +internal class Navigator( + val backStack: NavBackStack, +) { + fun navigate(destination: Destination) { + if (destination != current) { + backStack.add(destination) + } + } + + fun goBack() { + backStack.removeLastOrNull() + } + + val current: Destination + get() { + return backStack.last() as Destination + } +} + +@Composable +internal fun rememberNavigator(initialDestination: Destination = Destination.Home): Navigator { + val backStack = rememberNavBackStack(initialDestination) + return remember(backStack) { + Navigator(backStack = backStack) + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt index c274d584..482a9a6d 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt @@ -22,6 +22,9 @@ import androidx.compose.foundation.layout.ExperimentalGridApi import androidx.compose.foundation.layout.Grid import androidx.compose.foundation.layout.GridConfigurationScope import androidx.compose.foundation.layout.GridTrackSize +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.styleable +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -30,22 +33,41 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.NavMetadataKey import androidx.navigation3.runtime.get import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneDecoratorStrategy import androidx.navigation3.scene.SceneDecoratorStrategyScope import androidx.xr.compose.material3.ExperimentalMaterial3XrApi import androidx.xr.compose.spatial.Subspace -import androidx.xr.compose.subspace.MovePolicy import androidx.xr.compose.subspace.ResizePolicy import androidx.xr.compose.subspace.SpatialPanel +import androidx.xr.compose.subspace.layout.SubspaceModifier +import androidx.xr.compose.subspace.layout.movable import androidx.xr.compose.unit.DpVolumeSize import com.google.jetstream.presentation.components.feature.EngagementMode import com.google.jetstream.presentation.components.feature.LocalEngagementMode import com.google.jetstream.presentation.theme.JetStreamTokens +enum class PresentationType { + SinglePane, + ListDetailParent, + ListDetailChild, + Overlay, + ; + + companion object { + val PresentationTypeKey = object : NavMetadataKey {} + } +} + /** - * Strategy for decorating a scene based on its presentation type. + * Strategy for decorating a scene with navigation component and subNavigation component + * based on its presentation type and current EngagementMode. + * + * @param navigation A component implementing global navigation. + * @param subNavigation A component implement supplement navigation. + * @param engagementMode The current EngagementMode. */ class AppLayoutSceneDecoratorStrategy( val engagementMode: EngagementMode, @@ -55,7 +77,7 @@ class AppLayoutSceneDecoratorStrategy( override fun SceneDecoratorStrategyScope.decorateScene( scene: Scene, ): Scene { - val presentationType = scene.metadata[Destination.MetadataKey] + val presentationType = scene.metadata[PresentationType.PresentationTypeKey] // If the presentation type is Overlay, do not decorate the scene return when { @@ -104,15 +126,14 @@ private class SpatialAppLayoutSceneDecorator( minimumSize = DpVolumeSize.from(JetStreamTokens.LeanbackWindowSize), ) } - val dragPolicy = remember { MovePolicy() } - val presentationType = scene.metadata[Destination.MetadataKey] + val presentationType = scene.metadata[PresentationType.PresentationTypeKey] val isNavigationVisible = presentationType != PresentationType.Overlay Subspace { SpatialPanel( resizePolicy = resizePolicy, - dragPolicy = dragPolicy, + modifier = SubspaceModifier.movable(), ) { Surface { MainPanel( @@ -159,21 +180,26 @@ private class AppLayoutSceneDecorator( override val previousEntries: List> get() = scene.previousEntries - @OptIn(ExperimentalGridApi::class) + @OptIn(ExperimentalGridApi::class, ExperimentalFoundationStyleApi::class) override val content: @Composable (() -> Unit) = { val layout = selectLayout() - val subNavigationArea = layout.subNavigation + val subNavigationArea = layout.subNavigationArea + val backgroundColor = MaterialTheme.colorScheme.surface Grid( - config = layout.config, + config = layout.gridConfig, + modifier = + Modifier.styleable { + background(backgroundColor) + }, ) { Box( modifier = Modifier.gridItem( - column = layout.navigation.column, - row = layout.navigation.row, - rowSpan = layout.navigation.rowSpan, - columnSpan = layout.navigation.columnSpan, + column = layout.navigationArea.column, + row = layout.navigationArea.row, + rowSpan = layout.navigationArea.rowSpan, + columnSpan = layout.navigationArea.columnSpan, ), ) { navigation() @@ -203,7 +229,7 @@ private class AppLayoutSceneDecorator( private fun selectLayout(): AppLayout { return when (LocalEngagementMode.current) { is EngagementMode.Compact -> AppLayout.NavigationBar - EngagementMode.Leanback -> AppLayout.TopBar + EngagementMode.Leanback, EngagementMode.Cabin, is EngagementMode.Workstation -> AppLayout.TopBar else -> AppLayout.NavigationRail } } @@ -216,31 +242,51 @@ private class AppLayoutSceneDecorator( */ @OptIn(ExperimentalGridApi::class) private sealed interface AppLayout { - val config: GridConfigurationScope.() -> Unit - val navigation: GridArea - val subNavigation: GridArea? - val content: GridArea + val gridConfig: GridConfigurationScope.() -> Unit + val navigationArea: GridArea + val subNavigationArea: GridArea? + val contentArea: GridArea /** * Layout using a bottom navigation bar, typically for Compact. + * The navigation, subNavigation, and content are rendered in the following layout: + * - navigation: area A + * - subNavigation: area B + * - content: area C + * + * B B B B + * C C C C + * C C C C + * C C C C + * C C C C + * A A A A */ object NavigationBar : AppLayout { - override val config: GridConfigurationScope.() -> Unit = { + override val gridConfig: GridConfigurationScope.() -> Unit = { column(GridTrackSize.MinMax(350.dp, 1.fr)) row(GridTrackSize.Auto) row(GridTrackSize.MinMax(200.dp, 1.fr)) row(GridTrackSize.Auto) } - override val navigation = GridArea(row = -1, column = 1) - override val subNavigation = GridArea(row = 1, column = 1) - override val content = GridArea(row = 2, column = 1) + override val navigationArea = GridArea(row = -1, column = 1) + override val subNavigationArea = GridArea(row = 1, column = 1) + override val contentArea = GridArea(row = 2, column = 1) } /** * Layout using a side navigation rail, typically for Medium. + * It layouts navigation, subNavigation and content as follows. + * - navigation: area A + * - subNavigation: area B + * - content: area C + * + * A B B B B + * A C C C C + * A C C C C + * A C C C C */ object NavigationRail : AppLayout { - override val config: GridConfigurationScope.() -> Unit + override val gridConfig: GridConfigurationScope.() -> Unit get() = { column(GridTrackSize.Auto) column(GridTrackSize.MinMax(200.dp, 1.fr)) @@ -248,23 +294,32 @@ private sealed interface AppLayout { row(GridTrackSize.MinMax(200.dp, 1.fr)) rowGap(8.dp) } - override val navigation = GridArea(column = 1, row = 1, rowSpan = 2, columnSpan = 1) - override val subNavigation = GridArea(column = 2, row = 1) - override val content = GridArea(column = 2, row = 2) + override val navigationArea = GridArea(column = 1, row = 1, rowSpan = 2, columnSpan = 1) + override val subNavigationArea = GridArea(column = 2, row = 1) + override val contentArea = GridArea(column = 2, row = 2) } /** * Layout using a top navigation bar, typically for Leanback. + * It layouts navigation, subNavigation and content as follows. + * - navigation: area A + * - subNavigation: N/A + * - content: area C + * + * A A A A A + * C C C C C + * C C C C C + * C C C C C */ object TopBar : AppLayout { - override val config: GridConfigurationScope.() -> Unit = { + override val gridConfig: GridConfigurationScope.() -> Unit = { column(1f) row(GridTrackSize.Auto) row(GridTrackSize.MinMax(300.dp, 1.fr)) } - override val navigation: GridArea = GridArea(row = 1, column = 1) - override val subNavigation: GridArea? = null - override val content: GridArea = GridArea(row = 2, column = 1) + override val navigationArea: GridArea = GridArea(row = 1, column = 1) + override val subNavigationArea: GridArea? = null + override val contentArea: GridArea = GridArea(row = 2, column = 1) } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt index 97194baf..7ac027e5 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt @@ -16,166 +16,57 @@ package com.google.jetstream.presentation.app -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Subtitles -import androidx.compose.material.icons.filled.Support -import androidx.compose.material.icons.filled.Translate -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.NavMetadataKey -import com.google.jetstream.R import kotlinx.serialization.Serializable // ToDo: update names to read the resource file @Serializable -sealed class Destination( - val presentationType: PresentationType = PresentationType.SinglePane, -) : NavKey { - open val name: String - @Composable get() { - return "" - } - open val icon: Painter? - @Composable get() { - return null - } - +sealed class Destination : NavKey { @Serializable - data object Home : Destination() { - override val name: String @Composable get() = "Home" - override val icon: Painter @Composable get() = painterResource(R.drawable.ic_home) - } + data object Home : Destination() @Serializable - data object Categories : Destination() { - override val name: String @Composable get() = "Categories" - override val icon: Painter @Composable get() = painterResource(R.drawable.ic_category) - } + data object Categories : Destination() @Serializable - data object Movies : Destination() { - override val name: String @Composable get() = "Movies" - override val icon: Painter @Composable get() = painterResource(R.drawable.ic_movies) - } + data object Movies : Destination() @Serializable - data object Shows : Destination() { - override val name: String @Composable get() = "Shows" - override val icon: Painter @Composable get() = painterResource(R.drawable.ic_shows) - } + data object Shows : Destination() @Serializable - data object Favourites : Destination() { - override val name: String @Composable get() = "Favourites" - override val icon: Painter @Composable get() = painterResource(R.drawable.ic_favorites) - } + data object Favourites : Destination() @Serializable - data object Search : Destination() { - override val name: String @Composable get() = "Search" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) - } + data object Search : Destination() @Serializable - data object Profile : Destination(presentationType = PresentationType.ListDetailParent) { - override val name: String @Composable get() = "Profile" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) - } + data object Profile : Destination() @Serializable - data class CategoryMovieList(val categoryId: String) : Destination() { - companion object { - val presentationType = PresentationType.SinglePane - } - } + data class CategoryMovieList(val categoryId: String) : Destination() @Serializable - data class MovieDetails(val movieId: String) : Destination() { - companion object { - val presentationType = PresentationType.Overlay - } - } + data class MovieDetails(val movieId: String) : Destination() @Serializable - data class VideoPlayer(val movieId: String) : - Destination(presentationType = PresentationType.Overlay) { - companion object { - val presentationType = PresentationType.Overlay - } - } + data class VideoPlayer(val movieId: String) : Destination() @Serializable - data object About : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "About" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Info) - } + data object About : Destination() @Serializable - data object Accounts : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "Accounts" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) - } + data object Accounts : Destination() @Serializable - data object Subtitles : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "Subtitles" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Subtitles) - } + data object Subtitles : Destination() @Serializable - data object Language : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "Language" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Translate) - } + data object Language : Destination() @Serializable - data object SearchHistory : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "Search history" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) - } + data object SearchHistory : Destination() @Serializable - data object HelpAndSupport : Destination(presentationType = PresentationType.ListDetailChild) { - override val name: String @Composable get() = "Help and Support" - override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Support) - } - - companion object { - val MetadataKey = object : NavMetadataKey {} - val RootDestinations: List - get() { - return listOf( - Home, - Categories, - Movies, - Shows, - Favourites, - ) - } - - val ProfileSettings: List - get() { - return listOf( - About, - Accounts, - Subtitles, - Language, - SearchHistory, - HelpAndSupport, - ) - } - } -} - -enum class PresentationType { - SinglePane, - ListDetailParent, - ListDetailChild, - Overlay, + data object HelpAndSupport : Destination() } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt index 43f1cccc..a5d6086c 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt @@ -174,22 +174,19 @@ object DefaultNavigation : JetStreamAppNavigation { current: Destination?, onNavigation: (Destination) -> Unit, ) { - Destination.RootDestinations.forEach { destination -> + NavigationItem.RootDestinations.forEach { item -> NavigationRailItem( - selected = destination == current, - onClick = { onNavigation(destination) }, + selected = item.destination == current, + onClick = { onNavigation(item.destination) }, icon = { - val painter = destination.icon - if (painter != null) { - Icon( - painter = painter, - contentDescription = destination.name, - modifier = Modifier.size(24.dp), - ) - } + Icon( + painter = item.icon, + contentDescription = item.name, + modifier = Modifier.size(24.dp), + ) }, label = { - Text(text = destination.name) + Text(text = item.name) }, ) } @@ -373,6 +370,8 @@ private fun Topbar( .styleable { contentPaddingStart(contentPadding.start) contentPaddingEnd(contentPadding.end) + externalPaddingTop(8.dp) + externalPaddingBottom(8.dp) } .focusRestorer(fallback = focusRequester) .focusGroup(), @@ -419,7 +418,7 @@ private fun TabRow( val focusRequesterList = remember { - List(Destination.RootDestinations.size + 1) { + List(NavigationItem.RootDestinations.size + 1) { FocusRequester() } } @@ -432,22 +431,22 @@ private fun TabRow( .focusRestorer(fallback = focusRequesterList[selectedTabIndex]) .focusGroup(), ) { - Destination.RootDestinations.forEachIndexed { index, destination -> + NavigationItem.RootDestinations.forEachIndexed { index, item -> Tab( - selected = destination == current, + selected = item.destination == current, onClick = { - onTabClicked(destination) + onTabClicked(item.destination) }, modifier = Modifier .onFocusChanged { if (it.isFocused) { - onTabFocused(destination) + onTabFocused(item.destination) } } .focusRequester(focusRequesterList[index]), ) { - Text(text = destination.name, style = textStyle) + Text(text = item.name, style = textStyle) } } Tab( @@ -463,8 +462,8 @@ private fun TabRow( .focusRequester(focusRequesterList.last()), ) { Icon( - painter = Destination.Search.icon, - contentDescription = Destination.Search.name, + painter = NavigationItem.Search.icon, + contentDescription = NavigationItem.Search.name, ) } } @@ -472,14 +471,14 @@ private fun TabRow( /** * Determines the currently selected tab index based on the [current] destination. - * Returns the index of the destination in [Destination.RootDestinations], or the + * Returns the index of the destination in [NavigationItem.RootDestinations], or the * index of the Search destination if applicable. */ private fun currentTabIndex(current: Destination?): Int { - val index = Destination.RootDestinations.indexOf(current) + val index = NavigationItem.RootDestinations.indexOfFirst { it.destination == current } return when { index > -1 -> index - current == Destination.Search -> Destination.RootDestinations.size + current == Destination.Search -> NavigationItem.RootDestinations.size else -> 0 } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationItem.kt new file mode 100644 index 00000000..c30a2e9d --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/NavigationItem.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.jetstream.presentation.app + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.Support +import androidx.compose.material.icons.filled.Translate +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.google.jetstream.R + +sealed interface NavigationItem { + val name: String + @Composable get + + val icon: Painter + @Composable get + + val destination: Destination + + data object Home : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_home) + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_home) + override val destination: Destination = Destination.Home + } + + data object Categories : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_categories) + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_category) + override val destination: Destination = Destination.Categories + } + + data object Movies : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_movies) + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_movies) + override val destination: Destination = Destination.Movies + } + + data object Shows : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_shows) + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_shows) + override val destination: Destination = Destination.Shows + } + + data object Favorites : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_favorites) + override val icon: Painter @Composable get() = painterResource(R.drawable.ic_favorites) + override val destination: Destination = Destination.Favourites + } + + data object Search : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_search) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) + override val destination: Destination = Destination.Search + } + + data object Profile : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_profile) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) + override val destination: Destination = Destination.Profile + } + + data object About : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_about) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Info) + override val destination: Destination = Destination.About + } + + data object Accounts : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_accounts) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Person) + override val destination: Destination = Destination.Accounts + } + + data object Subtitles : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_subtitles) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Subtitles) + override val destination: Destination = Destination.Subtitles + } + + data object Language : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_language) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Translate) + override val destination: Destination = Destination.Language + } + + data object SearchHistory : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_search_history) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Search) + override val destination: Destination = Destination.SearchHistory + } + + data object HelpAndSupport : NavigationItem { + override val name: String @Composable get() = stringResource(R.string.navigation_help_and_support) + override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Default.Support) + override val destination: Destination = Destination.HelpAndSupport + } + + companion object { + val RootDestinations: List + get() { + return listOf( + Home, + Categories, + Movies, + Shows, + Favorites, + ) + } + + val ProfileSettings: List + get() { + return listOf( + About, + Accounts, + Subtitles, + Language, + SearchHistory, + HelpAndSupport, + ) + } + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt index b6efe277..0ba2070a 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/SearchButton.kt @@ -33,7 +33,7 @@ fun SearchButton( modifier = modifier, ) { Icon( - painter = Destination.Search.icon, + painter = NavigationItem.Search.icon, contentDescription = StringConstants.Composable.ContentDescription.DashboardSearchButton, tint = LocalContentColor.current, ) diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt index 933876e7..01983bb2 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/MoviesRow.kt @@ -99,7 +99,7 @@ fun MoviesRow( ) { Column( modifier = - Modifier + modifier .styleable(style = style) .focusGroup(), ) { @@ -107,7 +107,7 @@ fun MoviesRow( MovieList( movieList = movieList, contentPadding = LocalContentPadding.current.intoPaddingValues(), - modifier = modifier.focusRestorer(), + modifier = Modifier.focusRestorer(), itemContent = itemContent, ) } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/ProminentMovieCard.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/ProminentMovieCard.kt index 718f321d..45e5693c 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/ProminentMovieCard.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/ProminentMovieCard.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed import com.google.jetstream.data.entities.Movie import com.google.jetstream.presentation.components.feature.LocalEngagementMode import com.google.jetstream.presentation.components.shim.indication.scaleIndication @@ -63,7 +64,7 @@ fun ProminentMovieCard( modifier = modifier, style = style, interactionSource = interactionSource, - onClick = { onMovieClick(movie) }, + onClick = dropUnlessResumed { onMovieClick(movie) }, ) } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt index 5e8aba97..6393ce65 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt @@ -231,7 +231,7 @@ object FeaturedCarouselDefaults { IconButton( onClick = { coroutineScope.launch { - state.nextItem(itemCount) + state.scrollToNextItem(itemCount) } }, enabled = state.hasNextItem(itemCount), @@ -258,7 +258,7 @@ object FeaturedCarouselDefaults { IconButton( onClick = { coroutineScope.launch { - state.previousItem() + state.scrollToPreviousItem() } }, enabled = state.hasPreviousItem(), @@ -328,7 +328,7 @@ object FeaturedCarouselDefaults { /** * Animates to the next item in the carousel if it exists. */ -private suspend fun CarouselState.nextItem( +private suspend fun CarouselState.scrollToNextItem( itemCount: Int, ) { onScrollFinished { @@ -341,7 +341,7 @@ private suspend fun CarouselState.nextItem( /** * Animates to the previous item in the carousel if it exists. */ -private suspend fun CarouselState.previousItem() { +private suspend fun CarouselState.scrollToPreviousItem() { onScrollFinished { if (hasPreviousItem()) { currentCoroutineContext().ensureActive() @@ -390,7 +390,7 @@ private fun Modifier.autoScroll( while (true) { delay(autoScrollInterval) yield() - state.nextItem(itemCount) + state.scrollToNextItem(itemCount) } } } @@ -410,7 +410,7 @@ internal fun Modifier.carouselNavigation( if keyEvent.type == KeyEventType.KeyUp && keyEvent.modifierKeys() == ModifierKeys.None -> { coroutineScope.launch { - state.previousItem() + state.scrollToPreviousItem() } true } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/stylable/StylableCard.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/stylable/StylableCard.kt index 28161b19..4b70ef8a 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/stylable/StylableCard.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/stylable/StylableCard.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.lifecycle.compose.dropUnlessResumed import com.google.jetstream.presentation.theme.JetStreamTokens /** @@ -57,7 +58,7 @@ fun StylableCard( modifier = modifier .clickable( - onClick = onClick, + onClick = dropUnlessResumed(block = onClick), interactionSource = interactionSource, indication = null, enabled = enabled, diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreen.kt index f06f5e4f..f7e5e5f6 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreen.kt @@ -24,10 +24,11 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.fillSize +import androidx.compose.foundation.style.styleable import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -58,13 +59,6 @@ object CategoryMovieListScreen { const val CATEGORY_ID_BUNDLE_KEY = "categoryId" } -val categoryMovieListScreenArguments = - listOf( - navArgument(CategoryMovieListScreen.CATEGORY_ID_BUNDLE_KEY) { - type = NavType.StringType - }, - ) - @Composable fun CategoryMovieListScreen( onBackPressed: () -> Unit, @@ -84,10 +78,16 @@ fun CategoryMovieListScreen( is CategoryMovieListScreenUiState.Done -> { val categoryDetails = s.movieCategoryDetails + val backgroundColor = MaterialTheme.colorScheme.surface CategoryDetails( categoryDetails = categoryDetails, onBackPressed = onBackPressed, onMovieSelected = onMovieSelected, + modifier = + Modifier.styleable { + fillSize() + background(backgroundColor) + }, ) } } @@ -135,7 +135,7 @@ internal fun CategoryMovieList( ), ) LazyVerticalGrid( - columns = GridCells.Fixed(6), + columns = JetStreamTokens.gridCells(), contentPadding = PaddingValues(bottom = JetStreamTokens.VerticalListBottomPadding), ) { itemsIndexed( diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt index 3a4652af..7d0a1f0d 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/categories/CategoryMovieListScreenViewModel.kt @@ -25,6 +25,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -38,9 +39,12 @@ class CategoryMovieListScreenViewModel ) : ViewModel() { val uiState = flowOf(categoryId) - .map { id -> + .map { id -> val categoryDetails = movieRepository.getMovieCategoryDetails(id) CategoryMovieListScreenUiState.Done(categoryDetails) + } + .catch { + emit(CategoryMovieListScreenUiState.Error) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/FavouritesScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/FavouritesScreen.kt index e777c0fa..2f8605bc 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/FavouritesScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/FavouritesScreen.kt @@ -36,7 +36,7 @@ import com.google.jetstream.data.entities.Movie import com.google.jetstream.presentation.components.Loading import com.google.jetstream.presentation.screens.favourites.components.FilteredMoviesGrid import com.google.jetstream.presentation.screens.favourites.components.MovieFilterChipRow -import com.google.jetstream.presentation.screens.favourites.components.rememberFilteredMoviesGridColumns +import com.google.jetstream.presentation.theme.JetStreamTokens import com.google.jetstream.presentation.theme.LocalContentPadding import com.google.jetstream.presentation.theme.Padding @@ -120,7 +120,7 @@ internal fun Catalog( state = filteredMoviesGridState, movieList = favouriteMovieList, onMovieClick = onMovieClick, - columns = rememberFilteredMoviesGridColumns(), + columns = JetStreamTokens.gridCells(), ) } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt deleted file mode 100644 index 493350ab..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.favourites.components - -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.google.jetstream.presentation.components.feature.EngagementMode -import com.google.jetstream.presentation.components.feature.LocalEngagementMode - -@Composable -fun rememberFilteredMoviesGridColumns(): GridCells { - val engagementMode = LocalEngagementMode.current - return remember(engagementMode) { - engagementMode.filteredMovieGridColumns() - } -} - -private fun EngagementMode.filteredMovieGridColumns(): GridCells { - return when (this) { - is EngagementMode.Compact -> GridCells.Fixed(3) - is EngagementMode.Medium -> GridCells.Fixed(4) - else -> GridCells.Fixed(6) - } -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt index b9ed43dd..651676db 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/HomeScreen.kt @@ -114,7 +114,7 @@ internal fun Catalog( val shouldShowTopBar by remember { derivedStateOf { lazyListState.firstVisibleItemIndex == 0 && - lazyListState.firstVisibleItemScrollOffset < 300 + lazyListState.firstVisibleItemScrollOffset < 300 } } diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt index ef605791..5f9bb620 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/moviedetails/MovieDetailsScreenViewModel.kt @@ -25,6 +25,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -37,9 +38,11 @@ class MovieDetailsScreenViewModel repository: MovieRepository, ) : ViewModel() { val uiState = - flowOf(movieId).map { id -> + flowOf(movieId).map { id -> val details = repository.getMovieDetails(movieId = id) MovieDetailsScreenUiState.Done(movieDetails = details) + }.catch { + emit(MovieDetailsScreenUiState.Error) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt index 17f40612..668e8072 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/ProfileScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.foundation.style.ExperimentalFoundationStyleApi import androidx.compose.foundation.style.MutableStyleState import androidx.compose.foundation.style.Style import androidx.compose.foundation.style.fillWidth +import androidx.compose.foundation.style.focused +import androidx.compose.foundation.style.hovered import androidx.compose.foundation.style.styleable import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -38,8 +40,10 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed import com.google.jetstream.R import com.google.jetstream.presentation.app.Destination +import com.google.jetstream.presentation.app.NavigationItem import com.google.jetstream.presentation.components.shim.indication.borderIndication import com.google.jetstream.presentation.components.shim.indication.scaleIndication import com.google.jetstream.presentation.theme.LocalContentPadding @@ -52,11 +56,11 @@ fun ProfileScreen( LazyColumn( contentPadding = LocalContentPadding.current.intoPaddingValues(), ) { - items(Destination.ProfileSettings) { destination -> + items(NavigationItem.ProfileSettings) { item -> SectionListItem( - destination = destination, + navigationItem = item, onClick = { - onDestinationSelected(destination) + onDestinationSelected(item.destination) }, ) } @@ -66,20 +70,21 @@ fun ProfileScreen( @OptIn(ExperimentalFoundationStyleApi::class) @Composable private fun SectionListItem( - destination: Destination, + navigationItem: NavigationItem, modifier: Modifier = Modifier, style: Style = Style, onClick: () -> Unit = {}, ) { - val icon = destination.icon - val name = destination.name + val icon = navigationItem.icon + val name = navigationItem.name val textStyle = MaterialTheme.typography.bodyMedium.copy( fontWeight = FontWeight.Medium, ) + val tintColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) val interactionSource = - remember(destination) { + remember(navigationItem) { MutableInteractionSource() } val styleState = @@ -93,28 +98,33 @@ private fun SectionListItem( fillWidth() borderIndication() scaleIndication() + + focused { + foreground(tintColor) + } + hovered { + foreground(tintColor) + } } } ListItem( trailingContent = { - if (icon != null) { - Icon( - painter = icon, - modifier = - Modifier.styleable { - externalPaddingTop(2.dp) - externalPaddingBottom(2.dp) - externalPaddingStart(4.dp) - size(20.dp) - }, - contentDescription = - stringResource( - id = R.string.profile_screen_listItem_icon_content_description, - name, - ), - ) - } + Icon( + painter = icon, + modifier = + Modifier.styleable { + externalPaddingTop(2.dp) + externalPaddingBottom(2.dp) + externalPaddingStart(4.dp) + size(20.dp) + }, + contentDescription = + stringResource( + id = R.string.profile_screen_listItem_icon_content_description, + name, + ), + ) }, headlineContent = { Text( @@ -137,7 +147,7 @@ private fun SectionListItem( .clickable( interactionSource = interactionSource, indication = null, - onClick = onClick, + onClick = dropUnlessResumed(block = onClick), ) .semantics { role = Role.Button diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreens.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreens.kt deleted file mode 100644 index 4047ce5b..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileScreens.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.jetstream.presentation.screens.profile.compoents - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Subtitles -import androidx.compose.material.icons.filled.Support -import androidx.compose.material.icons.filled.Translate -import androidx.compose.ui.graphics.vector.ImageVector - -enum class ProfileScreens( - val icon: ImageVector, - private val title: String? = null, -) { - Accounts(Icons.Default.Person), - About(Icons.Default.Info), - Subtitles(Icons.Default.Subtitles), - Language(Icons.Default.Translate), - SearchHistory(title = "Search history", icon = Icons.Default.Search), - HelpAndSupport(title = "Help and Support", icon = Icons.Default.Support), - ; - - operator fun invoke() = name - - val tabTitle = title ?: name -} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDeleteDialog.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDeleteDialog.kt similarity index 97% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDeleteDialog.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDeleteDialog.kt index 8b7a7ccc..70d1d22b 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDeleteDialog.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDeleteDialog.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.padding diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDialogButton.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDialogButton.kt similarity index 94% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDialogButton.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDialogButton.kt index ff081918..5a5fb351 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSectionDialogButton.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSectionDialogButton.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.foundation.clickable import androidx.compose.material3.Button diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSelectionItem.kt similarity index 97% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSelectionItem.kt index 25a64c60..bf5d8c51 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSelectionItem.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.foundation.layout.ExperimentalFlexBoxApi import androidx.compose.foundation.layout.ExperimentalGridApi diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/ProfileSectionTitle.kt similarity index 93% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/ProfileSectionTitle.kt index 81b1f1dc..8b8c84ed 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/ProfileSectionTitle.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/ProfileSectionTitle.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SelectionItem.kt similarity index 92% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SelectionItem.kt index 835b4d55..e607631e 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SelectionItem.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SelectionItem.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -30,6 +30,7 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.dropUnlessResumed @OptIn(ExperimentalFoundationStyleApi::class) @Composable @@ -62,7 +63,7 @@ fun SelectionItem( .clickable( interactionSource = interactionSource, indication = null, - onClick = onClick, + onClick = dropUnlessResumed(block = onClick), ) .styleable( styleState = styleState, diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SettingItem.kt similarity index 97% rename from AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt rename to AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SettingItem.kt index 102ee3eb..3841b1d0 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/SettingItem.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SettingItem.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.jetstream.presentation.screens.profile.compoents +package com.google.jetstream.presentation.screens.profile.components import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemColors diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt index 2efd62aa..9b371c87 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionAppVersionTitle import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionDescription import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.AboutSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.ProfileSectionTitle import com.google.jetstream.presentation.theme.LocalContentPadding @OptIn(ExperimentalFoundationStyleApi::class) diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt index 866230a2..7e242e60 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AccountsSection.kt @@ -32,8 +32,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants import com.google.jetstream.presentation.components.TvPreview -import com.google.jetstream.presentation.screens.profile.compoents.AccountsSectionDeleteDialog -import com.google.jetstream.presentation.screens.profile.compoents.AccountsSelectionItem +import com.google.jetstream.presentation.screens.profile.components.AccountsSectionDeleteDialog +import com.google.jetstream.presentation.screens.profile.components.AccountsSelectionItem import com.google.jetstream.presentation.theme.LocalContentPadding @Immutable diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt index f5d32ee6..60c58955 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/HelpAndSupportSection.kt @@ -30,8 +30,8 @@ import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.He import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionFAQItem import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionPrivacyItem import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.HelpAndSupportSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.SettingItem +import com.google.jetstream.presentation.screens.profile.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.SettingItem import com.google.jetstream.presentation.theme.LocalContentPadding @Composable diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt index 4668604a..0f78061b 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/LanguageSection.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.LanguageSectionItems import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.LanguageSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.SelectionItem +import com.google.jetstream.presentation.screens.profile.components.SelectionItem import com.google.jetstream.presentation.theme.LocalContentPadding @Composable diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt index a870a759..ab44c6fc 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SearchHistorySection.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants import com.google.jetstream.data.util.StringConstants.Composable.Placeholders.SampleSearchHistory -import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.SettingItemValue +import com.google.jetstream.presentation.screens.profile.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.SettingItemValue import com.google.jetstream.presentation.theme.LocalContentPadding @Composable diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt index 1591c69f..a729dd71 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/SubtitlesSection.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.google.jetstream.data.util.StringConstants -import com.google.jetstream.presentation.screens.profile.compoents.ProfileSectionTitle -import com.google.jetstream.presentation.screens.profile.compoents.SettingItem +import com.google.jetstream.presentation.screens.profile.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.SettingItem import com.google.jetstream.presentation.theme.LocalContentPadding @OptIn(ExperimentalFoundationStyleApi::class) diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt index 94f7fbca..a6cac7e9 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/videoPlayer/VideoPlayerScreen.kt @@ -61,7 +61,7 @@ import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.modifiers.resizeWithContentScale -import androidx.xr.compose.platform.LocalSpatialConfiguration +import androidx.xr.compose.platform.LocalSession import androidx.xr.compose.spatial.ContentEdge import androidx.xr.compose.spatial.Orbiter import androidx.xr.compose.spatial.Subspace @@ -72,6 +72,7 @@ import androidx.xr.compose.subspace.layout.fillMaxSize import androidx.xr.compose.subspace.layout.height import androidx.xr.compose.subspace.layout.offset import androidx.xr.compose.subspace.layout.width +import androidx.xr.scenecore.scene import com.google.jetstream.R import com.google.jetstream.data.entities.MovieDetails import com.google.jetstream.data.entities.StereoscopicVisionType @@ -96,10 +97,6 @@ import com.google.jetstream.presentation.screens.videoPlayer.components.remember import com.google.jetstream.presentation.screens.videoPlayer.components.toggleImmersiveMode import com.google.jetstream.presentation.utils.handleDPadKeyEvents -object VideoPlayerScreen { - const val MOVIE_ID_BUNDLE_KEY = "movieId" -} - /** * [Work in progress] A composable screen for playing a video. * @@ -212,7 +209,7 @@ private fun VideoPlayer( .collectAsStateWithLifecycle(Size(1980f, 1080f)) val engagementMode = LocalEngagementMode.current - val spatialConfiguration = LocalSpatialConfiguration.current + val session = LocalSession.current val focusRequester = remember { FocusRequester() } @@ -224,11 +221,11 @@ private fun VideoPlayer( action = { when (engagementMode) { EngagementMode.Spatial -> { - spatialConfiguration.requestHomeSpaceMode() + session?.scene?.requestHomeSpaceMode() } EngagementMode.Enclosed -> { - spatialConfiguration.requestFullSpaceMode() + session?.scene?.requestFullSpaceMode() } else -> { diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt index 72ef21e7..05052b98 100644 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/JetStreamTokens.kt @@ -17,12 +17,14 @@ package com.google.jetstream.presentation.theme import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.style.ExperimentalFoundationStyleApi import androidx.compose.foundation.style.Style import androidx.compose.foundation.style.then import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ShapeDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -111,4 +113,16 @@ object JetStreamTokens { clip(true) } then indication() } + + @Composable + fun gridCells(): GridCells { + val engagementMode = LocalEngagementMode.current + return remember(engagementMode) { + when (engagementMode) { + is EngagementMode.Compact -> GridCells.Fixed(3) + is EngagementMode.Medium -> GridCells.Fixed(4) + else -> GridCells.Fixed(6) + } + } + } } diff --git a/AdaptiveJetStream/jetstream/src/main/res/values/strings.xml b/AdaptiveJetStream/jetstream/src/main/res/values/strings.xml index b46b4c98..1d51183c 100644 --- a/AdaptiveJetStream/jetstream/src/main/res/values/strings.xml +++ b/AdaptiveJetStream/jetstream/src/main/res/values/strings.xml @@ -64,6 +64,21 @@ https://www.apache.org/licenses/LICENSE-2.0 Back from video player screen Back from movie details screen Toggle play/pause + + Home + Categories + Movies + Shows + Favourites + Search + Profile + About + Accounts + Subtitles + Language + Search history + Help and Support + Rewind 10 seconds Skip 10 seconds Pan up diff --git a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt index 9c05f4a3..83c9d3a1 100644 --- a/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt +++ b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/screens/ScreenScreenshotTests.kt @@ -17,22 +17,13 @@ package com.google.jetstream.presentation.screens import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp import com.android.tools.screenshot.PreviewTest import com.google.jetstream.presentation.components.AdaptivePreview -import com.google.jetstream.presentation.components.AutoPreview -import com.google.jetstream.presentation.components.DesktopPreview -import com.google.jetstream.presentation.components.FoldablePreview import com.google.jetstream.presentation.components.JetStreamPreview -import com.google.jetstream.presentation.components.PhonePreview -import com.google.jetstream.presentation.components.TabletPreview -import com.google.jetstream.presentation.components.TvPreview import com.google.jetstream.presentation.components.mockCategoryScreenState import com.google.jetstream.presentation.screens.categories.CategoryDetails import com.google.jetstream.presentation.screens.favourites.FavouriteScreenViewModel @@ -187,4 +178,4 @@ fun SearchScreenScreenshot() { } } -// ToDo: define screenshots tests for app level layouts. \ No newline at end of file +// ToDo: define screenshots tests for app level layouts. diff --git a/AdaptiveJetStream/jetstream/src/test/java/com/google/jetstream/presentation/screens/profile/ProfileScreenTest.kt b/AdaptiveJetStream/jetstream/src/test/java/com/google/jetstream/presentation/screens/profile/ProfileScreenTest.kt index 7e1219fe..1050d3fb 100644 --- a/AdaptiveJetStream/jetstream/src/test/java/com/google/jetstream/presentation/screens/profile/ProfileScreenTest.kt +++ b/AdaptiveJetStream/jetstream/src/test/java/com/google/jetstream/presentation/screens/profile/ProfileScreenTest.kt @@ -16,15 +16,8 @@ package com.google.jetstream.presentation.screens.profile -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.google.jetstream.presentation.screens.profile.compoents.ProfileScreenLayoutType -import com.google.jetstream.presentation.screens.profile.compoents.ProfileScreens -import com.google.jetstream.presentation.theme.JetStreamTheme import org.junit.Rule -import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -35,66 +28,5 @@ class ProfileScreenTest { @get:Rule val composeTestRule = createComposeRule() - @Test - fun largeProfileScreen_displaysNavigationItems() { - composeTestRule.setContent { - JetStreamTheme { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.FullyExpanded) - } - } - - ProfileScreens.entries.forEach { screen -> - composeTestRule.onNodeWithText(screen.tabTitle).assertIsDisplayed() - } - } - - @Test - fun largeProfileScreen_navigatesToSections() { - composeTestRule.setContent { - JetStreamTheme { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.FullyExpanded) - } - } - - // Initially on Accounts section - composeTestRule.onNodeWithText("Switch accounts").assertIsDisplayed() - - // Navigate to About - composeTestRule.onNodeWithText(ProfileScreens.About.tabTitle).performClick() - composeTestRule.onNodeWithText("About JetStream").assertIsDisplayed() - - // Navigate to Help and Support - composeTestRule.onNodeWithText(ProfileScreens.HelpAndSupport.tabTitle).performClick() - composeTestRule.onNodeWithText("FAQ's").assertIsDisplayed() - } - - @Test - @Config(qualifiers = "w360dp-h640dp") - fun compactProfileScreen_displaysList() { - composeTestRule.setContent { - JetStreamTheme { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.ListDetail) - } - } - - ProfileScreens.entries.forEach { screen -> - composeTestRule.onNodeWithText(screen.tabTitle).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = "w360dp-h640dp") - fun compactProfileScreen_navigatesToDetail() { - composeTestRule.setContent { - JetStreamTheme { - ProfileScreen(profileScreenLayoutType = ProfileScreenLayoutType.ListDetail) - } - } - - // Click on About - composeTestRule.onNodeWithText(ProfileScreens.About.tabTitle).performClick() - - // Detail pane should show "About JetStream" - composeTestRule.onNodeWithText("About JetStream").assertIsDisplayed() - } + // Define screenshot tests for the Profile screen. }