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 @Composable
private fun ClusterRow( internal fun ClusterRow(
cluster: StreamCluster, cluster: StreamCluster,
onAppClick: (App) -> Unit, onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit, onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit onClusterScrolled: (StreamCluster) -> Unit = {}
) { ) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
val reachedEnd by remember { val reachedEnd by remember {

View File

@@ -14,14 +14,11 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
@@ -62,15 +59,21 @@ import com.aurora.extensions.share
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Review 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.datasafety.Report as DataSafetyReport
import com.aurora.store.ComposeActivity import com.aurora.store.ComposeActivity
import com.aurora.store.R 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.ContainedLoadingIndicator
import com.aurora.store.compose.composable.Error import com.aurora.store.compose.composable.Error
import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Header
import com.aurora.store.compose.composable.ScrollHint 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.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.Destination
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.preview.AppPreviewProvider 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.util.ShortcutManagerUtil
import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.aurora.store.viewmodel.details.AppDetailsViewModel
import com.jakewharton.processphoenix.ProcessPhoenix import com.jakewharton.processphoenix.ProcessPhoenix
import kotlin.random.Random
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -123,7 +125,7 @@ fun AppDetailsScreen(
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle() val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.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) } LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
@@ -147,7 +149,7 @@ fun AppDetailsScreen(
ScreenContentApp( ScreenContentApp(
app = loadedApp, app = loadedApp,
featuredReviews = featuredReviews, featuredReviews = featuredReviews,
suggestions = suggestions, suggestionsBundle = suggestionsBundle,
isFavorite = favorite, isFavorite = favorite,
isAnonymous = viewModel.authProvider.isAnonymous, isAnonymous = viewModel.authProvider.isAnonymous,
state = currentState, state = currentState,
@@ -155,6 +157,7 @@ fun AppDetailsScreen(
dataSafetyReport = dataSafetyReport, dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport, exodusReport = exodusReport,
onNavigateTo = onNavigateTo, onNavigateTo = onNavigateTo,
onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) },
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) }, onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
onFavorite = { viewModel.toggleFavourite(loadedApp) }, onFavorite = { viewModel.toggleFavourite(loadedApp) },
onCancelDownload = { viewModel.cancelDownload(loadedApp) }, onCancelDownload = { viewModel.cancelDownload(loadedApp) },
@@ -224,7 +227,7 @@ private fun ScreenContentError(message: String? = null) {
private fun ScreenContentApp( private fun ScreenContentApp(
app: App, app: App,
featuredReviews: List<Review> = emptyList(), featuredReviews: List<Review> = emptyList(),
suggestions: List<App> = emptyList(), suggestionsBundle: StreamBundle? = StreamBundle.EMPTY,
isFavorite: Boolean = false, isFavorite: Boolean = false,
isAnonymous: Boolean = true, isAnonymous: Boolean = true,
state: AppState = AppState.Unavailable, state: AppState = AppState.Unavailable,
@@ -232,6 +235,7 @@ private fun ScreenContentApp(
dataSafetyReport: DataSafetyReport? = null, dataSafetyReport: DataSafetyReport? = null,
exodusReport: Report? = null, exodusReport: Report? = null,
onNavigateTo: (Destination) -> Unit = {}, onNavigateTo: (Destination) -> Unit = {},
onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {},
onDownload: (requestedApp: App) -> Unit = {}, onDownload: (requestedApp: App) -> Unit = {},
onFavorite: () -> Unit = {}, onFavorite: () -> Unit = {},
onCancelDownload: () -> Unit = {}, onCancelDownload: () -> Unit = {},
@@ -527,6 +531,14 @@ private fun ScreenContentApp(
email = app.developerEmail email = app.developerEmail
) )
} }
if (shouldShowMenuOnMainPane) {
suggestionClusterItems(
suggestionsBundle = suggestionsBundle,
onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
onClusterScrolled = onLoadMoreCluster
)
}
} }
ScrollHint( ScrollHint(
listState = listState, listState = listState,
@@ -546,38 +558,16 @@ private fun ScreenContentApp(
) )
} }
) { paddingValues -> ) { paddingValues ->
if (suggestions.isNotEmpty()) { val hasContent = suggestionsBundle == null ||
Column( suggestionsBundle.streamClusters.isNotEmpty()
modifier = Modifier if (hasContent) {
.fillMaxSize() StreamCarousel(
.padding(paddingValues) modifier = Modifier.padding(paddingValues),
) { streamBundle = suggestionsBundle,
Row( filterSingleAppClusters = false,
modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)), onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) },
verticalAlignment = Alignment.CenterVertically onClusterScrolled = onLoadMoreCluster
) {
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)) }
)
}
}
}
} }
} }
} }
@@ -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) @PreviewWrapper(ThemePreviewProvider::class)
@PreviewScreenSizes @PreviewScreenSizes
@Composable @Composable
@@ -656,7 +678,20 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
ScreenContentApp( ScreenContentApp(
app = app, app = app,
isAnonymous = false, 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.extensions.requiresGMS
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Review 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.datasafety.Report as DataSafetyReport
import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.data.models.details.TestingProgramStatus
import com.aurora.gplayapi.helpers.AppDetailsHelper import com.aurora.gplayapi.helpers.AppDetailsHelper
@@ -44,8 +46,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
@@ -76,8 +82,13 @@ class AppDetailsViewModel @Inject constructor(
private val _state = MutableStateFlow<AppState>(AppState.Loading) private val _state = MutableStateFlow<AppState>(AppState.Loading)
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _suggestions = MutableStateFlow<List<App>>(emptyList()) private val _suggestionsBundle = MutableSharedFlow<StreamBundle?>(
val suggestions = _suggestions.asStateFlow() replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val suggestionsBundle: SharedFlow<StreamBundle?> = _suggestionsBundle.asSharedFlow()
private var suggestionsState: StreamBundle = StreamBundle()
private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList()) private val _featuredReviews = MutableStateFlow<List<Review>>(emptyList())
val featuredReviews = _featuredReviews.asStateFlow() val featuredReviews = _featuredReviews.asStateFlow()
@@ -318,14 +329,47 @@ class AppDetailsViewModel @Inject constructor(
} }
private fun fetchSuggestions() { private fun fetchSuggestions() {
// Bail out if we go no suggestions to offer val streamUrl = app.value!!.detailsStreamUrl
if (app.value!!.detailsStreamUrl.isNullOrBlank()) return
// Bail out if we got no suggestions to offer
if (streamUrl.isNullOrBlank()) {
_suggestionsBundle.tryEmit(StreamBundle.EMPTY)
return
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val streamBundle = appDetailsHelper.getDetailsStream(app.value!!.detailsStreamUrl!!) try {
_suggestions.value = streamBundle.streamClusters.values val pageBundle = appDetailsHelper.getDetailsStream(streamUrl)
.flatMap { it.clusterAppList } val pageClusters = pageBundle.streamClusters.filterValues {
.distinctBy { it.packageName } 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)
}
} }
} }