241 lines
8.1 KiB
Kotlin
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)
|
|
)
|
|
}
|
|
}
|
|
}
|