diff --git a/Jetcaster/designsystem/build.gradle.kts b/Jetcaster/designsystem/build.gradle.kts index 7fdf99b1fd..5b53b96537 100644 --- a/Jetcaster/designsystem/build.gradle.kts +++ b/Jetcaster/designsystem/build.gradle.kts @@ -10,7 +10,7 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() - + vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt index 0c9c17f3d3..b59efeb125 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/PodcastImage.kt @@ -20,8 +20,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,10 +31,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest +import com.example.jetcaster.designsystem.R @Composable fun PodcastImage( @@ -45,6 +46,11 @@ fun PodcastImage( contentScale: ContentScale = ContentScale.Crop, placeholderBrush: Brush = thumbnailPlaceholderDefaultBrush(), ) { + if (LocalInspectionMode.current) { + Box(modifier = modifier.background(MaterialTheme.colorScheme.primary)) + return + } + var imagePainterState by remember { mutableStateOf(AsyncImagePainter.State.Empty) } @@ -63,14 +69,15 @@ fun PodcastImage( contentAlignment = Alignment.Center ) { when (imagePainterState) { - is AsyncImagePainter.State.Loading -> { - CircularProgressIndicator( + is AsyncImagePainter.State.Loading, + is AsyncImagePainter.State.Error -> { + Image( + painter = painterResource(id = R.drawable.img_empty), + contentDescription = null, modifier = Modifier - .size(48.dp) - .align(Alignment.Center) + .fillMaxSize() ) } - else -> { Box( modifier = Modifier diff --git a/Jetcaster/designsystem/src/main/res/drawable/img_empty.xml b/Jetcaster/designsystem/src/main/res/drawable/img_empty.xml new file mode 100644 index 0000000000..46b27de1d1 --- /dev/null +++ b/Jetcaster/designsystem/src/main/res/drawable/img_empty.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jetcaster/designsystem/src/main/res/values-night/colors.xml b/Jetcaster/designsystem/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..148f321a7a --- /dev/null +++ b/Jetcaster/designsystem/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #FF1A120A + #FF42372D + diff --git a/Jetcaster/designsystem/src/main/res/values/colors.xml b/Jetcaster/designsystem/src/main/res/values/colors.xml new file mode 100644 index 0000000000..10f401c721 --- /dev/null +++ b/Jetcaster/designsystem/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FFFFF8F4 + #FFFFF8F4 + diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt index d4997baa7e..bfa3b20ba2 100644 --- a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -50,6 +50,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -129,7 +131,6 @@ import kotlinx.coroutines.launch data class HomeState( val windowSizeClass: WindowSizeClass, val featuredPodcasts: PersistentList, - val isRefreshing: Boolean, val selectedHomeCategory: HomeCategory, val homeCategories: List, val filterableCategoriesModel: FilterableCategoriesModel, @@ -230,14 +231,73 @@ private fun getExcludedVerticalBounds(posture: Posture, hingePolicy: HingePolicy } } -@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun MainScreen( windowSizeClass: WindowSizeClass, navigateToPlayer: (EpisodeInfo) -> Unit, viewModel: HomeViewModel = hiltViewModel() ) { - val viewState by viewModel.state.collectAsStateWithLifecycle() + val homeScreenUiState by viewModel.state.collectAsStateWithLifecycle() + when (val uiState = homeScreenUiState) { + is HomeScreenUiState.Loading -> HomeScreenLoading() + is HomeScreenUiState.Error -> HomeScreenError(onRetry = viewModel::refresh) + is HomeScreenUiState.Ready -> { + HomeScreenReady( + uiState = uiState, + windowSizeClass = windowSizeClass, + navigateToPlayer = navigateToPlayer, + viewModel = viewModel, + ) + } + } +} + +@Composable +private fun HomeScreenLoading(modifier: Modifier = Modifier) { + Surface(modifier.fillMaxSize()) { + Box { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } +} + +@Composable +private fun HomeScreenError(onRetry: () -> Unit, modifier: Modifier = Modifier) { + Surface(modifier = modifier) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + Text( + text = stringResource(id = R.string.an_error_has_occurred), + modifier = Modifier.padding(16.dp) + ) + Button(onClick = onRetry) { + Text(text = stringResource(id = R.string.retry_label)) + } + } + } +} + +@Preview +@Composable +fun HomeScreenErrorPreview() { + JetcasterTheme { + HomeScreenError(onRetry = {}) + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun HomeScreenReady( + uiState: HomeScreenUiState.Ready, + windowSizeClass: WindowSizeClass, + navigateToPlayer: (EpisodeInfo) -> Unit, + viewModel: HomeViewModel = hiltViewModel() +) { val navigator = rememberSupportingPaneScaffoldNavigator( scaffoldDirective = calculateScaffoldDirective(currentWindowAdaptiveInfo()) ) @@ -247,13 +307,12 @@ fun MainScreen( val homeState = HomeState( windowSizeClass = windowSizeClass, - featuredPodcasts = viewState.featuredPodcasts, - isRefreshing = viewState.refreshing, - homeCategories = viewState.homeCategories, - selectedHomeCategory = viewState.selectedHomeCategory, - filterableCategoriesModel = viewState.filterableCategoriesModel, - podcastCategoryFilterResult = viewState.podcastCategoryFilterResult, - library = viewState.library, + featuredPodcasts = uiState.featuredPodcasts, + homeCategories = uiState.homeCategories, + selectedHomeCategory = uiState.selectedHomeCategory, + filterableCategoriesModel = uiState.filterableCategoriesModel, + podcastCategoryFilterResult = uiState.podcastCategoryFilterResult, + library = uiState.library, onHomeCategorySelected = viewModel::onHomeCategorySelected, onCategorySelected = viewModel::onCategorySelected, onPodcastUnfollowed = viewModel::onPodcastUnfollowed, @@ -403,7 +462,6 @@ private fun HomeScreen( showGrid = showGrid, showHomeCategoryTabs = homeState.showHomeCategoryTabs, featuredPodcasts = homeState.featuredPodcasts, - isRefreshing = homeState.isRefreshing, selectedHomeCategory = homeState.selectedHomeCategory, homeCategories = homeState.homeCategories, filterableCategoriesModel = homeState.filterableCategoriesModel, @@ -433,7 +491,6 @@ private fun HomeContent( showGrid: Boolean, showHomeCategoryTabs: Boolean, featuredPodcasts: PersistentList, - isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, @@ -467,7 +524,6 @@ private fun HomeContent( pagerState = pagerState, showHomeCategoryTabs = showHomeCategoryTabs, featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, selectedHomeCategory = selectedHomeCategory, homeCategories = homeCategories, filterableCategoriesModel = filterableCategoriesModel, @@ -487,7 +543,6 @@ private fun HomeContent( pagerState = pagerState, showHomeCategoryTabs = showHomeCategoryTabs, featuredPodcasts = featuredPodcasts, - isRefreshing = isRefreshing, selectedHomeCategory = selectedHomeCategory, homeCategories = homeCategories, filterableCategoriesModel = filterableCategoriesModel, @@ -511,7 +566,6 @@ private fun HomeContentColumn( showHomeCategoryTabs: Boolean, pagerState: PagerState, featuredPodcasts: PersistentList, - isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, @@ -542,10 +596,6 @@ private fun HomeContentColumn( } } - if (isRefreshing) { - // TODO show a progress indicator or similar - } - if (showHomeCategoryTabs) { item { HomeCategoryTabs( @@ -586,7 +636,6 @@ private fun HomeContentGrid( showHomeCategoryTabs: Boolean, pagerState: PagerState, featuredPodcasts: PersistentList, - isRefreshing: Boolean, selectedHomeCategory: HomeCategory, homeCategories: List, filterableCategoriesModel: FilterableCategoriesModel, @@ -618,10 +667,6 @@ private fun HomeContentGrid( } } - if (isRefreshing) { - // TODO show a progress indicator or similar - } - if (showHomeCategoryTabs) { fullWidthItem { Row { @@ -868,7 +913,6 @@ private fun PreviewHomeContent() { val homeState = HomeState( windowSizeClass = CompactWindowSizeClass, featuredPodcasts = PreviewPodcasts.toPersistentList(), - isRefreshing = false, homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, filterableCategoriesModel = FilterableCategoriesModel( @@ -905,7 +949,6 @@ private fun PreviewHomeContentExpanded() { val homeState = HomeState( windowSizeClass = CompactWindowSizeClass, featuredPodcasts = PreviewPodcasts.toPersistentList(), - isRefreshing = false, homeCategories = HomeCategory.entries, selectedHomeCategory = HomeCategory.Discover, filterableCategoriesModel = FilterableCategoriesModel( diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index da80232a9b..5d1775519c 100644 --- a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.ui.home +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast @@ -46,6 +47,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) + @HiltViewModel class HomeViewModel @Inject constructor( private val podcastsRepository: PodcastsRepository, @@ -64,11 +66,11 @@ class HomeViewModel @Inject constructor( // Holds our currently selected category private val _selectedCategory = MutableStateFlow(null) // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(HomeViewState()) + private val _state = MutableStateFlow(HomeScreenUiState.Loading) // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) - val state: StateFlow + val state: StateFlow get() = _state init { @@ -100,6 +102,11 @@ class HomeViewModel @Inject constructor( podcastCategoryFilterResult, libraryEpisodes -> + if (refreshing) { + Log.d("Jetcaster", "refreshing: $refreshing, podcasts $podcasts") + return@combine HomeScreenUiState.Loading + } + _selectedCategory.value = filterableCategories.selectedCategory // Override selected home category to show 'DISCOVER' if there are no @@ -107,19 +114,16 @@ class HomeViewModel @Inject constructor( selectedHomeCategory.value = if (podcasts.isEmpty()) HomeCategory.Discover else homeCategory - HomeViewState( + HomeScreenUiState.Ready( homeCategories = homeCategories, selectedHomeCategory = homeCategory, featuredPodcasts = podcasts.map { it.asExternalModel() }.toPersistentList(), - refreshing = refreshing, filterableCategoriesModel = filterableCategories, podcastCategoryFilterResult = podcastCategoryFilterResult, - library = libraryEpisodes.asLibrary(), - errorMessage = null, /* TODO */ + library = libraryEpisodes.asLibrary() ) }.catch { throwable -> - // TODO: emit a UI error here. For now we'll just rethrow - throw throwable + _state.value = HomeScreenUiState.Error(throwable.message) }.collect { _state.value = it } @@ -128,7 +132,7 @@ class HomeViewModel @Inject constructor( refresh(force = false) } - private fun refresh(force: Boolean) { + fun refresh(force: Boolean = true) { viewModelScope.launch { runCatching { refreshing.value = true @@ -179,13 +183,21 @@ enum class HomeCategory { Library, Discover } -data class HomeViewState( - val featuredPodcasts: PersistentList = persistentListOf(), - val refreshing: Boolean = false, - val selectedHomeCategory: HomeCategory = HomeCategory.Discover, - val homeCategories: List = emptyList(), - val filterableCategoriesModel: FilterableCategoriesModel = FilterableCategoriesModel(), - val podcastCategoryFilterResult: PodcastCategoryFilterResult = PodcastCategoryFilterResult(), - val library: LibraryInfo = LibraryInfo(), - val errorMessage: String? = null -) +sealed interface HomeScreenUiState { + data object Loading : HomeScreenUiState + + data class Error( + val errorMessage: String? = null + ) : HomeScreenUiState + + data class Ready( + val featuredPodcasts: PersistentList = persistentListOf(), + val selectedHomeCategory: HomeCategory = HomeCategory.Discover, + val homeCategories: List = emptyList(), + val filterableCategoriesModel: FilterableCategoriesModel = + FilterableCategoriesModel(), + val podcastCategoryFilterResult: PodcastCategoryFilterResult = + PodcastCategoryFilterResult(), + val library: LibraryInfo = LibraryInfo(), + ) : HomeScreenUiState +} diff --git a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt index a1db91845a..f06c5ca692 100644 --- a/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt +++ b/Jetcaster/mobile/src/main/java/com/example/jetcaster/ui/shared/EpisodeListItem.kt @@ -18,7 +18,6 @@ package com.example.jetcaster.ui.shared import android.content.res.Configuration import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -43,8 +42,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role @@ -52,12 +49,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest import com.example.jetcaster.R import com.example.jetcaster.core.model.EpisodeInfo import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.model.PodcastInfo +import com.example.jetcaster.designsystem.component.PodcastImage import com.example.jetcaster.ui.home.PreviewEpisodes import com.example.jetcaster.ui.home.PreviewPodcasts import com.example.jetcaster.ui.theme.JetcasterTheme @@ -229,19 +225,11 @@ private fun EpisodeListItemImage( podcast: PodcastInfo, modifier: Modifier = Modifier ) { - if (LocalInspectionMode.current) { - Box(modifier = modifier.background(MaterialTheme.colorScheme.primary)) - } else { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(podcast.imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - ) - } + PodcastImage( + podcastImageUrl = podcast.imageUrl, + contentDescription = null, + modifier = modifier, + ) } @Preview( diff --git a/Jetcaster/mobile/src/main/res/values/strings.xml b/Jetcaster/mobile/src/main/res/values/strings.xml index d21cc705a0..078f542b1f 100644 --- a/Jetcaster/mobile/src/main/res/values/strings.xml +++ b/Jetcaster/mobile/src/main/res/values/strings.xml @@ -62,5 +62,6 @@ Subscribed see more Search for a podcast + An error has occurred.