compose: add shared composable foundations

EmptyState, SectionHeader, Shimmer placeholders; TopAppBar and
ScrollHint refinements to support the migrated screens.
This commit is contained in:
Rahul Patel
2026-05-19 12:09:33 +05:30
parent e9be2c8293
commit d12cd77e17
5 changed files with 481 additions and 9 deletions

View File

@@ -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
)
}

View File

@@ -36,8 +36,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.aurora.store.R import com.aurora.store.R
@@ -48,7 +48,6 @@ import kotlinx.coroutines.launch
fun ScrollHint( fun ScrollHint(
listState: LazyListState, listState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bottomPadding: Dp = 72.dp, // keep above bottom bar
autoHideOnScroll: Boolean = true, autoHideOnScroll: Boolean = true,
enableBounce: Boolean = true, enableBounce: Boolean = true,
onClickScrollOffset: Float = 300f onClickScrollOffset: Float = 300f
@@ -88,7 +87,7 @@ fun ScrollHint(
visible = visible, visible = visible,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = bottomPadding), .padding(bottom = dimensionResource(R.dimen.scroll_hint_padding)),
enter = fadeIn() + slideInVertically { it / 2 }, enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut() exit = fadeOut()
) { ) {

View File

@@ -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")
}

View File

@@ -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()
}

View File

@@ -5,11 +5,15 @@
package com.aurora.store.compose.composable 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.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter 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 modifier The modifier to be applied to the composable
* @param title Title of the screen * @param title Title of the screen
* @param navigationIcon Icon for the navigation button * @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) * @param actions Actions to display on the top app bar (for e.g. menu)
*/ */
@Composable @Composable
@@ -33,15 +37,17 @@ fun TopAppBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null, title: String? = null,
navigationIcon: Painter = painterResource(R.drawable.ic_arrow_back), navigationIcon: Painter = painterResource(R.drawable.ic_arrow_back),
onNavigateUp: (() -> Unit)? = null, showNavigationIcon: Boolean = true,
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
actions: @Composable (RowScope.() -> Unit) = {} actions: @Composable (RowScope.() -> Unit) = {}
) { ) {
val activity = LocalActivity.current as? ComponentActivity
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = { if (title != null) Text(text = title) }, title = { if (title != null) Text(text = title) },
navigationIcon = { navigationIcon = {
if (onNavigateUp != null) { if (showNavigationIcon) {
IconButton(onClick = onNavigateUp) { IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
painter = navigationIcon, painter = navigationIcon,
contentDescription = stringResource(R.string.action_back) contentDescription = stringResource(R.string.action_back)
@@ -49,6 +55,7 @@ fun TopAppBar(
} }
} }
}, },
windowInsets = windowInsets,
actions = actions actions = actions
) )
} }
@@ -58,7 +65,6 @@ fun TopAppBar(
@Composable @Composable
private fun TopAppBarPreview() { private fun TopAppBarPreview() {
TopAppBar( TopAppBar(
title = stringResource(R.string.title_about), title = stringResource(R.string.title_about)
onNavigateUp = {}
) )
} }