feat: add presentation-app code
Test: NA
Change-Id: I049551d6496e013df2a1aecd3e7fe5b76e7b2b62
diff --git a/tv/integration-tests/presentation/README.md b/tv/integration-tests/presentation/README.md
new file mode 100644
index 0000000..eb86b24
--- /dev/null
+++ b/tv/integration-tests/presentation/README.md
@@ -0,0 +1,14 @@
+# Presentation app
+
+## Setup
+
+* Uncomment the `coil` and `gson` libraries dependency additions from the `build.gradle` file.
+* Create the `data.json` file in `presentation/src/main/assets` directory and add the content from
+ this link: go/compose-tv-presentation-app-data
+
+> If you are not a Googler and want to use this app for
+> testing, you will have to create the `data.json` file by following the schema mentioned in the
+`Data.kt` file
+
+* Uncomment the function content and imports from
+ `presentation/src/main/java/androidx/tv/integration/presentation/ExternalLibs.kt` file
diff --git a/tv/integration-tests/presentation/build.gradle b/tv/integration-tests/presentation/build.gradle
index b712865..85dc901 100644
--- a/tv/integration-tests/presentation/build.gradle
+++ b/tv/integration-tests/presentation/build.gradle
@@ -32,6 +32,10 @@
implementation(project(":compose:material3:material3"))
implementation(project(":navigation:navigation-runtime"))
implementation(project(":profileinstaller:profileinstaller"))
+ implementation "androidx.compose.material:material-icons-extended:1.3.1"
+
+// implementation 'io.coil-kt:coil-compose:2.2.2'
+// implementation 'com.google.code.gson:gson:2.8.9'
implementation(project(":tv:tv-foundation"))
implementation(project(":tv:tv-material"))
@@ -75,3 +79,8 @@
using project(":lifecycle:lifecycle-viewmodel-savedstate")
}
}
+
+repositories {
+ mavenCentral()
+ google()
+}
diff --git a/tv/integration-tests/presentation/src/main/assets/.gitignore b/tv/integration-tests/presentation/src/main/assets/.gitignore
new file mode 100644
index 0000000..114ea57
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/assets/.gitignore
@@ -0,0 +1 @@
+data.json
\ No newline at end of file
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AlignmentCenter.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AlignmentCenter.kt
new file mode 100644
index 0000000..9284cd6
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AlignmentCenter.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun AlignmentCenter(
+ modifier: Modifier = Modifier,
+ horizontalAxis: Boolean = false,
+ verticalAxis: Boolean = false,
+ content: @Composable RowScope.() -> Unit
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = if (horizontalAxis) Arrangement.Center else Arrangement.Start,
+ verticalAlignment = if (verticalAxis) Alignment.CenterVertically else Alignment.Top,
+ ) {
+ content()
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/App.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/App.kt
new file mode 100644
index 0000000..4078a99
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/App.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.nativeKeyCode
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.ModalNavigationDrawer
+
+val pageColor = Color(0xff18171a)
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun App() {
+ val tabs = listOf("Home", "Movies", "Shows")
+ var selectedTabIndex by remember { mutableStateOf(0) }
+ val homePageFr = remember { FocusRequester() }
+ val moviesPageFr = remember { FocusRequester() }
+ val showsPageFr = remember { FocusRequester() }
+
+ val tabRow = @Composable {
+ AppTabRow(
+ tabs = tabs,
+ selectedTabIndex = selectedTabIndex,
+ onSelectedTabIndexChange = { selectedTabIndex = it },
+ modifier = Modifier
+ .zIndex(100f)
+ .onKeyEvent {
+ if (it.key.nativeKeyCode == Key.DirectionDown.nativeKeyCode) {
+ val fr = when (selectedTabIndex) {
+ 0 -> homePageFr
+ 1 -> moviesPageFr
+ 2 -> showsPageFr
+ else -> null
+ }
+ fr?.requestFocus()
+ true
+ } else
+ false
+ }
+ )
+ }
+
+ val homepage = @Composable {
+ TvLazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .focusRequester(homePageFr)
+ .background(pageColor)
+ ) {
+ item {
+ FeaturedCarousel(
+// modifier = Modifier.focusRequester(homePageFr)
+ )
+ AppSpacer(height = 50.dp)
+ }
+ movieCollections.forEach { movieCollection ->
+ item {
+ AppLazyRow(
+ title = movieCollection.label,
+ items = movieCollection.items,
+ drawItem = { movie, _, modifier ->
+ ImageCard(
+ movie,
+ aspectRatio = 2f / 3,
+ modifier = modifier
+ )
+ }
+ )
+ AppSpacer(height = 35.dp)
+ }
+ }
+ }
+ }
+
+ val moviesPage = @Composable {
+ TvLazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(pageColor)
+ ) {
+ item {
+ AppImmersiveList(Modifier.focusRequester(moviesPageFr))
+ }
+ }
+ }
+
+ val showsPage = @Composable {
+ TvLazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(pageColor)
+ ) {
+ item {
+ ShowsGrid(Modifier.focusRequester(showsPageFr))
+ }
+ }
+ }
+
+ val activePage: MutableState<(@Composable () -> Unit)> = remember(selectedTabIndex) {
+ mutableStateOf(
+ when (selectedTabIndex) {
+ 0 -> homepage
+ 1 -> moviesPage
+ 2 -> showsPage
+ else -> homepage
+ }
+ )
+ }
+
+ ModalNavigationDrawer(
+ drawerContent = {
+ Sidebar(
+ selectedIndex = selectedTabIndex,
+ onIndexChange = { selectedTabIndex = it }
+ )
+ }
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ activePage.value()
+ tabRow()
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppButton.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppButton.kt
new file mode 100644
index 0000000..9857021
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppButton.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+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.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Text
+
+@Composable
+fun AppButton(
+ text: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = modifier
+ .border(2.dp, if (isFocused) Color.White else Color.Transparent, RoundedCornerShape(50))
+ .padding(4.dp)
+ .background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(50))
+ .padding(top = 5.dp, bottom = 5.dp, start = 10.dp, end = 15.dp)
+ .onFocusChanged {
+ isFocused = it.isFocused
+ }
+ .focusable(),
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(25.dp),
+ tint = Color.Black
+ )
+ Text(text = text, fontSize = 12.sp)
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppImmersiveList.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppImmersiveList.kt
new file mode 100644
index 0000000..b087bc3
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppImmersiveList.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.ImmersiveList
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun AppImmersiveList(modifier: Modifier = Modifier) {
+ ImmersiveList(
+ modifier = modifier
+ .height(500.dp)
+ .fillMaxSize(),
+ background = { index, _ ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ val movie = topPicksForYou[index]
+ LandscapeImageBackground(movie)
+ }
+ }
+ ) {
+ AppLazyRow(
+ title = "",
+ items = topPicksForYou,
+ modifier = Modifier
+ ) { movie, index, modifier ->
+ ImageCard(
+ movie,
+// aspectRatio = 2f / 3,
+ modifier = modifier.immersiveListItem(index)
+ )
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppLazyRow.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppLazyRow.kt
new file mode 100644
index 0000000..455dc59
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppLazyRow.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import androidx.tv.material3.Text
+
+@Composable
+fun AppLazyRow(
+ title: String,
+ items: List<Movie>,
+ modifier: Modifier = Modifier,
+ drawItem: @Composable (movie: Movie, index: Int, modifier: Modifier) -> Unit
+) {
+ val paddingLeft = 58.dp
+ var hasFocus by remember { mutableStateOf(false) }
+
+ Column(modifier = modifier.onFocusChanged { hasFocus = it.hasFocus }) {
+ Text(
+ text = title,
+ color = if (hasFocus) Color.White else Color.White.copy(alpha = 0.8f),
+ fontSize = 14.sp,
+ modifier = Modifier.padding(start = paddingLeft)
+ )
+
+ AppSpacer(height = 12.dp)
+
+ TvLazyRow(
+ contentPadding = PaddingValues(horizontal = paddingLeft),
+ horizontalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ items.forEachIndexed { index, movie ->
+ item {
+ drawItem(
+ movie = movie,
+ index = index,
+ modifier = Modifier
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppSpacer.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppSpacer.kt
new file mode 100644
index 0000000..c8a57ec
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppSpacer.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+
+@Composable
+fun AppSpacer(
+ width: Dp? = null,
+ height: Dp? = null
+) {
+ var modifier: Modifier = Modifier
+ if (width != null) {
+ modifier = modifier.width(width)
+ }
+ if (height != null) {
+ modifier = modifier.height(height)
+ }
+ Spacer(modifier)
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppTabRow.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppTabRow.kt
new file mode 100644
index 0000000..b1a566e
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/AppTabRow.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.Tab
+import androidx.tv.material3.TabDefaults
+import androidx.tv.material3.TabRow
+import androidx.tv.material3.Text
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun AppTabRow(
+ tabs: List<String>,
+ selectedTabIndex: Int,
+ onSelectedTabIndexChange: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ AlignmentCenter(horizontalAxis = true) {
+ FocusGroup {
+ TabRow(
+ selectedTabIndex = selectedTabIndex,
+ separator = { Spacer(modifier = Modifier.width(4.dp)) },
+ modifier = modifier.padding(top = 20.dp),
+// indicator = @Composable { tabPositions ->
+// tabPositions.getOrNull(selectedTabIndex)?.let {
+// TabRowDefaults.PillIndicator(
+// currentTabPosition = it,
+// inactiveColor = Color(0xFFE5E1E6),
+// )
+// }
+// }
+ ) {
+ tabs.forEachIndexed { index, tabLabel ->
+ val tabModifier = Modifier.restorableFocus()
+ val firstTabModifier = Modifier.initiallyFocused()
+ val isFirstTab = index == 0
+
+ Tab(
+ selected = selectedTabIndex == index,
+ onFocus = { onSelectedTabIndexChange(index) },
+ colors = TabDefaults.pillIndicatorTabColors(
+ contentColor = LocalContentColor.current,
+// selectedContentColor = Color(0xFF313033),
+ ),
+ modifier = if (isFirstTab) firstTabModifier else tabModifier
+ ) {
+ Text(
+ text = tabLabel,
+ fontSize = 12.sp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/BringIntoViewIfChildrenAreFocused.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/BringIntoViewIfChildrenAreFocused.kt
new file mode 100644
index 0000000..2ad15c7
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/BringIntoViewIfChildrenAreFocused.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.relocation.BringIntoViewResponder
+import androidx.compose.foundation.relocation.bringIntoViewResponder
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.debugInspectorInfo
+
+@OptIn(ExperimentalFoundationApi::class)
+internal fun Modifier.bringIntoViewIfChildrenAreFocused(): Modifier = composed(
+ inspectorInfo = debugInspectorInfo { name = "bringIntoViewIfChildrenAreFocused" },
+ factory = {
+ var myRect: Rect = Rect.Zero
+ this
+ .onSizeChanged {
+ myRect = Rect(Offset.Zero, Offset(it.width.toFloat(), it.height.toFloat()))
+ }
+ .bringIntoViewResponder(
+ remember {
+ object : BringIntoViewResponder {
+ // return the current rectangle and ignoring the child rectangle received.
+ @ExperimentalFoundationApi
+ override fun calculateRectForParent(localRect: Rect): Rect = myRect
+
+ // The container is not expected to be scrollable. Hence the child is
+ // already in view with respect to the container.
+ @ExperimentalFoundationApi
+ override suspend fun bringChildIntoView(localRect: () -> Rect?) {
+ }
+ }
+ }
+ )
+ }
+)
\ No newline at end of file
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Data.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Data.kt
new file mode 100644
index 0000000..afe48bf
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Data.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+data class MovieImage(val url: String, val aspect: String)
+data class Movie(val name: String, val images: List<MovieImage>, val root: String)
+data class MovieCollection(val label: String, val items: List<Movie>)
+data class RootData(
+ val data: List<MovieCollection>,
+ val featuredCarouselMovies: List<String>,
+ val commonDescription: String
+)
+
+var movieCollections = listOf<MovieCollection>()
+var topPicksForYou = listOf<Movie>()
+var allMovies = listOf<Movie>()
+var featuredCarouselMovies = listOf<Movie>()
+var commonDescription = ""
+
+val Movie.description: String
+ get() = commonDescription
+
+fun getMovieImageUrl(
+ movie: Movie,
+ aspect: String = "orientation/backdrop_16x9"
+): String =
+ movie
+ .images
+ .find { image -> image.aspect == aspect }?.url ?: movie.images.first().url
+
+fun initializeData(rootData: RootData) {
+ commonDescription = rootData.commonDescription
+ movieCollections = rootData.data
+ topPicksForYou = movieCollections[3].items
+ allMovies = movieCollections.flatMap { it.items }.reversed()
+ featuredCarouselMovies = run {
+ val titles = rootData.featuredCarouselMovies
+ val previousTitles = mutableListOf<String>()
+
+ movieCollections.flatMap { it.items }.filter {
+ if (previousTitles.contains(it.name)) {
+ false
+ } else {
+ previousTitles.add(it.name)
+ titles.contains(it.name)
+ }
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ExternalLibs.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ExternalLibs.kt
new file mode 100644
index 0000000..4a497f2
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ExternalLibs.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+// import coil.compose.AsyncImage
+// import com.google.gson.Gson
+
+fun getRootDataFromJson(jsonData: String): RootData {
+ Log.d("LOL", "getRootDataFromJson: $jsonData")
+// return Gson().fromJson(jsonData, RootData::class.java)
+ return RootData(listOf(), listOf(), "")
+}
+
+@Composable
+fun AppAsyncImage(
+ imageUrl: String,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit,
+ alignment: Alignment = Alignment.Center,
+ contentDescription: String? = null
+) {
+ Log.d(
+ "LOL",
+ "AppAsyncImage: $imageUrl, $modifier, $contentScale, $alignment, $contentDescription"
+ )
+// AsyncImage(
+// model = imageUrl,
+// contentScale = contentScale,
+// alignment = alignment,
+// modifier = modifier,
+// contentDescription = contentDescription
+// )
+}
\ No newline at end of file
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FeaturedCarousel.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FeaturedCarousel.kt
new file mode 100644
index 0000000..33e5b16
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FeaturedCarousel.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+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.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ArrowRight
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Carousel
+import androidx.tv.material3.CarouselDefaults
+import androidx.tv.material3.CarouselScope
+import androidx.tv.material3.CarouselState
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Text
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun FeaturedCarousel(
+ movies: List<Movie> = featuredCarouselMovies,
+ modifier: Modifier = Modifier
+) {
+ val carouselState: CarouselState = remember { CarouselState() }
+ val slidesCount = movies.size
+
+ Carousel(
+ itemCount = slidesCount,
+ carouselState = carouselState,
+ modifier = modifier
+ .height(340.dp)
+ .fillMaxWidth(),
+ carouselIndicator = {
+ CarouselDefaults.IndicatorRow(
+ itemCount = slidesCount,
+ activeItemIndex = carouselState.activeItemIndex,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(end = 58.dp, bottom = 16.dp),
+ )
+ }
+ ) { itemIndex ->
+ val movie = movies[itemIndex]
+
+ CarouselSlide(
+ title = movie.name,
+ description = movie.description,
+ background = {
+ LandscapeImageBackground(movie)
+ },
+ actions = {
+ AppButton(
+ text = "Watch on YouTube",
+ icon = Icons.Outlined.ArrowRight,
+ )
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun CarouselScope.CarouselSlide(
+ title: String,
+ description: String,
+ background: @Composable () -> Unit,
+ actions: @Composable () -> Unit
+) {
+ CarouselItem(
+ background = {
+ background()
+ },
+ modifier = Modifier
+ ) {
+ Column(
+ modifier = Modifier.padding(start = 58.dp, top = 150.dp)
+ ) {
+ Text(
+ text = title,
+ color = Color.White,
+ fontSize = 40.sp
+ )
+
+ AppSpacer(height = 16.dp)
+
+ Text(
+ text = description,
+ color = Color.White,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ modifier = Modifier.width(500.dp),
+ )
+
+ AppSpacer(height = 15.dp)
+
+ actions()
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FocusGroup.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FocusGroup.kt
new file mode 100644
index 0000000..928dcf9
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/FocusGroup.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.currentCompositeKeyHash
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.ExperimentalComposeUiApi
+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.onFocusChanged
+import androidx.compose.ui.platform.LocalFocusManager
+
+/**
+ * Composable container that provides modifier extensions to allow focus to be restored to the
+ * element that was previously focused within the TvFocusGroup.
+ *
+ * @param modifier the modifier to apply to this group.
+ * @param content the content that is present within the group and can use focus-group modifier
+ * extensions.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun FocusGroup(
+ modifier: Modifier = Modifier,
+ content: @Composable FocusGroupScope.() -> Unit
+) {
+ val focusManager = LocalFocusManager.current
+ val focusGroupKeyHash = currentCompositeKeyHash
+
+ // TODO: Is this the intended way to call rememberSaveable
+ // with key set to parentHash?
+ val previousFocusedItemHash: MutableState<Int?> = rememberSaveable(
+ key = focusGroupKeyHash.toString()
+ ) {
+ mutableStateOf(null)
+ }
+
+ val state = FocusGroupState(previousFocusedItemHash = previousFocusedItemHash)
+
+ Box(
+ modifier = modifier
+ .onFocusChanged {
+ if (it.isFocused) {
+ if (state.noRecordedState()) {
+ focusManager.moveFocus(FocusDirection.Enter)
+ } else {
+ if (state.focusRequester != FocusRequester.Default) {
+ try {
+ state.focusRequester.requestFocus()
+ } catch (e: Exception) {
+ Log.w("TvFocusGroup", "TvFocusGroup: Failed to request focus", e)
+ }
+ } else {
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
+ }
+ }
+ }
+ .focusable(),
+ content = { FocusGroupScope(state).content() }
+ )
+}
+
+/**
+ * Scope containing the modifier extensions to be used within [FocusGroup].
+ */
+class FocusGroupScope internal constructor(private val state: FocusGroupState) {
+ private var currentFocusableIdIndex = 0
+
+ private fun generateUniqueFocusableId(): Int = currentFocusableIdIndex++
+
+ /**
+ * Modifier that records if the item was in focus before it moved out of the group. When focus
+ * enters the [FocusGroup], the item will be returned focus.
+ */
+ @SuppressLint("ComposableModifierFactory")
+ @Composable
+ fun Modifier.restorableFocus(): Modifier =
+ this.restorableFocus(focusId = rememberSaveable { generateUniqueFocusableId() })
+
+ /**
+ * Modifier that marks the current composable as the item to gain focus initially when focus
+ * enters the [FocusGroup]. When focus enters the [FocusGroup], the item will be returned focus.
+ */
+ @SuppressLint("ComposableModifierFactory")
+ @Composable
+ fun Modifier.initiallyFocused(): Modifier {
+ val focusId = rememberSaveable { generateUniqueFocusableId() }
+ if (state.noRecordedState()) {
+ state.recordFocusedItemHash(focusId)
+ }
+ return this.restorableFocus(focusId)
+ }
+
+ @SuppressLint("ComposableModifierFactory")
+ @OptIn(ExperimentalComposeUiApi::class)
+ @Composable
+ private fun Modifier.restorableFocus(focusId: Int): Modifier {
+ val focusRequester = remember { FocusRequester() }
+ var isFocused = remember { false }
+ val isCurrentlyFocused by rememberUpdatedState(isFocused)
+ val focusManager = LocalFocusManager.current
+ state.associatedWith(focusId, focusRequester)
+ DisposableEffect(Unit) {
+ onDispose {
+ state.clearDisposedFocusRequester(focusId)
+ if (isCurrentlyFocused) {
+ focusManager.moveFocus(FocusDirection.Exit)
+ focusManager.moveFocus(FocusDirection.Enter)
+ }
+ }
+ }
+
+ return this
+ .focusRequester(focusRequester)
+ .onFocusChanged {
+ isFocused = it.isFocused || it.hasFocus
+ if (isFocused) {
+ state.recordFocusedItemHash(focusId)
+ state.associatedWith(focusId, focusRequester)
+ }
+ }
+ }
+}
+
+@Stable
+internal class FocusGroupState(
+ private var previousFocusedItemHash: MutableState<Int?>
+) {
+ internal var focusRequester: FocusRequester = FocusRequester.Default
+ private set
+
+ internal fun recordFocusedItemHash(itemHash: Int) {
+ previousFocusedItemHash.value = itemHash
+ }
+
+ internal fun clearDisposedFocusRequester(itemHash: Int) {
+ if (previousFocusedItemHash.value == itemHash) {
+ focusRequester = FocusRequester.Default
+ }
+ }
+
+ internal fun associatedWith(itemHash: Int, focusRequester: FocusRequester) {
+ if (previousFocusedItemHash.value == itemHash) {
+ this.focusRequester = focusRequester
+ }
+ }
+
+ internal fun noRecordedState(): Boolean =
+ previousFocusedItemHash.value == null && focusRequester == FocusRequester.Default
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ImageCard.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ImageCard.kt
new file mode 100644
index 0000000..450a9a0
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ImageCard.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.border
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Text
+
+@Composable
+fun ImageCard(
+ movie: Movie,
+ aspectRatio: Float = 16f / 9,
+ customCardWidth: Dp? = null,
+ modifier: Modifier = Modifier,
+) {
+ val aspect = if (aspectRatio == 16f / 9)
+ "orientation/vod_art_16x9"
+ else
+ "orientation/vod_art_2x3"
+ val scaleMax = if (aspectRatio == 16f / 9) 1.1f else 1.025f
+ val cardWidth = customCardWidth ?: if (aspectRatio == 16f / 9) 200.dp else 172.dp
+
+ var isFocused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(12.dp)
+ val borderColor by animateColorAsState(
+ targetValue = if (isFocused) Color.White.copy(alpha = 0.8f) else Color.Transparent
+ )
+ val scale by animateFloatAsState(targetValue = if (isFocused) scaleMax else 1f)
+
+ Column(
+ modifier = Modifier
+ .width(cardWidth)
+ .scale(scale)
+ ) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .aspectRatio(aspectRatio)
+ .border(2.dp, borderColor, shape)
+ .clip(shape)
+ .onFocusChanged { isFocused = it.isFocused }
+ .focusable()
+ ) {
+ AppAsyncImage(imageUrl = getMovieImageUrl(movie, aspect = aspect))
+ }
+ androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = movie.name,
+ color = Color.White.copy(alpha = 0.8f),
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/LandscapeImageBackground.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/LandscapeImageBackground.kt
new file mode 100644
index 0000000..18a9b33
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/LandscapeImageBackground.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+
+@Composable
+fun LandscapeImageBackground(movie: Movie, aspect: String = "orientation/iconic_16x9") {
+ val navigationGradient = Brush.verticalGradient(
+ colors = listOf(pageColor, Color.Transparent),
+ startY = 0f,
+ endY = 200f
+ )
+ var height by remember {
+ mutableStateOf(0f)
+ }
+
+ val navigationGradientBottom = Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ pageColor
+ ),
+ startY = 50f,
+ endY = height,
+ )
+
+ val horizontalGradient = Brush.horizontalGradient(
+ colors = listOf(pageColor, Color.Transparent),
+ startX = 1400f,
+ endX = 900f,
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onGloballyPositioned { height = it.size.height.toFloat() }
+ ) {
+ AppAsyncImage(
+ imageUrl = getMovieImageUrl(
+ movie = movie,
+ aspect = aspect
+ ),
+ contentScale = ContentScale.FillWidth,
+ alignment = Alignment.Center,
+ modifier = Modifier.fillMaxWidth(),
+ contentDescription = null
+ )
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(navigationGradientBottom)
+ .background(navigationGradient)
+ .background(horizontalGradient)
+ )
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/MainActivity.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/MainActivity.kt
index 6306306..83c3ad1 100644
--- a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/MainActivity.kt
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/MainActivity.kt
@@ -19,13 +19,19 @@
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.foundation.text.BasicText
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
+ // Create the "data.json" file in "presentation/src/main/assets" directory and add the
+ // content from this link: go/compose-tv-presentation-app-data
+ val jsonData = assets.readAssetsFile("data.json")
+ val deserializedData = getRootDataFromJson(jsonData)
+
+ initializeData(deserializedData)
+
super.onCreate(savedInstanceState)
setContent {
- BasicText("Hi")
+ App()
}
}
}
\ No newline at end of file
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ShowsGrid.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ShowsGrid.kt
new file mode 100644
index 0000000..4dfbf0b
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/ShowsGrid.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.grid.TvGridCells
+import androidx.tv.foundation.lazy.grid.TvLazyHorizontalGrid
+import androidx.tv.material3.Text
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShowsGrid(modifier: Modifier = Modifier) {
+ var keyword by remember { mutableStateOf("") }
+ val movies by remember(keyword) {
+ mutableStateOf(allMovies.filter { movie -> movie.name.contains(keyword) })
+ }
+ Column(
+ modifier = Modifier
+ .height(520.dp)
+ .padding(top = 70.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 58.dp)
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ value = keyword,
+ onValueChange = { keyword = it },
+ placeholder = {
+ Text(text = "Search", color = Color.White)
+ },
+ modifier = modifier
+ .fillMaxWidth()
+ .align(Alignment.Center),
+ colors = TextFieldDefaults.outlinedTextFieldColors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ focusedBorderColor = Color.White,
+ unfocusedBorderColor = Color.White.copy(alpha = 0.5f)
+ )
+ )
+ }
+
+ AppSpacer(height = 20.dp)
+
+ if (movies.isEmpty()) {
+ AppSpacer(height = 20.dp)
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "No movies matched",
+ modifier = Modifier.align(Alignment.Center),
+ color = Color.White,
+ )
+ }
+ }
+
+ FocusGroup {
+ TvLazyHorizontalGrid(
+ rows = TvGridCells.Fixed(3),
+ contentPadding = PaddingValues(horizontal = 58.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .bringIntoViewIfChildrenAreFocused(),
+ ) {
+ items(movies.size) {
+ val movie = movies[it]
+ val isFirstItem = it == 0
+ val itemModifier = Modifier.restorableFocus()
+ val firstItemModifier = Modifier.initiallyFocused()
+
+ Box(modifier = Modifier.padding(end = 30.dp)) {
+ ImageCard(
+ movie,
+ customCardWidth = 150.dp,
+ modifier = if (isFirstItem) firstItemModifier else itemModifier
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Sidebar.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Sidebar.kt
new file mode 100644
index 0000000..882b27b
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/Sidebar.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material.icons.outlined.Movie
+import androidx.compose.material.icons.outlined.Tv
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+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.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun Sidebar(
+ selectedIndex: Int,
+ onIndexChange: (index: Int) -> Unit,
+) {
+ val fr = remember { FocusRequester() }
+ val drawIcon: @Composable (
+ imageVector: ImageVector,
+ index: Int,
+ modifier: Modifier
+ ) -> Unit =
+ { imageVector, index, modifier ->
+ var isFocused by remember { mutableStateOf(false) }
+ val isSelected = selectedIndex == index
+
+ IconButton(
+ onClick = { },
+ modifier = modifier
+ .onFocusChanged {
+ isFocused = it.isFocused
+ if (it.isFocused) {
+ onIndexChange(index)
+ }
+ }
+ .focusRequester(if (index == 0) fr else FocusRequester()),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor =
+ if (isSelected && isFocused)
+ Color.White
+ else
+ Color.Transparent,
+ )
+ ) {
+ Box(modifier = Modifier) {
+ Icon(
+ imageVector = imageVector,
+ tint = if (isSelected && isFocused) pageColor else Color.White,
+ contentDescription = null,
+ )
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .width(10.dp)
+ .height(3.dp)
+ .offset(y = 4.dp)
+ .align(Alignment.BottomCenter)
+ .background(Color.Red)
+ )
+ }
+ }
+ }
+ }
+
+ FocusGroup {
+ Column(
+ modifier = Modifier
+ .width(60.dp)
+ .fillMaxHeight()
+ .background(pageColor)
+ .focusable(false),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ drawIcon(
+ imageVector = Icons.Outlined.Home,
+ index = 0,
+ modifier = Modifier.initiallyFocused(),
+ )
+ drawIcon(
+ imageVector = Icons.Outlined.Movie,
+ index = 1,
+ modifier = Modifier.restorableFocus(),
+ )
+ drawIcon(
+ imageVector = Icons.Outlined.Tv,
+ index = 2,
+ modifier = Modifier.restorableFocus(),
+ )
+ }
+ }
+}
diff --git a/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/readAssetsFile.kt b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/readAssetsFile.kt
new file mode 100644
index 0000000..18708dd
--- /dev/null
+++ b/tv/integration-tests/presentation/src/main/java/androidx/tv/integration/presentation/readAssetsFile.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * 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
+ *
+ * http://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 androidx.tv.integration.presentation
+
+import android.content.res.AssetManager
+
+fun AssetManager.readAssetsFile(fileName: String): String =
+ open(fileName).bufferedReader().use { it.readText() }
\ No newline at end of file