diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index c967939408d..aee1437b27a 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -252,6 +252,7 @@ Recommendations Because you liked Podcast Keep Listening + Browse categories Starred Episodes Log in to Pocket Casts Scan the QR code or go to %1$s diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt new file mode 100644 index 00000000000..da7086259e8 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt @@ -0,0 +1,93 @@ +package au.com.shiftyjelly.pocketcasts.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography +import coil3.compose.AsyncImage + +@Composable +fun TvCategoryTile( + category: DiscoverCategory, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary + + TvTile( + onClick = onClick, + colors = CardDefaults.colors( + containerColor = MaterialTheme.tvColors.backgroundOverlay, + focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay, + ), + interactionSource = interactionSource, + modifier = modifier + .width(280.dp) + .height(128.dp), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AsyncImage( + model = category.icon, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = Modifier.size(28.dp), + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = category.name, + style = MaterialTheme.tvTypography.body, + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvCategoryTilePreview() { + TvTheme { + Column( + modifier = Modifier + .background(MaterialTheme.tvColors.backgroundSunken) + .padding(48.dp), + ) { + TvCategoryTile( + category = DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""), + onClick = {}, + ) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt index 9dca3732b59..8d564e1776c 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt @@ -32,6 +32,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvTopBarVisibility import au.com.shiftyjelly.pocketcasts.nowplaying.TvNowPlayingScreen import au.com.shiftyjelly.pocketcasts.playlists.TvPlaylistsScreen import au.com.shiftyjelly.pocketcasts.podcasts.TvYourPodcastsScreen +import au.com.shiftyjelly.pocketcasts.search.TvSearchScreen import au.com.shiftyjelly.pocketcasts.theme.TvScreenBackgroundBrush import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight @@ -105,8 +106,8 @@ fun TvScaffold( onConsumeOpenRequest = { isNowPlayingOpenRequested = false }, ) - else -> Box(modifier = belowTopBar) { - TvTabPlaceholder(tab = tab) + is TvTab.Search -> Box(modifier = belowTopBar) { + TvSearchScreen() } } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt new file mode 100644 index 00000000000..b304e0bb7ca --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt @@ -0,0 +1,208 @@ +package au.com.shiftyjelly.pocketcasts.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.FocusDirection +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.RectangleShape +import androidx.compose.ui.graphics.SolidColor +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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import au.com.shiftyjelly.pocketcasts.component.TvTile +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +@Composable +internal fun TvSearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var editing by remember { mutableStateOf(false) } + var restoreRestFocus by remember { mutableStateOf(false) } + val fieldFocusRequester = remember { FocusRequester() } + val restFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(editing) { + if (editing) { + runCatching { fieldFocusRequester.requestFocus() } + keyboardController?.show() + } else { + keyboardController?.hide() + if (restoreRestFocus) { + runCatching { restFocusRequester.requestFocus() } + restoreRestFocus = false + } + } + } + + if (editing) { + var hasFocused by remember { mutableStateOf(false) } + + BackHandler(enabled = true) { + editing = false + restoreRestFocus = true + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.tvTypography.title3.copy(color = MaterialTheme.tvColors.textPrimary), + cursorBrush = SolidColor(MaterialTheme.tvColors.textPrimary), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + editing = false + restoreRestFocus = true + }, + ), + modifier = modifier + .focusRequester(fieldFocusRequester) + .onFocusChanged { + if (it.isFocused) { + hasFocused = true + } else if (hasFocused) { + editing = false + } + } + .onPreviewKeyEvent { event -> + when { + event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown -> { + focusManager.moveFocus(FocusDirection.Down) + true + } + + event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp -> { + focusManager.moveFocus(FocusDirection.Up) + true + } + + else -> false + } + }, + decorationBox = { innerTextField -> + TvSearchFieldContent( + query = query, + contentColor = MaterialTheme.tvColors.textPrimary, + innerTextField = innerTextField, + ) + }, + ) + } else { + TvTile( + onClick = { editing = true }, + scale = CardDefaults.scale(focusedScale = 1f), + shape = CardDefaults.shape(shape = RectangleShape), + colors = CardDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.tvColors.textSecondary, + focusedContainerColor = Color.Transparent, + focusedContentColor = MaterialTheme.tvColors.textPrimary, + pressedContainerColor = Color.Transparent, + ), + modifier = modifier.focusRequester(restFocusRequester), + ) { + TvSearchFieldContent(query = query, contentColor = LocalContentColor.current) + } + } +} + +@Composable +private fun TvSearchFieldContent( + query: String, + contentColor: Color, + modifier: Modifier = Modifier, + innerTextField: (@Composable () -> Unit)? = null, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(IR.drawable.ic_search), + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(32.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + text = stringResource(LR.string.search), + style = MaterialTheme.tvTypography.title3, + color = MaterialTheme.tvColors.textSecondary, + ) + } + if (innerTextField != null) { + innerTextField() + } else if (query.isNotEmpty()) { + Text( + text = query, + style = MaterialTheme.tvTypography.title3, + color = MaterialTheme.tvColors.textPrimary, + ) + } + } + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSearchFieldPreview() { + TvTheme { + Box( + modifier = Modifier + .background(MaterialTheme.tvColors.backgroundSunken) + .padding(48.dp), + ) { + TvSearchField(query = "", onQueryChange = {}) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt new file mode 100644 index 00000000000..d3c725be6f2 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -0,0 +1,109 @@ +package au.com.shiftyjelly.pocketcasts.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.MaterialTheme +import au.com.shiftyjelly.pocketcasts.component.TvCategoryTile +import au.com.shiftyjelly.pocketcasts.component.TvRow +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +@Composable +fun TvSearchScreen( + modifier: Modifier = Modifier, + viewModel: TvSearchViewModel = hiltViewModel(), +) { + var query by rememberSaveable { mutableStateOf("") } + val categories by viewModel.categories.collectAsStateWithLifecycle() + + TvSearchContent( + query = query, + categories = categories, + onQueryChange = { query = it }, + modifier = modifier, + ) +} + +@Composable +private fun TvSearchContent( + query: String, + categories: List, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Column(modifier = Modifier.padding(horizontal = 48.dp)) { + Spacer(modifier = Modifier.height(40.dp)) + TvSearchField( + query = query, + onQueryChange = onQueryChange, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + + if (categories.isNotEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + .height(1.dp) + .background(MaterialTheme.tvColors.overlayBorder), + ) + Spacer(modifier = Modifier.height(24.dp)) + TvRow( + title = stringResource(LR.string.tv_search_browse_categories), + items = categories, + contentPadding = PaddingValues(horizontal = 48.dp), + key = { it.id }, + ) { category -> + TvCategoryTile(category = category, onClick = {}) + } + Spacer(modifier = Modifier.height(40.dp)) + } + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSearchScreenPreview() { + TvTheme { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.tvColors.backgroundSunken)) { + TvSearchContent( + query = "", + categories = listOf( + DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""), + DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""), + DiscoverCategory(id = 3, name = "Fiction", icon = "", source = ""), + ), + onQueryChange = {}, + ) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt new file mode 100644 index 00000000000..2c0dea965c9 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -0,0 +1,41 @@ +package au.com.shiftyjelly.pocketcasts.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +@HiltViewModel +class TvSearchViewModel @Inject constructor( + private val listRepository: ListRepository, +) : ViewModel() { + + private val _categories = MutableStateFlow>(emptyList()) + val categories: StateFlow> = _categories.asStateFlow() + + init { + viewModelScope.launch { + _categories.value = try { + listRepository.getCategoriesList(CATEGORIES_URL) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV browse categories") + emptyList() + } + } + } + + companion object { + private const val CATEGORIES_URL = "${Settings.SERVER_STATIC_URL}/discover/json/categories_v2.json" + } +} diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt new file mode 100644 index 00000000000..3c5c9ff0856 --- /dev/null +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -0,0 +1,45 @@ +package au.com.shiftyjelly.pocketcasts.search + +import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory +import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class TvSearchViewModelTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val listRepository = mock() + + @Test + fun `exposes the loaded browse categories`() = runTest { + whenever(listRepository.getCategoriesList(any())).thenReturn( + listOf(category(1, "Comedy"), category(2, "True Crime")), + ) + + val viewModel = TvSearchViewModel(listRepository) + + assertEquals(listOf("Comedy", "True Crime"), viewModel.categories.value.map { it.name }) + } + + @Test + fun `categories are empty when loading fails`() = runTest { + whenever(listRepository.getCategoriesList(any())).thenThrow(RuntimeException("Network error")) + + val viewModel = TvSearchViewModel(listRepository) + + assertTrue(viewModel.categories.value.isEmpty()) + } + + private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "") +}