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..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.1.1" -android-test-plugin = "9.1.1" -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.03.01" -compose-latest = "1.11.0-rc01" +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" @@ -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-beta01" media3 = "1.10.0" -navigation-compose = "2.9.7" +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" @@ -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" } @@ -63,7 +66,9 @@ 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" } 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" } @@ -76,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/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..eb2a5b90 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" @@ -64,6 +64,7 @@ configure { } getByName("release") { isMinifyEnabled = true + isShrinkResources = true signingConfig = signingConfigs.getByName("debug") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -194,7 +195,10 @@ 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) + 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..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" /> - Unit, modifier: Modifier = Modifier, - appState: AppState = rememberAppState(), ) { - val navController = rememberNavController() + val navigator = rememberNavigator(initialDestination = 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( + backStack = navigator.backStack, + modifier = modifier, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + 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( + current = navigator.current, + onNavigation = navigator::navigate, + isVisible = isTopbarVisible, + ) + }, + subNavigation = { + appNavigation.SubNavigation( + current = navigator.current, + onNavigation = navigator::navigate, + ) + }, + ), + ), + entryProvider = + entryProvider { + 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) - val navigationComponentType = rememberNavigationComponentType() + profileEntries(navigator) + }, + ) + } +} - val keyboardShortcuts = - rememberKeyboardShortcuts( - onSelectScreen = { screen -> - if (appState.selectedScreen != screen) { - navController.navigate(screen()) - } +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, ) + } +} - LaunchedEffect(Unit) { - navController.addOnDestinationChangedListener { _, destination, _ -> - appState.updateSelectedScreen(destination) - } +private fun EntryProviderScope.categoriesEntry( + navigator: Navigator, +) { + entry( + metadata = + metadata { + put(PresentationType.PresentationTypeKey, PresentationType.SinglePane) + }, + ) { + CategoriesScreen( + onCategoryClick = { + navigator.navigate(Destination.CategoryMovieList(categoryId = it)) + }, + ) + } + + 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, + ) } +} - LaunchedEffect(navigationComponentType) { - appState.updateNavigationComponentType(navigationComponentType) +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, + ) } +} - // 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) }, +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, ) } +} - 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) - } - } +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, + ) + } +} - 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) - } +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 } + } - else -> { - // All other form factors (phone, tablet, foldable etc). - AppWithNavigationSuiteScaffold( - appState = appState, - navController = navController, - keyboardShortcuts = keyboardShortcuts, - modifier = modifier, - ) { paddingValues -> - mainContent(paddingValues) - } + 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 new file mode 100644 index 00000000..482a9a6d --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/AppLayoutSceneDecoratorStrategy.kt @@ -0,0 +1,356 @@ +/* + * 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.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.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 +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.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.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 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, + val subNavigation: @Composable () -> Unit = {}, + val navigation: @Composable () -> Unit = {}, +) : SceneDecoratorStrategy { + override fun SceneDecoratorStrategyScope.decorateScene( + scene: Scene, + ): Scene { + val presentationType = scene.metadata[PresentationType.PresentationTypeKey] + + // 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 presentationType = scene.metadata[PresentationType.PresentationTypeKey] + + val isNavigationVisible = presentationType != PresentationType.Overlay + + Subspace { + SpatialPanel( + resizePolicy = resizePolicy, + modifier = SubspaceModifier.movable(), + ) { + 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, ExperimentalFoundationStyleApi::class) + override val content: @Composable (() -> Unit) = { + val layout = selectLayout() + val subNavigationArea = layout.subNavigationArea + val backgroundColor = MaterialTheme.colorScheme.surface + + Grid( + config = layout.gridConfig, + modifier = + Modifier.styleable { + background(backgroundColor) + }, + ) { + Box( + modifier = + Modifier.gridItem( + column = layout.navigationArea.column, + row = layout.navigationArea.row, + rowSpan = layout.navigationArea.rowSpan, + columnSpan = layout.navigationArea.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, EngagementMode.Cabin, is EngagementMode.Workstation -> 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 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 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 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 gridConfig: 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 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 gridConfig: GridConfigurationScope.() -> Unit = { + column(1f) + row(GridTrackSize.Auto) + row(GridTrackSize.MinMax(300.dp, 1.fr)) + } + override val navigationArea: GridArea = GridArea(row = 1, column = 1) + override val subNavigationArea: GridArea? = null + override val contentArea: GridArea = GridArea(row = 2, column = 1) + } +} + +/** + * Data structure describing a grid area. + */ +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..7ac027e5 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/Destination.kt @@ -0,0 +1,72 @@ +/* + * 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.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +// ToDo: update names to read the resource file +@Serializable +sealed class Destination : NavKey { + @Serializable + data object Home : Destination() + + @Serializable + data object Categories : Destination() + + @Serializable + data object Movies : Destination() + + @Serializable + data object Shows : Destination() + + @Serializable + data object Favourites : Destination() + + @Serializable + data object Search : Destination() + + @Serializable + data object Profile : Destination() + + @Serializable + data class CategoryMovieList(val categoryId: String) : Destination() + + @Serializable + data class MovieDetails(val movieId: String) : Destination() + + @Serializable + data class VideoPlayer(val movieId: String) : Destination() + + @Serializable + data object About : Destination() + + @Serializable + data object Accounts : Destination() + + @Serializable + data object Subtitles : Destination() + + @Serializable + data object Language : Destination() + + @Serializable + data object SearchHistory : Destination() + + @Serializable + 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 new file mode 100644 index 00000000..a5d6086c --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/app/JetStreamAppNavigation.kt @@ -0,0 +1,484 @@ +/* + * 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, + ) { + NavigationItem.RootDestinations.forEach { item -> + NavigationRailItem( + selected = item.destination == current, + onClick = { onNavigation(item.destination) }, + icon = { + Icon( + painter = item.icon, + contentDescription = item.name, + modifier = Modifier.size(24.dp), + ) + }, + label = { + Text(text = item.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, + ) + } + } +} + +/** + * Navigation implementation optimized for the spatial UI. + */ +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 + } + } + } +} + +/** + * 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, + 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) + externalPaddingTop(8.dp) + externalPaddingBottom(8.dp) + } + .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)) + } +} + +/** + * 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?, + 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(NavigationItem.RootDestinations.size + 1) { + FocusRequester() + } + } + + PrimaryTabRow( + selectedTabIndex = selectedTabIndex, + divider = {}, + modifier = + modifier + .focusRestorer(fallback = focusRequesterList[selectedTabIndex]) + .focusGroup(), + ) { + NavigationItem.RootDestinations.forEachIndexed { index, item -> + Tab( + selected = item.destination == current, + onClick = { + onTabClicked(item.destination) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + onTabFocused(item.destination) + } + } + .focusRequester(focusRequesterList[index]), + ) { + Text(text = item.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 = NavigationItem.Search.icon, + contentDescription = NavigationItem.Search.name, + ) + } + } +} + +/** + * Determines the currently selected tab index based on the [current] destination. + * 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 = NavigationItem.RootDestinations.indexOfFirst { it.destination == current } + return when { + index > -1 -> index + current == Destination.Search -> NavigationItem.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/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/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..0ba2070a 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 = NavigationItem.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/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/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/FeaturedCarousel.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/components/shim/FeaturedCarousel.kt index e9db7523..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,22 +328,20 @@ 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 { - if (hasNextItem(itemCount)) { - currentCoroutineContext().ensureActive() - val nextItemIndex = currentItem + 1 - animateScrollToItem(nextItemIndex) - } + currentCoroutineContext().ensureActive() + val nextItemIndex = (currentItem + 1) % itemCount + animateScrollToItem(nextItemIndex) } } /** * 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() @@ -392,7 +390,7 @@ private fun Modifier.autoScroll( while (true) { delay(autoScrollInterval) yield() - state.nextItem(itemCount) + state.scrollToNextItem(itemCount) } } } @@ -412,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/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/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/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/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 f61114c1..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 @@ -16,40 +16,45 @@ 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.catch +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, - ) + .catch { + emit(CategoryMovieListScreenUiState.Error) + }.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/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 25900e2d..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/favourites/components/RememberFilteredMovieGridColumns.kt +++ /dev/null @@ -1,66 +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.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 - -@Composable -fun rememberFilteredMoviesGridColumns( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, -): GridCells { - return remember(navigationComponentType, windowSizeClass) { - calculateFilteredMoviesGridColumns(navigationComponentType, windowSizeClass) - } -} - -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) - } - } -} 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..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 @@ -27,6 +27,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 +154,7 @@ internal fun Catalog( } } -@OptIn(ExperimentalFoundationStyleApi::class) +@OptIn(ExperimentalFoundationStyleApi::class, ExperimentalFoundationApi::class) @Composable private fun Catalog( featuredMovies: List, @@ -168,6 +170,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 +210,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 +224,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 +245,9 @@ private fun Catalog( onMovieClick = onMovieClick, modifier = Modifier.focusRequester(top10), onExpanded = onExpanded, - onCollapsed = onCollapsed, + onCollapsed = { + onCollapsed() + }, ) } 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..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 @@ -16,39 +16,43 @@ 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.catch +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) + }.catch { + emit(MovieDetailsScreenUiState.Error) + }.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..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 @@ -16,426 +16,141 @@ 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.focused +import androidx.compose.foundation.style.hovered +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 androidx.lifecycle.compose.dropUnlessResumed 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.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 -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(NavigationItem.ProfileSettings) { item -> + SectionListItem( + navigationItem = item, + onClick = { + onDestinationSelected(item.destination) + }, + ) } } } +@OptIn(ExperimentalFoundationStyleApi::class) @Composable -private fun LargeProfileScreen( - sidebarWidthFraction: Float, - contentPadding: Padding = LocalContentPadding.current, +private fun SectionListItem( + navigationItem: NavigationItem, + 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 = navigationItem.icon + val name = navigationItem.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 - } + val tintColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - 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(navigationItem) { + 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) } + val defaultStyle = + remember { + Style { + fillWidth() + borderIndication() + scaleIndication() - 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 - } - } - } + focused { + foreground(tintColor) } - } - }, - 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() - } - } - } + hovered { + foreground(tintColor) } } - }, - ) -} + } -@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) { + ListItem( + trailingContent = { Icon( - Icons.Default.ArrowBackIosNew, + painter = icon, modifier = - Modifier - .padding(vertical = 2.dp) - .padding(start = 4.dp) - .size(36.dp), + Modifier.styleable { + externalPaddingTop(2.dp) + externalPaddingBottom(2.dp) + externalPaddingStart(4.dp) + size(20.dp) + }, contentDescription = stringResource( - id = R.string.back_content_description, - profileScreen.tabTitle, + id = R.string.profile_screen_listItem_icon_content_description, + name, ), ) - 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) - } - } + }, + headlineContent = { + Text( + text = name, + style = textStyle, + modifier = + Modifier.styleable { + fillWidth() + textStyle(textStyle) + }, + ) + }, + modifier = + modifier + .styleable( + styleState = styleState, + defaultStyle, + style, + ) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = dropUnlessResumed(block = 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 deleted file mode 100644 index eea459f4..00000000 --- a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/compoents/AccountsSelectionItem.kt +++ /dev/null @@ -1,141 +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.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.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 - -@Composable -fun AccountsSelectionItem( - accountsSectionData: AccountsSectionData, - modifier: Modifier = Modifier, - isExpanded: Boolean = true, - key: Any? = null, -) { - key(key) { - Surface( - modifier = - modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)) - .clickable(onClick = accountsSectionData.onClick), - shape = MaterialTheme.shapes.extraSmall, - ) { - if (isExpanded) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.Bottom, - ) { - AccountData(accountsSectionData) - } - } else { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - AccountData(accountsSectionData) - } - } - } - } -} - -@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/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/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/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/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/components/AccountsSelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSelectionItem.kt new file mode 100644 index 00000000..bf5d8c51 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/AccountsSelectionItem.kt @@ -0,0 +1,90 @@ +/* + * 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.components + +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.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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, +) { + 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.styleable { + fillSize() + contentPadding(16.dp) + }, + ) { + 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.styleable { + alpha(0.75f) + }, + ) + } + } + } +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/ProfileSectionTitle.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/ProfileSectionTitle.kt new file mode 100644 index 00000000..8b8c84ed --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/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.components + +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/components/SelectionItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SelectionItem.kt new file mode 100644 index 00000000..e607631e --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SelectionItem.kt @@ -0,0 +1,75 @@ +/* + * 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.components + +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 +import androidx.lifecycle.compose.dropUnlessResumed + +@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 = dropUnlessResumed(block = onClick), + ) + .styleable( + styleState = styleState, + style = { + // ToDo: add hover & focus state + }, + ), + ) +} diff --git a/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SettingItem.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/SettingItem.kt new file mode 100644 index 00000000..3841b1d0 --- /dev/null +++ b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/components/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.components + +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/section/AboutSection.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/screens/profile/section/AboutSection.kt new file mode 100644 index 00000000..9b371c87 --- /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.components.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..7e242e60 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.components.AccountsSectionDeleteDialog +import com.google.jetstream.presentation.screens.profile.components.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..60c58955 --- /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.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.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..0f78061b --- /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.components.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..ab44c6fc --- /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.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.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..a729dd71 --- /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.components.ProfileSectionTitle +import com.google.jetstream.presentation.screens.profile.components.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/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/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..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 @@ -65,6 +67,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 { @@ -109,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/java/com/google/jetstream/presentation/theme/Size.kt b/AdaptiveJetStream/jetstream/src/main/java/com/google/jetstream/presentation/theme/Size.kt index a0bc2c8b..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.app.NavigationComponentType -import com.google.jetstream.presentation.app.rememberNavigationComponentType -import com.google.jetstream.presentation.components.feature.isIWidthCompact -import com.google.jetstream.presentation.components.feature.isWidthMedium +import com.google.jetstream.presentation.components.feature.EngagementMode +import com.google.jetstream.presentation.components.feature.LocalEngagementMode val LocalFeaturedCarouselHeight: ProvidableCompositionLocal = staticCompositionLocalOf { @@ -41,40 +38,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 +89,29 @@ private fun WindowSizeClass.cardWidth(): Dp { } @Composable -fun rememberCategoryGridColumns( - navigationComponentType: NavigationComponentType = rememberNavigationComponentType(), -): GridCells { - return remember(navigationComponentType) { - when (navigationComponentType) { - NavigationComponentType.TopBar -> GridCells.Fixed(4) - else -> GridCells.Fixed(3) +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/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/components/ComponentScreenshotTests.kt b/AdaptiveJetStream/jetstream/src/screenshotTest/kotlin/com/google/jetstream/presentation/components/ComponentScreenshotTests.kt index 6471ae2b..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,8 +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 -import com.google.jetstream.presentation.screens.Screens @PreviewTest @Preview @@ -128,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..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,27 +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.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 -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 @@ -192,78 +178,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. 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 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. }