diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/components/AnimatedComponents.kt b/app/src/main/java/com/hotdeal/alarm/presentation/components/AnimatedComponents.kt index dfe902a..58d65c2 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/components/AnimatedComponents.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/components/AnimatedComponents.kt @@ -2,137 +2,160 @@ package com.hotdeal.alarm.presentation.components import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.hotdeal.alarm.ui.theme.CornerRadius import com.hotdeal.alarm.ui.theme.Spacing /** - * 애니메이션이 있는 아이콘 버튼 + * One UI 9 Elastic Spring Press Effect + * 클릭 시 부드럽게 눌리고 뗐을 때 탄성 있게 복원되는 고품질 인터랙션 */ -@Composable -fun AnimatedIconButton( - onClick: () -> Unit, - icon: @Composable () -> Unit, - modifier: Modifier = Modifier, - isActivated: Boolean = false -) { +fun Modifier.elasticPressClickable( + interactionSource: MutableInteractionSource? = null, + enabled: Boolean = true, + pressedScale: Float = 0.97f, + onClick: () -> Unit +): Modifier = composed { + val source = interactionSource ?: remember { MutableInteractionSource() } + val isPressed by source.collectIsPressedAsState() + val scale by animateFloatAsState( - targetValue = if (isActivated) 1.2f else 1f, + targetValue = if (isPressed && enabled) pressedScale else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow + stiffness = Spring.StiffnessMedium ), - label = "icon_scale" + label = "elastic_press_scale" ) - - IconButton( - onClick = onClick, - modifier = modifier.scale(scale) - ) { - icon() - } + + this + .scale(scale) + .clickable( + interactionSource = source, + indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공 + enabled = enabled, + onClick = onClick + ) } /** - * 페이드 인 애니메이션 박스 + * One UI 9 Squircle Container Card with Ambient Border */ @Composable -fun FadeInBox( +fun SquircleCard( modifier: Modifier = Modifier, - delayMillis: Int = 0, - content: @Composable AnimatedVisibilityScope.() -> Unit + shape: Shape = CornerRadius.shapeSquircle, + containerColor: Color = MaterialTheme.colorScheme.surface, + borderColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + borderWidth: Dp = 0.8.dp, + content: @Composable BoxScope.() -> Unit ) { - var visible by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(delayMillis.toLong()) - visible = true - } - - AnimatedVisibility( - visible = visible, - enter = fadeIn( - animationSpec = tween(300) - ) + slideInVertically( - animationSpec = tween(300), - initialOffsetY = { it / 4 } - ), + Box( modifier = modifier - ) { - content() - } -} - -/** - * 스케일 애니메이션 카드 - */ -@Composable -fun ScaleAnimationCard( - modifier: Modifier = Modifier, - onClick: () -> Unit, - content: @Composable () -> Unit -) { - var isPressed by remember { mutableStateOf(false) } - - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.95f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessHigh - ), - label = "card_scale" + .clip(shape) + .background(containerColor) + .border(borderWidth, borderColor, shape), + content = content ) - - Card( - modifier = modifier - .scale(scale), - onClick = { - isPressed = true - onClick() - } - ) { - content() - } - - LaunchedEffect(isPressed) { - if (isPressed) { - kotlinx.coroutines.delay(100) - isPressed = false - } - } } /** - * 로딩 오버레이 + * One UI 9 Pill Capsule Badge */ @Composable -fun LoadingOverlay( - isLoading: Boolean, +fun PillBadge( + text: String, modifier: Modifier = Modifier, - content: @Composable () -> Unit + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, + textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + icon: (@Composable () -> Unit)? = null ) { - Box(modifier = modifier) { - content() - - AnimatedVisibility( - visible = isLoading, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.matchParentSize() + Surface( + shape = CornerRadius.shapePill, + color = backgroundColor, + modifier = modifier.height(24.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(Spacing.lg), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + icon?.invoke() + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor + ) + } + } +} + +/** + * One UI 9 Now Status Pill (상단 브리핑 캡슐) + */ +@Composable +fun NowStatusPill( + text: String, + modifier: Modifier = Modifier, + isLive: Boolean = true, + accentColor: Color = MaterialTheme.colorScheme.primary +) { + val infiniteTransition = rememberInfiniteTransition(label = "now_pulse") + val dotAlpha by infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "dot_alpha" + ) + + Surface( + shape = CornerRadius.shapePill, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = androidx.compose.foundation.BorderStroke( + 0.8.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ), + modifier = modifier.height(28.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp) + ) { + if (isLive) { + Box( + modifier = Modifier + .size(7.dp) + .background(accentColor.copy(alpha = dotAlpha), CircleShape) + ) } + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } }