compose: add stream and app item composables

StreamCarousel for cluster-based home/category lists, CategoryItem grid
tile, and AppUpdateItem for the updates list. AnimatedAppIcon plus the
existing AppListItem and LargeAppListItem are adjusted to fit the new
call sites.
This commit is contained in:
Rahul Patel
2026-05-19 12:09:51 +05:30
parent d12cd77e17
commit 2137710012
6 changed files with 521 additions and 31 deletions

View File

@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.Category
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
@Composable
fun CategoryItem(modifier: Modifier = Modifier, category: Category, onClick: () -> Unit = {}) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_large))
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(category.imageUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_category))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))
)
Text(
text = category.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun CategoryItemPreview() {
CategoryItem(
category = Category(
title = "Entertainment",
imageUrl = ""
)
)
}

View File

@@ -0,0 +1,187 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.R
import com.aurora.store.compose.composable.app.AppListItem
import com.aurora.store.compose.composable.app.LargeAppListItem
import com.aurora.store.compose.preview.ThemePreviewProvider
import kotlinx.coroutines.flow.distinctUntilChanged
private const val LOAD_MORE_THRESHOLD = 2
@Composable
fun StreamCarousel(
modifier: Modifier = Modifier,
streamBundle: StreamBundle?,
filterSingleAppClusters: Boolean = true,
lazyListState: LazyListState = rememberLazyListState(),
onHeaderClick: (StreamCluster) -> Unit = {},
onAppClick: (App) -> Unit = {},
onAppLongClick: (App) -> Unit = {},
onClusterScrolled: (StreamCluster) -> Unit = {},
onScrolledToEnd: () -> Unit = {}
) {
val bundleLoaded = streamBundle != null
LaunchedEffect(lazyListState, bundleLoaded) {
snapshotFlow {
val last = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
val total = lazyListState.layoutInfo.totalItemsCount
last >= total - LOAD_MORE_THRESHOLD
}.distinctUntilChanged().collect { reachedEnd ->
if (reachedEnd && bundleLoaded) onScrolledToEnd()
}
}
if (streamBundle == null) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
items(5) { ShimmerCarouselSection() }
}
return
}
val clusters = streamBundle.streamClusters.values
.filter { cluster ->
cluster.clusterAppList.isNotEmpty() &&
cluster.clusterTitle.isNotBlank() &&
(!filterSingleAppClusters || cluster.clusterAppList.size > 1)
}
if (clusters.isEmpty()) {
EmptyState(
modifier = modifier,
icon = R.drawable.ic_apps,
message = R.string.no_apps_available
)
return
}
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(
if (clusters.size == 1) {
R.dimen.margin_medium
} else {
R.dimen.margin_xxsmall
}
)
)
) {
if (clusters.size == 1) {
val apps = clusters.first().clusterAppList
items(count = apps.size, key = { apps[it].id }) { index ->
LargeAppListItem(
app = apps[index],
onClick = { onAppClick(apps[index]) }
)
}
} else {
clusters.forEach { cluster ->
item(key = "header_${cluster.id}") {
SectionHeader(
title = cluster.clusterTitle,
browseUrl = cluster.clusterBrowseUrl,
onClick = { onHeaderClick(cluster) }
)
}
item(key = "row_${cluster.id}") {
ClusterRow(
cluster = cluster,
onAppClick = onAppClick,
onAppLongClick = onAppLongClick,
onClusterScrolled = onClusterScrolled
)
}
}
}
if (streamBundle.hasNext()) {
item(key = "shimmer_footer") { ShimmerCarouselSection() }
}
}
}
@Composable
private fun ClusterRow(
cluster: StreamCluster,
onAppClick: (App) -> Unit,
onAppLongClick: (App) -> Unit,
onClusterScrolled: (StreamCluster) -> Unit
) {
val rowState = rememberLazyListState()
val reachedEnd by remember {
derivedStateOf {
val last = rowState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
val total = rowState.layoutInfo.totalItemsCount
last >= total - LOAD_MORE_THRESHOLD
}
}
LaunchedEffect(reachedEnd) {
if (reachedEnd && cluster.hasNext()) onClusterScrolled(cluster)
}
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_small)),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small))
) {
itemsIndexed(
items = cluster.clusterAppList,
key = { _, app -> app.packageName }
) { _, app ->
AppListItem(
app = app,
onClick = { onAppClick(app) },
onLongClick = { onAppLongClick(app) }
)
}
if (cluster.hasNext()) {
item(key = "shimmer_${cluster.id}") { ShimmerAppListItem() }
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun StreamCarouselLoadingPreview() {
StreamCarousel(streamBundle = null)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun StreamCarouselEmptyPreview() {
StreamCarousel(streamBundle = StreamBundle.EMPTY)
}

View File

@@ -111,7 +111,9 @@ private fun AnimatedAppIconPreview(@PreviewParameter(AppPreviewProvider::class)
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun AnimatedAppIconPreview(@PreviewParameter(ProgressProvider::class) progress: Float) {
private fun AnimatedAppIconInProgressPreview(
@PreviewParameter(ProgressProvider::class) progress: Float
) {
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_large)),
iconUrl = "",

View File

@@ -5,21 +5,25 @@
package com.aurora.store.compose.composable.app
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -37,42 +41,49 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
* @param modifier The modifier to be applied to the composable
* @param app [App] to display
* @param onClick Callback when the composable is clicked
* @param onLongClick Callback when the composable is long-clicked
* @see LargeAppListItem
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppListItem(modifier: Modifier = Modifier, app: App, onClick: () -> Unit = {}) {
val context = LocalContext.current
fun AppListItem(
modifier: Modifier = Modifier,
app: App,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
Column(
modifier = modifier
.width(dimensionResource(R.dimen.icon_size_cluster))
.clickable(onClick = onClick)
.padding(dimensionResource(R.dimen.padding_xsmall))
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(all = dimensionResource(R.dimen.padding_xxsmall)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_xsmall),
Alignment.CenterVertically
)
) {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))),
model = ImageRequest.Builder(LocalContext.current)
.data(app.iconArtwork.url)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
contentScale = ContentScale.Crop
)
Text(
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_cluster))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
Text(
.fillMaxWidth(),
text = app.displayName,
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = if (app.size > 0) {
Formatter.formatShortFileSize(context, app.size)
} else {
app.downloadString
},
style = MaterialTheme.typography.bodySmall
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start
)
}
}

View File

@@ -0,0 +1,209 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable.app
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.aurora.store.R
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.update.Update
import com.aurora.store.util.CommonUtil
@Composable
fun AppUpdateItem(
modifier: Modifier = Modifier,
update: Update,
download: Download? = null,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onUpdate: () -> Unit = {},
onCancel: () -> Unit = {}
) {
val inProgress = download != null && download.status !in DownloadStatus.finished
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
download.progress.toFloat()
} else {
0f
}
var changelogExpanded by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) {
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = update.iconURL,
inProgress = inProgress,
progress = progress
)
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_small)))
Column(modifier = Modifier.weight(1f)) {
Text(
text = update.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = update.developerName,
style = MaterialTheme.typography.bodySmall
)
Text(
text = "${CommonUtil.addSiPrefix(update.size)}${update.updatedOn}",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "${update.versionName} (${update.versionCode})",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(Modifier.width(dimensionResource(R.dimen.margin_small)))
if (inProgress) {
OutlinedButton(onClick = onCancel) {
Text(stringResource(R.string.action_cancel))
}
} else {
Button(onClick = onUpdate) {
Text(stringResource(R.string.action_update))
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { changelogExpanded = !changelogExpanded }
.padding(horizontal = dimensionResource(R.dimen.padding_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.weight(1f))
IconButton(onClick = { changelogExpanded = !changelogExpanded }) {
Icon(
painter = painterResource(
if (changelogExpanded) R.drawable.ic_arrow_up else R.drawable.ic_arrow_down
),
contentDescription = stringResource(R.string.expand)
)
}
}
AnimatedVisibility(visible = changelogExpanded) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
),
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small))
) {
Text(
text = if (update.changelog.isNotEmpty()) {
AnnotatedString.fromHtml(update.changelog)
} else {
AnnotatedString(stringResource(R.string.details_changelog_unavailable))
},
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
)
}
}
}
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun AppUpdateItemPreview() {
val update = Update(
packageName = "com.example.app",
versionCode = 100,
versionName = "1.0.0",
displayName = "Example App",
iconURL = "",
changelog = "• Bug fixes<br>• Performance improvements",
id = 1,
developerName = "Developer",
size = 5_000_000,
updatedOn = "May 2025",
hasValidCert = true,
offerType = 1,
fileList = emptyList(),
sharedLibs = emptyList()
)
AppUpdateItem(update = update)
}
@PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true)
@Composable
private fun AppUpdateItemDownloadingPreview() {
val update = Update(
packageName = "com.example.app",
versionCode = 100,
versionName = "1.0.0",
displayName = "Example App",
iconURL = "",
changelog = "",
id = 1,
developerName = "Developer",
size = 5_000_000,
updatedOn = "May 2025",
hasValidCert = true,
offerType = 1,
fileList = emptyList(),
sharedLibs = emptyList()
)
val download = Download.fromUpdate(update).copy(
status = DownloadStatus.DOWNLOADING,
progress = 45
)
AppUpdateItem(update = update, download = download)
}

View File

@@ -71,23 +71,26 @@ fun LargeAppListItem(modifier: Modifier = Modifier, app: App, onClick: () -> Uni
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
horizontal = dimensionResource(R.dimen.padding_small),
vertical = dimensionResource(R.dimen.padding_xsmall)
)
) {
AsyncImage(
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))),
model = ImageRequest.Builder(LocalContext.current)
.data(app.iconArtwork.url)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.margin_small))
) {
Text(
text = app.displayName,