From 21377100123c69410fe15fd7a5ff5bba61f29d28 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Tue, 19 May 2026 12:09:51 +0530 Subject: [PATCH] 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. --- .../store/compose/composable/CategoryItem.kt | 78 +++++++ .../compose/composable/StreamCarousel.kt | 187 ++++++++++++++++ .../compose/composable/app/AnimatedAppIcon.kt | 4 +- .../compose/composable/app/AppListItem.kt | 57 +++-- .../compose/composable/app/AppUpdateItem.kt | 209 ++++++++++++++++++ .../composable/app/LargeAppListItem.kt | 17 +- 6 files changed, 521 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt diff --git a/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt new file mode 100644 index 000000000..d891f33d0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt @@ -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 = "" + ) + ) +} 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 new file mode 100644 index 000000000..1baf71c90 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt @@ -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) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt index 860988813..f75de3896 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt @@ -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 = "", diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt index fed710407..73f4057d9 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt @@ -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 ) } } diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt new file mode 100644 index 000000000..d1dfd06c5 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt @@ -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
• 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) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt index fbb949c8d..bc78fdc63 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt @@ -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,