Skip to content

Instantly share code, notes, and snippets.

@tasyamalia
Created March 6, 2026 07:44
Show Gist options
  • Select an option

  • Save tasyamalia/00bf5245d4bb35535a4c4d5d623ad70a to your computer and use it in GitHub Desktop.

Select an option

Save tasyamalia/00bf5245d4bb35535a4c4d5d623ad70a to your computer and use it in GitHub Desktop.
/*
* 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()
}
}
@tasyamalia
Copy link
Author

Screen_recording_20260306_144223.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment