package app.closer.ui.components import app.closer.R import app.closer.ui.theme.closerCardColor import android.provider.Settings import androidx.compose.animation.core.FastOutSlowInEasing 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.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.imageResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @Composable fun LoadingState( message: String = "Loading…", modifier: Modifier = Modifier ) { CloserCard( modifier = modifier.fillMaxWidth(), containerColor = closerCardColor(alpha = 0.8f) ) { Column( modifier = Modifier .fillMaxWidth() .padding(CloserSpacing.Xxxl), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md) ) { CloserHeartLoader() Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) BrandMessageRotator( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.82f), style = MaterialTheme.typography.bodySmall ) } } } @Composable fun CloserHeartLoader( modifier: Modifier = Modifier, size: Dp = 76.dp ) { CloserMarkLoader(modifier = modifier, size = size) } /** Below this size we use a lightweight inline spinner instead of the full app-icon chip. */ private val ChipLoaderMinSize = 40.dp /** * The brand loader. At prominent sizes it renders the **white-keyhole logo on an aubergine app-icon * chip** with a soft fill that rises through the mark (the "charging the connection" motion). At small * inline sizes (e.g. inside buttons) it falls back to a lightweight indeterminate arc tinted to the * current content color, so it never shows a dark square on a colored button. Honors "remove animations". */ @Composable fun CloserMarkLoader( modifier: Modifier = Modifier, size: Dp = 76.dp ) { val context = LocalContext.current val reducedMotion = remember { Settings.Global.getFloat( context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f ) == 0f } if (size >= ChipLoaderMinSize) { CloserMarkChipLoader(modifier = modifier, size = size, reducedMotion = reducedMotion) } else { CloserInlineSpinner( modifier = modifier, size = size, color = LocalContentColor.current, reducedMotion = reducedMotion ) } } @Composable private fun CloserMarkChipLoader( modifier: Modifier, size: Dp, reducedMotion: Boolean ) { val transition = rememberInfiniteTransition(label = "closerMarkLoader") val fill = if (reducedMotion) 1f else transition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Restart ), label = "closerMarkFill" ).value val pulse = if (reducedMotion) 1f else transition.animateFloat( initialValue = 0.97f, targetValue = 1.03f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 900, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse ), label = "closerMarkPulse" ).value // White-keyhole mark (already safe-zone padded) drawn over the aubergine app-icon chip. val mark = ImageBitmap.imageResource(R.drawable.closer_launcher_foreground) Canvas( modifier = modifier .size(size) .graphicsLayer { scaleX = pulse scaleY = pulse } .clearAndSetSemantics {} ) { val w = this.size.width val h = this.size.height val corner = CornerRadius(w * 0.22f, h * 0.22f) // Aubergine gradient chip (matches ic_launcher_background palette). drawRoundRect( brush = Brush.linearGradient( colors = listOf(Color(0xFF5A2F74), Color(0xFF3A1D4D), Color(0xFF24122F)), start = Offset(0f, 0f), end = Offset(w, h) ), cornerRadius = corner ) val src = IntSize(mark.width, mark.height) val dst = IntSize(w.toInt(), h.toInt()) // Ghost of the full mark… drawImage( image = mark, srcOffset = IntOffset.Zero, srcSize = src, dstOffset = IntOffset.Zero, dstSize = dst, alpha = 0.16f ) // …then the fill rises bottom→top. clipRect(top = h * (1f - fill)) { drawImage( image = mark, srcOffset = IntOffset.Zero, srcSize = src, dstOffset = IntOffset.Zero, dstSize = dst, alpha = 1f ) } // Faint ring so the dark chip separates on dark backgrounds. drawRoundRect( color = Color.White.copy(alpha = 0.10f), cornerRadius = corner, style = Stroke(width = 1.dp.toPx()) ) } } @Composable private fun CloserInlineSpinner( modifier: Modifier, size: Dp, color: Color, reducedMotion: Boolean ) { val angle = if (reducedMotion) 0f else rememberInfiniteTransition(label = "closerInlineSpinner") .animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 900, easing = LinearEasing), repeatMode = RepeatMode.Restart ), label = "closerInlineAngle" ).value Canvas( modifier = modifier .size(size) .clearAndSetSemantics {} ) { val stroke = this.size.minDimension * 0.12f val inset = stroke / 2f val d = this.size.minDimension - stroke rotate(degrees = angle) { drawArc( brush = Brush.sweepGradient( colors = listOf(color.copy(alpha = 0.12f), color) ), startAngle = 0f, sweepAngle = 270f, useCenter = false, topLeft = Offset(inset, inset), size = Size(d, d), style = Stroke(width = stroke, cap = StrokeCap.Round) ) } } }