Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required. Shimmer effect is more of a cosmetic change


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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid syncronous call to Setting.Global.getFloat

this can induce Jank. Need to check on this :

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pacific-ring The call is in-memory, not heavy like DB-call or disk write and the composition read that runs once before the animation begins. It will never happen on the per-frame draw path. So there should not be a meaningful jank.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sudhanshu96 good point . checking above got to know its not on the per-frame draw path. So good to go buddy 👍

context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
return scale != 0f
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,13 +17,20 @@ 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
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
Expand Down Expand Up @@ -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() }
}
}

Expand Down
1 change: 1 addition & 0 deletions universal_components/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<string name="copy_as_code">Copy as Code</string>
<string name="active_label">Active</string>
<string name="remove">Remove</string>
<string name="loading">Loading</string>

<!-- Phone Enrollment -->
<string name="add_phone_sms_otp">Add Phone for SMS OTP</string>
Expand Down
Loading