diff --git a/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt index 1baf71c90..e2d46a134 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt @@ -132,11 +132,11 @@ fun StreamCarousel( } @Composable -private fun ClusterRow( +internal fun ClusterRow( cluster: StreamCluster, - onAppClick: (App) -> Unit, - onAppLongClick: (App) -> Unit, - onClusterScrolled: (StreamCluster) -> Unit + onAppClick: (App) -> Unit = {}, + onAppLongClick: (App) -> Unit = {}, + onClusterScrolled: (StreamCluster) -> Unit = {} ) { val rowState = rememberLazyListState() val reachedEnd by remember { diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index f36834149..2e16e1931 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -14,14 +14,11 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith 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.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 @@ -62,15 +59,21 @@ import com.aurora.extensions.share import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.store.ComposeActivity import com.aurora.store.R +import com.aurora.store.compose.composable.ClusterRow import com.aurora.store.compose.composable.ContainedLoadingIndicator import com.aurora.store.compose.composable.Error import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.ScrollHint +import com.aurora.store.compose.composable.SectionHeader +import com.aurora.store.compose.composable.ShimmerCarouselSection +import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.TopAppBar -import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.composition.collectForced import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.preview.AppPreviewProvider @@ -104,7 +107,6 @@ import com.aurora.store.util.PackageUtil import com.aurora.store.util.ShortcutManagerUtil import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.jakewharton.processphoenix.ProcessPhoenix -import kotlin.random.Random import kotlinx.coroutines.launch @Composable @@ -123,7 +125,7 @@ fun AppDetailsScreen( val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle() val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() - val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() + val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null) LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) } @@ -147,7 +149,7 @@ fun AppDetailsScreen( ScreenContentApp( app = loadedApp, featuredReviews = featuredReviews, - suggestions = suggestions, + suggestionsBundle = suggestionsBundle, isFavorite = favorite, isAnonymous = viewModel.authProvider.isAnonymous, state = currentState, @@ -155,6 +157,7 @@ fun AppDetailsScreen( dataSafetyReport = dataSafetyReport, exodusReport = exodusReport, onNavigateTo = onNavigateTo, + onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) }, onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) }, onFavorite = { viewModel.toggleFavourite(loadedApp) }, onCancelDownload = { viewModel.cancelDownload(loadedApp) }, @@ -224,7 +227,7 @@ private fun ScreenContentError(message: String? = null) { private fun ScreenContentApp( app: App, featuredReviews: List = emptyList(), - suggestions: List = emptyList(), + suggestionsBundle: StreamBundle? = StreamBundle.EMPTY, isFavorite: Boolean = false, isAnonymous: Boolean = true, state: AppState = AppState.Unavailable, @@ -232,6 +235,7 @@ private fun ScreenContentApp( dataSafetyReport: DataSafetyReport? = null, exodusReport: Report? = null, onNavigateTo: (Destination) -> Unit = {}, + onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {}, onDownload: (requestedApp: App) -> Unit = {}, onFavorite: () -> Unit = {}, onCancelDownload: () -> Unit = {}, @@ -527,6 +531,14 @@ private fun ScreenContentApp( email = app.developerEmail ) } + + if (shouldShowMenuOnMainPane) { + suggestionClusterItems( + suggestionsBundle = suggestionsBundle, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onClusterScrolled = onLoadMoreCluster + ) + } } ScrollHint( listState = listState, @@ -546,38 +558,16 @@ private fun ScreenContentApp( ) } ) { paddingValues -> - if (suggestions.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Row( - modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_suggestions), - contentDescription = null - ) - Header(title = stringResource(R.string.pref_ui_similar_apps)) - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)), - verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_medium) - ) - ) { - items(items = suggestions, key = { item -> item.id }) { app -> - LargeAppListItem( - app = app, - onClick = { onNavigateTo(Destination.AppDetails(app.packageName)) } - ) - } - } - } + val hasContent = suggestionsBundle == null || + suggestionsBundle.streamClusters.isNotEmpty() + if (hasContent) { + StreamCarousel( + modifier = Modifier.padding(paddingValues), + streamBundle = suggestionsBundle, + filterSingleAppClusters = false, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onClusterScrolled = onLoadMoreCluster + ) } } } @@ -649,6 +639,38 @@ private fun ScreenContentApp( ) } +/** + * Renders the suggestion stream as cluster rows inside the parent [LazyColumn]. + * Shows a shimmer placeholder while the bundle is still loading (`null`). + */ +private fun LazyListScope.suggestionClusterItems( + suggestionsBundle: StreamBundle?, + onAppClick: (App) -> Unit, + onClusterScrolled: (StreamCluster) -> Unit +) { + if (suggestionsBundle == null) { + item(key = "suggestions-shimmer") { ShimmerCarouselSection() } + return + } + + val clusters = suggestionsBundle.streamClusters.values.filter { + it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty() + } + + clusters.forEach { cluster -> + item(key = "cluster-header-${cluster.id}") { + SectionHeader(title = cluster.clusterTitle) + } + item(key = "cluster-row-${cluster.id}") { + ClusterRow( + cluster = cluster, + onAppClick = onAppClick, + onClusterScrolled = onClusterScrolled + ) + } + } +} + @PreviewWrapper(ThemePreviewProvider::class) @PreviewScreenSizes @Composable @@ -656,7 +678,20 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class) ScreenContentApp( app = app, isAnonymous = false, - suggestions = List(10) { app.copy(id = Random.nextInt()) } + suggestionsBundle = StreamBundle( + streamClusters = mapOf( + 1 to StreamCluster( + id = 1, + clusterTitle = "Similar apps", + clusterAppList = List(8) { app.copy(id = it) } + ), + 2 to StreamCluster( + id = 2, + clusterTitle = "More by ${app.developerName}", + clusterAppList = List(5) { app.copy(id = 100 + it) } + ) + ) + ) ) } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index 1669913ce..a02c038f0 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -16,6 +16,8 @@ import com.aurora.extensions.TAG import com.aurora.extensions.requiresGMS import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.helpers.AppDetailsHelper @@ -44,8 +46,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter @@ -76,8 +82,13 @@ class AppDetailsViewModel @Inject constructor( private val _state = MutableStateFlow(AppState.Loading) val state = _state.asStateFlow() - private val _suggestions = MutableStateFlow>(emptyList()) - val suggestions = _suggestions.asStateFlow() + private val _suggestionsBundle = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val suggestionsBundle: SharedFlow = _suggestionsBundle.asSharedFlow() + + private var suggestionsState: StreamBundle = StreamBundle() private val _featuredReviews = MutableStateFlow>(emptyList()) val featuredReviews = _featuredReviews.asStateFlow() @@ -318,14 +329,47 @@ class AppDetailsViewModel @Inject constructor( } private fun fetchSuggestions() { - // Bail out if we go no suggestions to offer - if (app.value!!.detailsStreamUrl.isNullOrBlank()) return + val streamUrl = app.value!!.detailsStreamUrl + + // Bail out if we got no suggestions to offer + if (streamUrl.isNullOrBlank()) { + _suggestionsBundle.tryEmit(StreamBundle.EMPTY) + return + } viewModelScope.launch(Dispatchers.IO) { - val streamBundle = appDetailsHelper.getDetailsStream(app.value!!.detailsStreamUrl!!) - _suggestions.value = streamBundle.streamClusters.values - .flatMap { it.clusterAppList } - .distinctBy { it.packageName } + try { + val pageBundle = appDetailsHelper.getDetailsStream(streamUrl) + val pageClusters = pageBundle.streamClusters.filterValues { + it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty() + } + suggestionsState = pageBundle.copy(streamClusters = pageClusters) + _suggestionsBundle.tryEmit(suggestionsState) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch suggestions stream", exception) + _suggestionsBundle.tryEmit(StreamBundle.EMPTY) + } + } + } + + fun loadMoreCluster(cluster: StreamCluster) { + if (!cluster.hasNext()) return + + viewModelScope.launch(Dispatchers.IO) { + try { + val nextPage = appDetailsHelper.getNextStreamCluster(cluster.clusterNextPageUrl) + val existing = suggestionsState.streamClusters[cluster.id] ?: return@launch + val mergedCluster = existing.copy( + clusterAppList = existing.clusterAppList + nextPage.clusterAppList, + clusterNextPageUrl = nextPage.clusterNextPageUrl + ) + suggestionsState = suggestionsState.copy( + streamClusters = suggestionsState.streamClusters + (cluster.id to mergedCluster) + ) + _suggestionsBundle.tryEmit(suggestionsState) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch next cluster page", exception) + } } }