diff --git a/README.md b/README.md index dd05692..4fae49f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This library provides ready-to-use UI components for multi-factor authentication - 💬 **SMS OTP** - Phone number verification via one-time codes - 📧 **Email OTP** - Email-based verification - 🔑 **Recovery Codes** - Backup authentication codes for account recovery +- ✨ **Skeleton shimmer loading** - Card screens show animated, theme-aware skeleton placeholders while data loads (light/dark mode, honours the system animation setting) All components are built on top of the [Auth0 Android SDK](https://github.com/auth0/Auth0.Android) and integrate with Auth0's My Account APIs. diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt new file mode 100644 index 0000000..d291786 --- /dev/null +++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/Shimmer.kt @@ -0,0 +1,126 @@ +package com.auth0.universalcomponents.presentation.ui.components.skeleton + +import android.provider.Settings +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import com.auth0.universalcomponents.theme.Auth0Theme + +private const val SHIMMER_DURATION_MS = 1300 +private const val BAND_WIDTH_MULTIPLIER = 2f +private const val BAND_TRAVEL_MULTIPLIER = 3f +private const val LUMINANCE_DARK_THRESHOLD = 0.5f + +// Purpose-built skeleton greys. Declared as properties (not inline literals) so the colour +// hex values are exempt from detekt's MagicNumber rule, mirroring how the theme tokens are +// handled. +private val DarkSkeletonBase = Color(0xFF383838) +private val DarkSkeletonHighlight = Color(0xFF575757) +private val LightSkeletonBase = Color(0xFFE0E0E0) +private val LightSkeletonHighlight = Color(0xFFF7F7F7) + +private val DarkSkeletonPalette = SkeletonPalette(base = DarkSkeletonBase, highlight = DarkSkeletonHighlight) +private val LightSkeletonPalette = SkeletonPalette(base = LightSkeletonBase, highlight = LightSkeletonHighlight) + +/** + * Light/dark greys for skeletons. The SDK's layer tokens are near-white-on-white in + * light mode (an invisible sweep), so we use purpose-built greys that adapt to the + * colour scheme. + */ +internal data class SkeletonPalette(val base: Color, val highlight: Color) { + companion object { + fun forScheme(dark: Boolean): SkeletonPalette = + if (dark) DarkSkeletonPalette else LightSkeletonPalette + } +} + +/** + * Resolves the active skeleton palette from the current [Auth0Theme]. + * + * Uses the resolved theme background's luminance rather than [androidx.compose.foundation.isSystemInDarkTheme] + * so that a consumer who forces `Auth0Theme(darkTheme = …)` (independent of the system + * setting) still gets greys that match the rendered surface. + */ +@Composable +internal fun rememberSkeletonPalette(): SkeletonPalette { + val dark = Auth0Theme.colors.backgroundLayerBase.luminance() < LUMINANCE_DARK_THRESHOLD + return remember(dark) { SkeletonPalette.forScheme(dark) } +} + +/** + * Sweeps an animated shimmer highlight across this composable to indicate loading. + * Apply to ONE container (a list/Column) so every child animates in a single + * synchronized sweep — the highlight is clipped to the content that is actually drawn. + * + * When [active] is false, or the system "remove animations" setting is on, this is a + * no-op and the placeholders render as static base-grey blocks (the reduce-motion + * fallback). + */ +@Composable +internal fun Modifier.shimmer(active: Boolean = true): Modifier { + if (!active || !animationsEnabled()) return this + + val palette = rememberSkeletonPalette() + val transition = rememberInfiniteTransition(label = "shimmer") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_DURATION_MS, easing = LinearEasing), + repeatMode = RepeatMode.Restart, // sweep travels in one direction only + ), + label = "shimmer-phase", + ) + + // SrcAtop masks to the content only when that content is drawn into an isolated + // buffer; without an offscreen layer the band would paint across the whole bounding + // rect (including the gaps between cards), instead of just the drawn placeholders. + return this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + // `phase` is read inside the draw lambda → only the draw phase re-runs per frame, + // not recomposition. + .drawWithContent { + drawContent() // 1) draw the real placeholders first + val w = size.width + // base → highlight → base band, 2× width, translated by (phase*3 - 2)*w. + val startX = (phase * BAND_TRAVEL_MULTIPLIER - BAND_WIDTH_MULTIPLIER) * w + val brush = Brush.linearGradient( + colors = listOf(palette.base, palette.highlight, palette.base), + start = Offset(startX, 0f), + end = Offset(startX + w * BAND_WIDTH_MULTIPLIER, 0f), + ) + // 2) paint the band ONLY over already-drawn pixels → clips the sweep to the content. + drawRect(brush = brush, blendMode = BlendMode.SrcAtop) + } +} + +/** + * Compose has no direct `accessibilityReduceMotion`. Honour the system "remove animations" + * setting instead: when animations are scaled to 0, skip the sweep (static placeholders). + */ +@Composable +internal fun animationsEnabled(): Boolean { + val context = LocalContext.current + val scale = Settings.Global.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f, + ) + return scale != 0f +} diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt new file mode 100644 index 0000000..91660ad --- /dev/null +++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonCards.kt @@ -0,0 +1,80 @@ +package com.auth0.universalcomponents.presentation.ui.components.skeleton + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.auth0.universalcomponents.theme.Auth0Theme + +private val AuthMethodCardHeight = 70.dp +private val AuthMethodTitleWidth = 140.dp + +/** + * Repeats a skeleton [row] [count] times. Intentionally does NOT shimmer itself — apply + * Modifier.shimmer() ONCE on the screen's loading container (see the screens) so the header + * line and every card animate in a single synchronized sweep. + */ +@Composable +internal fun SkeletonList( + count: Int = 5, + modifier: Modifier = Modifier, + spacing: Dp = Auth0Theme.dimensions.spacingMd, + row: @Composable () -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + repeat(count) { row() } + } +} + +/** + * Border-only card shell matching the real cards' size, border, and radius (NO fill — + * a grey sweep over the near-white `backgroundLayerMedium` fill would read as a dark band + * crossing a white card). Size, border, and radius still match the real card so there's no + * layout shift when data arrives. Parametrised because the two real cards differ in + * height/padding. + */ +@Composable +internal fun SkeletonCard( + height: Dp, + contentPadding: Dp, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(height) + .border(1.dp, Auth0Theme.colors.borderDefault, Auth0Theme.shapes.large) + .padding(contentPadding), + verticalAlignment = verticalAlignment, + content = content, + ) +} + +/** Mirrors AuthenticatorItem: leading icon, title line, trailing chevron. */ +@Composable +internal fun AuthMethodCardSkeleton() = SkeletonCard( + height = AuthMethodCardHeight, + contentPadding = Auth0Theme.sizes.padding, // 16.dp +) { + SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small) + Spacer(Modifier.width(Auth0Theme.dimensions.spacingMd)) + SkeletonLine(width = AuthMethodTitleWidth) + Spacer(Modifier.weight(1f)) + SkeletonBox(Modifier.size(Auth0Theme.sizes.iconMedium), shape = Auth0Theme.shapes.small) +} diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt new file mode 100644 index 0000000..3180186 --- /dev/null +++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/components/skeleton/SkeletonShape.kt @@ -0,0 +1,35 @@ +package com.auth0.universalcomponents.presentation.ui.components.skeleton + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Default height of a single text-line placeholder. */ +private val SkeletonLineHeight = 14.dp + +/** + * A single themed placeholder block — the atom of the skeleton system. Compose several + * inside Rows/Columns to mirror a real layout, then wrap the parent in Modifier.shimmer(). + * Renders the resting base grey; the sweep is layered on by the parent's shimmer(). + */ +@Composable +internal fun SkeletonBox( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(8.dp), +) { + val base = rememberSkeletonPalette().base + Box(modifier.background(color = base, shape = shape)) +} + +/** Convenience: a single text-line placeholder (default 14.dp tall). */ +@Composable +internal fun SkeletonLine(width: Dp, modifier: Modifier = Modifier) { + SkeletonBox(modifier.width(width).height(SkeletonLineHeight)) +} diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt index 10eda46..ab121e5 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/presentation/ui/mfa/AuthenticatorMethodsScreen.kt @@ -3,7 +3,9 @@ package com.auth0.universalcomponents.presentation.ui.mfa import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -15,6 +17,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.auth0.universalcomponents.R @@ -22,6 +27,10 @@ import com.auth0.universalcomponents.di.UniversalComponentsModule import com.auth0.universalcomponents.presentation.ui.components.CircularLoader import com.auth0.universalcomponents.presentation.ui.components.ErrorHandler import com.auth0.universalcomponents.presentation.ui.components.TopBar +import com.auth0.universalcomponents.presentation.ui.components.skeleton.AuthMethodCardSkeleton +import com.auth0.universalcomponents.presentation.ui.components.skeleton.SkeletonLine +import com.auth0.universalcomponents.presentation.ui.components.skeleton.SkeletonList +import com.auth0.universalcomponents.presentation.ui.components.skeleton.shimmer import com.auth0.universalcomponents.presentation.ui.mfa.authenticatormethods.PrimaryAuthenticatorListScreen import com.auth0.universalcomponents.presentation.ui.mfa.authenticatormethods.SecondaryAuthenticatorListScreen import com.auth0.universalcomponents.presentation.ui.passkeys.PasskeyEvent @@ -106,11 +115,19 @@ internal fun AuthenticatorMethodsScreen( } AuthenticatorUiState.Loading -> { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + // Skeleton mirrors the loaded layout → no layout shift when data arrives. + // One shimmer on the container = one synchronized sweep over header + cards. + val loadingDescription = stringResource(R.string.loading) + Column( + modifier = Modifier + .fillMaxSize() + .padding(vertical = dimensions.spacingMd) + .shimmer() + .semantics { contentDescription = loadingDescription }, ) { - CircularLoader() + SkeletonLine(width = 180.dp, modifier = Modifier.height(22.dp)) + Spacer(Modifier.height(dimensions.spacingMd)) + SkeletonList(count = 5) { AuthMethodCardSkeleton() } } } diff --git a/universal_components/src/main/res/values/strings.xml b/universal_components/src/main/res/values/strings.xml index 8d017c5..f43ca8a 100644 --- a/universal_components/src/main/res/values/strings.xml +++ b/universal_components/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Copy as Code Active Remove + Loading Add Phone for SMS OTP