Closer/app/src/main/java/app/closer/ui/components/LoadingState.kt

241 lines
8.1 KiB
Kotlin

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