Created
March 6, 2026 07:44
-
-
Save tasyamalia/00bf5245d4bb35535a4c4d5d623ad70a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * GoalRing UI | |
| * Jetpack Compose UI experiment | |
| * | |
| * Created by Tasya Amalia Salsabila, 2026 | |
| */ | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.RepeatMode | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.infiniteRepeatable | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.detectDragGestures | |
| import androidx.compose.foundation.layout.* | |
| import androidx.compose.foundation.lazy.LazyRow | |
| import androidx.compose.foundation.lazy.items | |
| 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.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.* | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.graphics.drawscope.withTransform | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.* | |
| import androidx.compose.ui.util.lerp | |
| import kotlin.math.* | |
| import kotlin.random.Random | |
| /** | |
| * Interactive Goal Ring UI | |
| * | |
| * Highlights: | |
| * - Drag the handle around the ring to set progress | |
| * - Snaps to a step (feel-good UX) | |
| * - Animated gradient background | |
| * - Confetti burst when reaching 100% | |
| */ | |
| @Composable | |
| fun GoalRing( | |
| modifier: Modifier = Modifier, | |
| goalAmount: Long = 10_000_000L, | |
| stepAmount: Long = 100_000L | |
| ) { | |
| // Background motion | |
| val bgShift = remember { Animatable(0f) } | |
| LaunchedEffect(Unit) { | |
| bgShift.animateTo( | |
| targetValue = 1f, | |
| animationSpec = infiniteRepeatable( | |
| animation = tween(durationMillis = 7000, easing = LinearEasing), | |
| repeatMode = RepeatMode.Reverse | |
| ) | |
| ) | |
| } | |
| // Main state | |
| var amount by remember { mutableLongStateOf(goalAmount / 4) } | |
| val clampedAmount = amount.coerceIn(0L, goalAmount) | |
| val rawProgress = remember(clampedAmount, goalAmount) { | |
| (clampedAmount.toDouble() / goalAmount.toDouble()).toFloat().coerceIn(0f, 1f) | |
| } | |
| val animatedProgress by animateFloatAsState( | |
| targetValue = rawProgress, | |
| animationSpec = spring(stiffness = Spring.StiffnessMediumLow, dampingRatio = 0.85f), | |
| label = "progress" | |
| ) | |
| val reachedGoal = rawProgress >= 1f | |
| // Confetti trigger | |
| var confettiKey by remember { mutableIntStateOf(0) } | |
| LaunchedEffect(reachedGoal) { | |
| if (reachedGoal) confettiKey++ | |
| } | |
| // Derived display | |
| val formattedAmount by remember(clampedAmount) { mutableStateOf(formatIdr(clampedAmount)) } | |
| val formattedGoal by remember(goalAmount) { mutableStateOf(formatIdr(goalAmount)) } | |
| val tips = remember(goalAmount) { | |
| listOf( | |
| Tip("Auto-debit", "Set weekly auto-save to stay consistent"), | |
| Tip("Cut 1 expense", "Remove 1 small recurring spend"), | |
| Tip("Boost income", "Try 1 micro freelance gig"), | |
| Tip("Track daily", "Log expenses in 30 seconds") | |
| ) | |
| } | |
| val bgBrush = remember(bgShift.value) { | |
| val t = bgShift.value | |
| Brush.linearGradient( | |
| colors = listOf( | |
| Color(0xFF0B1220), | |
| lerp(Color(0xFF1B2B4D), Color(0xFF2A1B4D), t), | |
| lerp(Color(0xFF1C3B5A), Color(0xFF0B3D2E), 1f - t), | |
| Color(0xFF070B12) | |
| ), | |
| start = Offset(0f, 0f), | |
| end = Offset(1200f * (0.3f + t), 1200f * (0.7f - t)) | |
| ) | |
| } | |
| Surface( | |
| modifier = modifier.fillMaxSize(), | |
| color = Color(0xFF070B12) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(bgBrush) | |
| .padding(20.dp) | |
| ) { | |
| Column( | |
| modifier = Modifier.fillMaxSize(), | |
| verticalArrangement = Arrangement.spacedBy(16.dp) | |
| ) { | |
| HeaderBlock( | |
| title = "Savings Goal Ring", | |
| subtitle = "Drag the handle. Snap-to-step. Confetti on 100%." | |
| ) | |
| Card( | |
| shape = RoundedCornerShape(24.dp), | |
| colors = CardDefaults.cardColors(containerColor = Color(0xFF0D1424).copy(alpha = 0.85f)), | |
| modifier = Modifier.fillMaxWidth() | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(18.dp), | |
| verticalArrangement = Arrangement.spacedBy(12.dp) | |
| ) { | |
| AmountRow( | |
| leftLabel = "Saved", | |
| leftValue = formattedAmount, | |
| rightLabel = "Goal", | |
| rightValue = formattedGoal | |
| ) | |
| GoalRing( | |
| progress = animatedProgress, | |
| onProgressChange = { newProgress -> | |
| val snapped = snapToStep( | |
| value = (newProgress * goalAmount).toLong(), | |
| step = stepAmount | |
| ).coerceIn(0L, goalAmount) | |
| amount = snapped | |
| }, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(280.dp) | |
| ) | |
| LinearMeta( | |
| progress = rawProgress, | |
| amount = clampedAmount, | |
| goal = goalAmount | |
| ) | |
| } | |
| } | |
| LazyRow( | |
| horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| contentPadding = PaddingValues(horizontal = 2.dp) | |
| ) { | |
| items(tips) { tip -> | |
| TipCard(tip = tip) | |
| } | |
| } | |
| Spacer(Modifier.weight(1f)) | |
| FooterCTA( | |
| onRandomize = { | |
| val newAmount = (goalAmount * Random.nextDouble(0.05, 1.0)).toLong() | |
| amount = snapToStep(newAmount, stepAmount).coerceIn(0L, goalAmount) | |
| }, | |
| onReset = { amount = 0L } | |
| ) | |
| } | |
| ConfettiOverlay( | |
| key = confettiKey, | |
| enabled = reachedGoal, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun HeaderBlock(title: String, subtitle: String) { | |
| Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| Text( | |
| text = title, | |
| style = MaterialTheme.typography.headlineSmall.copy( | |
| fontWeight = FontWeight.SemiBold, | |
| color = Color(0xFFEAF1FF) | |
| ) | |
| ) | |
| Text( | |
| text = subtitle, | |
| style = MaterialTheme.typography.bodyMedium.copy(color = Color(0xFFB9C6E6)) | |
| ) | |
| } | |
| } | |
| @Composable | |
| private fun AmountRow( | |
| leftLabel: String, | |
| leftValue: String, | |
| rightLabel: String, | |
| rightValue: String | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween | |
| ) { | |
| Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { | |
| Text(leftLabel, style = MaterialTheme.typography.labelMedium, color = Color(0xFF9FB1D9)) | |
| Text( | |
| leftValue, | |
| style = MaterialTheme.typography.titleLarge.copy( | |
| fontWeight = FontWeight.SemiBold, | |
| color = Color(0xFFEAF1FF) | |
| ) | |
| ) | |
| } | |
| Column( | |
| horizontalAlignment = Alignment.End, | |
| verticalArrangement = Arrangement.spacedBy(4.dp) | |
| ) { | |
| Text(rightLabel, style = MaterialTheme.typography.labelMedium, color = Color(0xFF9FB1D9)) | |
| Text( | |
| rightValue, | |
| style = MaterialTheme.typography.titleMedium.copy( | |
| fontWeight = FontWeight.Medium, | |
| color = Color(0xFFCFE0FF) | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun GoalRing( | |
| progress: Float, | |
| onProgressChange: (Float) -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val ringThickness = 18.dp | |
| val handleRadius = 10.dp | |
| val density = LocalDensity.current | |
| val ringThicknessPx = with(density) { ringThickness.toPx() } | |
| val handleRadiusPx = with(density) { handleRadius.toPx() } | |
| val trackColor = Color(0xFF22314F).copy(alpha = 0.9f) | |
| val glowColor = Color(0xFF8BC0FF).copy(alpha = 0.20f) | |
| val progressBrush = Brush.sweepGradient( | |
| colors = listOf( | |
| Color(0xFF6CE7FF), | |
| Color(0xFF8C7CFF), | |
| Color(0xFFFF7CC8), | |
| Color(0xFF6CE7FF) | |
| ) | |
| ) | |
| var lastProgress by remember { mutableFloatStateOf(progress) } | |
| fun angleFromProgress(p: Float): Float = -90f + (p.coerceIn(0f, 1f) * 360f) | |
| fun progressFromPosition(center: Offset, pos: Offset): Float { | |
| val dx = pos.x - center.x | |
| val dy = pos.y - center.y | |
| var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() | |
| if (angle < 0f) angle += 360f | |
| val shifted = (angle + 90f) % 360f | |
| return (shifted / 360f).coerceIn(0f, 1f) | |
| } | |
| fun unwrapProgress(current: Float, previous: Float): Float { | |
| val c0 = current | |
| val c1 = current + 1f | |
| val c2 = current - 1f | |
| fun dist(a: Float, b: Float) = abs(a - b) | |
| val best = when { | |
| dist(c1, previous) < dist(c0, previous) && dist(c1, previous) < dist(c2, previous) -> c1 | |
| dist(c2, previous) < dist(c0, previous) -> c2 | |
| else -> c0 | |
| } | |
| return best | |
| } | |
| Box( | |
| modifier = modifier, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(Unit) { | |
| detectDragGestures( | |
| onDragStart = { | |
| lastProgress = progress | |
| }, | |
| onDrag = { change, _ -> | |
| change.consume() | |
| val size = this.size | |
| val center = Offset(size.width / 2f, size.height / 2f) | |
| val p = progressFromPosition(center, change.position) // 0..1 but wraps | |
| val unwrapped = unwrapProgress(p, lastProgress) // continuous | |
| .coerceIn(0f, 1f) | |
| lastProgress = unwrapped | |
| onProgressChange(unwrapped) | |
| } | |
| ) | |
| } | |
| ) { | |
| val stroke = Stroke(width = ringThicknessPx, cap = StrokeCap.Round) | |
| val radius = min(size.width, size.height) * 0.34f | |
| val ringRect = Rect( | |
| center = center, | |
| radius = radius | |
| ) | |
| // Soft glow behind the ring | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| colors = listOf(glowColor, Color.Transparent), | |
| center = center, | |
| radius = radius * 1.7f | |
| ), | |
| radius = radius * 1.2f, | |
| center = center | |
| ) | |
| // Track | |
| drawArc( | |
| color = trackColor, | |
| startAngle = 0f, | |
| sweepAngle = 360f, | |
| useCenter = false, | |
| topLeft = ringRect.topLeft, | |
| size = ringRect.size, | |
| style = stroke | |
| ) | |
| // Progress arc | |
| val sweep = (progress.coerceIn(0f, 1f) * 360f) | |
| drawArc( | |
| brush = progressBrush, | |
| startAngle = -90f, | |
| sweepAngle = sweep, | |
| useCenter = false, | |
| topLeft = ringRect.topLeft, | |
| size = ringRect.size, | |
| style = stroke | |
| ) | |
| // Handle position | |
| val a = Math.toRadians(angleFromProgress(progress).toDouble()) | |
| val hx = center.x + cos(a).toFloat() * radius | |
| val hy = center.y + sin(a).toFloat() * radius | |
| val handleCenter = Offset(hx, hy) | |
| // Handle shadow | |
| drawCircle( | |
| color = Color.Black.copy(alpha = 0.35f), | |
| radius = handleRadiusPx * 1.65f, | |
| center = handleCenter + Offset(0f, handleRadiusPx * 0.35f) | |
| ) | |
| // Handle fill | |
| drawCircle( | |
| brush = Brush.radialGradient( | |
| colors = listOf(Color(0xFFEAF1FF), Color(0xFFB8C7FF)), | |
| center = handleCenter, | |
| radius = handleRadiusPx * 1.8f | |
| ), | |
| radius = handleRadiusPx * 1.35f, | |
| center = handleCenter | |
| ) | |
| // Handle outline | |
| drawCircle( | |
| color = Color(0xFF0B1220), | |
| radius = handleRadiusPx * 1.35f, | |
| center = handleCenter, | |
| style = Stroke(width = 2.5f) | |
| ) | |
| } | |
| // Center labels | |
| val pct = (progress * 100f).roundToInt().coerceIn(0, 100) | |
| Column(horizontalAlignment = Alignment.CenterHorizontally) { | |
| Text( | |
| text = "$pct%", | |
| style = MaterialTheme.typography.displaySmall.copy( | |
| fontWeight = FontWeight.SemiBold, | |
| color = Color(0xFFEAF1FF) | |
| ) | |
| ) | |
| Spacer(Modifier.height(6.dp)) | |
| Text( | |
| text = "Drag to adjust", | |
| style = MaterialTheme.typography.labelLarge.copy(color = Color(0xFFB9C6E6)) | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun LinearMeta(progress: Float, amount: Long, goal: Long) { | |
| val remaining = (goal - amount).coerceAtLeast(0L) | |
| Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
| LinearProgressIndicator( | |
| progress = { progress.coerceIn(0f, 1f) }, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(10.dp), | |
| trackColor = Color(0xFF22314F), | |
| color = Color(0xFF6CE7FF) | |
| ) | |
| Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { | |
| MetaChip(label = "Remaining", value = formatIdr(remaining)) | |
| MetaChip(label = "Pace", value = paceHint(progress)) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun MetaChip(label: String, value: String) { | |
| Surface( | |
| shape = RoundedCornerShape(999.dp), | |
| color = Color(0xFF101B33), | |
| tonalElevation = 0.dp | |
| ) { | |
| Row( | |
| modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Text(label, style = MaterialTheme.typography.labelMedium, color = Color(0xFF9FB1D9)) | |
| Spacer(Modifier.width(10.dp)) | |
| Text(value, style = MaterialTheme.typography.labelLarge, color = Color(0xFFEAF1FF)) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun TipCard(tip: Tip) { | |
| Card( | |
| shape = RoundedCornerShape(20.dp), | |
| colors = CardDefaults.cardColors(containerColor = Color(0xFF6CE7FF).copy(alpha = 0.28f)), | |
| modifier = Modifier.width(220.dp) | |
| ) { | |
| Column( | |
| modifier = Modifier.padding(14.dp), | |
| verticalArrangement = Arrangement.spacedBy(8.dp) | |
| ) { | |
| Text( | |
| tip.title, | |
| style = MaterialTheme.typography.titleMedium.copy( | |
| fontWeight = FontWeight.SemiBold, | |
| color = Color(0xFFEAF1FF) | |
| ) | |
| ) | |
| Text( | |
| tip.body, | |
| style = MaterialTheme.typography.bodyMedium.copy(color = Color(0xFFB9C6E6)) | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun FooterCTA( | |
| onRandomize: () -> Unit, | |
| onReset: () -> Unit | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(10.dp) | |
| ) { | |
| Button( | |
| onClick = onRandomize, | |
| modifier = Modifier.weight(1f), | |
| shape = RoundedCornerShape(16.dp) | |
| ) { | |
| Text("Randomize") | |
| } | |
| OutlinedButton( | |
| onClick = onReset, | |
| modifier = Modifier.weight(1f), | |
| shape = RoundedCornerShape(16.dp), | |
| colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFEAF1FF)) | |
| ) { | |
| Text("Reset") | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun ConfettiOverlay( | |
| key: Int, | |
| enabled: Boolean, | |
| modifier: Modifier = Modifier | |
| ) { | |
| if (!enabled || key == 0) return | |
| val particles = remember(key) { generateParticles(count = 120) } | |
| val t = remember(key) { Animatable(0f) } | |
| LaunchedEffect(key) { | |
| t.snapTo(0f) | |
| t.animateTo(1f, animationSpec = tween(durationMillis = 1400, easing = LinearEasing)) | |
| } | |
| val alpha = (1f - t.value).coerceIn(0f, 1f) | |
| Canvas(modifier = modifier) { | |
| val w = size.width | |
| val h = size.height | |
| val time = t.value | |
| particles.forEach { p -> | |
| val x = (p.startX * w) + (p.vx * w * time) | |
| val y = (p.startY * h) + (p.vy * h * time) + (time * time * h * 0.35f) | |
| val rot = p.rot + (time * p.rotSpeed) | |
| val sizePx = lerp(3f, 10f, p.sizeBias) | |
| val color = p.color.copy(alpha = alpha) | |
| withTransform({ | |
| translate(left = x, top = y) | |
| rotate(degrees = rot) | |
| }) { | |
| drawRoundRect( | |
| color = color, | |
| topLeft = Offset(-sizePx, -sizePx), | |
| size = Size(sizePx * 2f, sizePx * 1.2f), | |
| cornerRadius = CornerRadius(sizePx * 0.45f, sizePx * 0.45f) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| private data class Tip(val title: String, val body: String) | |
| private data class Particle( | |
| val startX: Float, | |
| val startY: Float, | |
| val vx: Float, | |
| val vy: Float, | |
| val rot: Float, | |
| val rotSpeed: Float, | |
| val sizeBias: Float, | |
| val color: Color | |
| ) | |
| private fun generateParticles(count: Int): List<Particle> { | |
| val palette = listOf( | |
| Color(0xFF6CE7FF), | |
| Color(0xFF8C7CFF), | |
| Color(0xFFFF7CC8), | |
| Color(0xFFFFD37C), | |
| Color(0xFF7CFFB6) | |
| ) | |
| return List(count) { | |
| val sx = Random.nextFloat() | |
| val sy = Random.nextFloat() * 0.12f | |
| val vx = (Random.nextFloat() - 0.5f) * 0.9f | |
| val vy = Random.nextFloat() * 0.9f + 0.15f | |
| Particle( | |
| startX = sx, | |
| startY = sy, | |
| vx = vx, | |
| vy = vy, | |
| rot = Random.nextFloat() * 360f, | |
| rotSpeed = (Random.nextFloat() - 0.5f) * 540f, | |
| sizeBias = Random.nextFloat(), | |
| color = palette.random() | |
| ) | |
| } | |
| } | |
| private fun snapToStep(value: Long, step: Long): Long { | |
| if (step <= 0L) return value | |
| val q = value / step | |
| val r = value % step | |
| return if (r >= step / 2) (q + 1) * step else q * step | |
| } | |
| private fun formatIdr(value: Long): String { | |
| val abs = abs(value) | |
| return when { | |
| abs >= 1_000_000_000L -> "Rp ${trimZeros(abs / 1_000_000_000.0)}B" | |
| abs >= 1_000_000L -> "Rp ${trimZeros(abs / 1_000_000.0)}M" | |
| abs >= 1_000L -> "Rp ${trimZeros(abs / 1_000.0)}K" | |
| else -> "Rp $abs" | |
| } | |
| } | |
| private fun trimZeros(d: Double): String { | |
| val s = String.format("%.2f", d) | |
| return s.replace(Regex("\\.?0+$"), "") | |
| } | |
| private fun paceHint(progress: Float): String { | |
| return when { | |
| progress >= 0.90f -> "Finish line" | |
| progress >= 0.70f -> "Strong pace" | |
| progress >= 0.40f -> "Steady" | |
| progress >= 0.15f -> "Warm up" | |
| else -> "Start small" | |
| } | |
| } | |
| @Preview(showBackground = true, widthDp = 420, heightDp = 900) | |
| @Composable | |
| private fun GoalRingPreview() { | |
| MaterialTheme { | |
| GoalRing() | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen_recording_20260306_144223.mp4