appdetails: add back app suggestions

This commit is contained in:
Rahul Patel
2026-05-19 03:04:10 +05:30
parent 59d297386c
commit f5ef5e71e9
3 changed files with 133 additions and 54 deletions

View File

@@ -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 {

View File

@@ -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<Review> = emptyList(),
suggestions: List<App> = 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
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
)
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)) }
)
}
}
}
}
}
}
@@ -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) }
)
)
)
)
}

View File

@@ -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>(AppState.Loading)
val state = _state.asStateFlow()
private val _suggestions = MutableStateFlow<List<App>>(emptyList())
val suggestions = _suggestions.asStateFlow()
private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private var suggestionsState: StreamBundle = StreamBundle()
private val _featuredReviews = MutableStateFlow<List<Review>>(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)
}
}
}