From d12cd77e179e0eb49604fca3f9419d52bb0a5973 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Tue, 19 May 2026 12:09:33 +0530 Subject: [PATCH] compose: add shared composable foundations EmptyState, SectionHeader, Shimmer placeholders; TopAppBar and ScrollHint refinements to support the migrated screens. --- .../store/compose/composable/EmptyState.kt | 77 +++++ .../store/compose/composable/ScrollHint.kt | 5 +- .../store/compose/composable/SectionHeader.kt | 116 ++++++++ .../store/compose/composable/Shimmer.kt | 274 ++++++++++++++++++ .../store/compose/composable/TopAppBar.kt | 18 +- 5 files changed, 481 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/composable/EmptyState.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt diff --git a/app/src/main/java/com/aurora/store/compose/composable/EmptyState.kt b/app/src/main/java/com/aurora/store/compose/composable/EmptyState.kt new file mode 100644 index 000000000..8e973ccbd --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/EmptyState.kt @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +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.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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 + +@Composable +fun EmptyState( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + @StringRes message: Int, + @StringRes actionLabel: Int? = null, + onAction: () -> Unit = {} +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_small), + Alignment.CenterVertically + ) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_medium)), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = stringResource(message), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onPrimary + ) + + if (actionLabel != null) { + FilledTonalButton(onClick = onAction) { + Text(stringResource(actionLabel)) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun EmptyStatePreview() { + EmptyState( + icon = R.drawable.ic_updates, + message = R.string.details_no_updates, + actionLabel = R.string.check_updates + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt index 3597f508d..f73d9a191 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt @@ -36,8 +36,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.aurora.store.R @@ -48,7 +48,6 @@ import kotlinx.coroutines.launch fun ScrollHint( listState: LazyListState, modifier: Modifier = Modifier, - bottomPadding: Dp = 72.dp, // keep above bottom bar autoHideOnScroll: Boolean = true, enableBounce: Boolean = true, onClickScrollOffset: Float = 300f @@ -88,7 +87,7 @@ fun ScrollHint( visible = visible, modifier = modifier .fillMaxWidth() - .padding(bottom = bottomPadding), + .padding(bottom = dimensionResource(R.dimen.scroll_hint_padding)), enter = fadeIn() + slideInVertically { it / 2 }, exit = fadeOut() ) { diff --git a/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt new file mode 100644 index 000000000..960aaf6d8 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt @@ -0,0 +1,116 @@ +/* + * 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.text.font.FontWeight +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 + +/** + * Section header row with optional right-chevron for navigation (replaces HeaderView). + * When [browseUrl] is non-null and non-blank the row is clickable and shows a chevron. + */ +@Composable +fun SectionHeader( + modifier: Modifier = Modifier, + title: String, + browseUrl: String? = null, + onClick: () -> Unit = {} +) { + val hasLink = !browseUrl.isNullOrBlank() + Row( + modifier = modifier + .fillMaxWidth() + .then(if (hasLink) Modifier.clickable(onClick = onClick) else Modifier) + .padding(all = dimensionResource(R.dimen.padding_medium)), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (hasLink) { + Spacer(Modifier.width(dimensionResource(R.dimen.margin_small))) + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_default)), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +/** + * Section header row with a right-aligned action button (replaces UpdateHeaderView). + */ +@Composable +fun SectionHeaderWithAction( + modifier: Modifier = Modifier, + title: String, + action: String, + onAction: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.padding_small), + top = dimensionResource(R.dimen.padding_xxsmall), + bottom = dimensionResource(R.dimen.padding_xxsmall) + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onAction) { + Text(action) + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SectionHeaderPreview() { + SectionHeader(title = "Top Charts", browseUrl = "https://example.com") +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SectionHeaderWithActionPreview() { + SectionHeaderWithAction(title = "3 updates available", action = "Update all") +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt b/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt new file mode 100644 index 000000000..5e663fa0f --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +@Composable +fun shimmerBrush(): Brush { + val base = MaterialTheme.colorScheme.onSurface + val colors = listOf( + base.copy(alpha = 0.08f), + base.copy(alpha = 0.20f), + base.copy(alpha = 0.08f) + ) + val transition = rememberInfiniteTransition(label = "shimmer") + val offset by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_offset" + ) + val x = offset * 1200f + return Brush.linearGradient( + colors = colors, + start = Offset(x - 400f, 0f), + end = Offset(x, 400f) + ) +} + +@Composable +internal fun ShimmerCategoryRow() { + val brush = shimmerBrush() + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.padding_small) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_large)) + ) { + Box( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_category)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + + Box( + modifier = Modifier + .weight(1f) + .height(24.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + } +} + +@Composable +internal fun ShimmerAppRow() { + val brush = shimmerBrush() + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_small), + vertical = dimensionResource(R.dimen.padding_xsmall) + ), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + Column( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) + ) { + repeat(3) { i -> + Box( + modifier = Modifier + .fillMaxWidth(if (i == 0) 0.75f else 0.5f) + .height(14.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + if (i < 2) Spacer(Modifier.height(dimensionResource(R.dimen.padding_xxsmall))) + } + } + } +} + +@Composable +internal fun ShimmerUpdateItem() { + val brush = shimmerBrush() + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_small), + vertical = dimensionResource(R.dimen.padding_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)) + ) { + Box( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + + Column(modifier = Modifier.weight(1f)) { + repeat(3) { i -> + Box( + modifier = Modifier + .fillMaxWidth(if (i == 0) 0.7f else 0.5f) + .height(14.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + if (i < 2) Spacer(Modifier.height(dimensionResource(R.dimen.padding_xxsmall))) + } + } + + Box( + modifier = Modifier + .width(80.dp) + .height(36.dp) + .clip(RoundedCornerShape(50)) + .background(brush) + ) + } +} + +@Composable +internal fun ShimmerCarouselSection() { + val brush = shimmerBrush() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = dimensionResource(R.dimen.padding_small)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.42f) + .height(16.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)) + ) { + repeat(5) { + Column( + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_xsmall) + ) + ) { + Box( + modifier = Modifier + .size(dimensionResource(R.dimen.icon_size_cluster)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .background(brush) + ) + Box( + modifier = Modifier + .width(dimensionResource(R.dimen.icon_size_cluster) * 0.75f) + .height(11.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + Box( + modifier = Modifier + .width(dimensionResource(R.dimen.icon_size_cluster) * 0.5f) + .height(11.dp) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(brush) + ) + } + } + } + } +} + +@Composable +internal fun ShimmerAppListItem() { + val brush = shimmerBrush() + Box( + modifier = Modifier + .padding(dimensionResource(R.dimen.padding_xsmall)) + .size(dimensionResource(R.dimen.icon_size_cluster)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .background(brush) + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerCategoryRowPreview() { + ShimmerCategoryRow() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerAppRowPreview() { + ShimmerAppRow() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerUpdateItemPreview() { + ShimmerUpdateItem() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerCarouselSectionPreview() { + ShimmerCarouselSection() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerAppListItemPreview() { + ShimmerAppListItem() +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt b/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt index c9043064f..3f9ab4713 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt @@ -5,11 +5,15 @@ package com.aurora.store.compose.composable +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -25,7 +29,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider * @param modifier The modifier to be applied to the composable * @param title Title of the screen * @param navigationIcon Icon for the navigation button - * @param onNavigateUp Action when user clicks the navigation icon + * @param showNavigationIcon Whether to show the navigation (back) icon button * @param actions Actions to display on the top app bar (for e.g. menu) */ @Composable @@ -33,15 +37,17 @@ fun TopAppBar( modifier: Modifier = Modifier, title: String? = null, navigationIcon: Painter = painterResource(R.drawable.ic_arrow_back), - onNavigateUp: (() -> Unit)? = null, + showNavigationIcon: Boolean = true, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, actions: @Composable (RowScope.() -> Unit) = {} ) { + val activity = LocalActivity.current as? ComponentActivity TopAppBar( modifier = modifier, title = { if (title != null) Text(text = title) }, navigationIcon = { - if (onNavigateUp != null) { - IconButton(onClick = onNavigateUp) { + if (showNavigationIcon) { + IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) { Icon( painter = navigationIcon, contentDescription = stringResource(R.string.action_back) @@ -49,6 +55,7 @@ fun TopAppBar( } } }, + windowInsets = windowInsets, actions = actions ) } @@ -58,7 +65,6 @@ fun TopAppBar( @Composable private fun TopAppBarPreview() { TopAppBar( - title = stringResource(R.string.title_about), - onNavigateUp = {} + title = stringResource(R.string.title_about) ) }