1+ package com.cornellappdev.uplift.ui.components.general
2+
3+ import androidx.compose.animation.core.LinearEasing
4+ import androidx.compose.animation.core.animateFloatAsState
5+ import androidx.compose.animation.core.tween
6+ import androidx.compose.foundation.Canvas
7+ import androidx.compose.foundation.layout.fillMaxSize
8+ import androidx.compose.runtime.*
9+ import androidx.compose.ui.Modifier
10+ import androidx.compose.ui.geometry.CornerRadius
11+ import androidx.compose.ui.geometry.Offset
12+ import androidx.compose.ui.geometry.Rect
13+ import androidx.compose.ui.geometry.Size
14+ import androidx.compose.ui.graphics.Brush
15+ import androidx.compose.ui.graphics.Color
16+ import androidx.compose.ui.graphics.drawscope.withTransform
17+ import com.cornellappdev.uplift.ui.theme.ConfettiColors
18+ import kotlinx.coroutines.delay
19+ import com.cornellappdev.uplift.ui.viewmodels.profile.ConfettiViewModel
20+ import kotlin.math.cos
21+ import kotlin.math.sin
22+ import kotlin.random.Random
23+
24+ /* *
25+ * Shape of a confetti particle used by [ConfettiBurst]
26+ */
27+ enum class ConfettiShape { CIRCLE , RECTANGLE }
28+
29+ /* *
30+ * UI state backing the Check-In pop-up
31+ *
32+ * @param start Emission point where the particle spawns
33+ * @param vx Initial horizontal velocity. Positive is to the right.
34+ * @param vy0 Initial vertical velocity (px/s). Negative values launch upward.
35+ * @param size Base size (px). Used as the radius for circles and scale for rectangles.
36+ * @param color Color for the particle.
37+ * @param shape Geometric shape to render for this particle.
38+ * @param rotation Initial rotation (degrees).
39+ */
40+ private data class ConfettiParticle2D (
41+ val start : Offset ,
42+ val vx : Float ,
43+ val vy0 : Float ,
44+ val size : Float ,
45+ val color : Color ,
46+ val shape : ConfettiShape ,
47+ val rotation : Float
48+ )
49+
50+ /* *
51+ * Renders a confetti burst anchored to a given popup rectangle.(checkinpopup).
52+ *
53+ * Reads 'showing' from [ConfettiViewModel]. If false or rect is null, renders nothing.
54+ * When shown, spawns particles inside the bounds of 'originRectInRoot'.
55+ * Animates progress from 0 to 1 in 1.2 seconds with linear easing.
56+ * Applies simple ballistic motion to each particle:
57+ * x(t) = x0 + vx * t
58+ * y(t) = y0 +vy0 * t + 0.5 * g * t^2
59+ * where g = 1750 px/s^2 to pull particles downward.
60+ * Fades particles out as progress approaches 1.
61+ * When complete calls [ConfettiViewModel.onAnimationFinished] to hide the confetti.
62+ */
63+ @Composable
64+ fun ConfettiBurst (
65+ confettiViewModel : ConfettiViewModel ,
66+ particleSpawningBounds : Rect ? ,
67+ modifier : Modifier = Modifier ,
68+ particleCount : Int = 30,
69+ colors : List <Color > = listOf(
70+ ConfettiColors .Yellow1 ,
71+ ConfettiColors .Yellow2 ,
72+ ConfettiColors .Yellow3 ,
73+ ConfettiColors .Yellow4
74+ )
75+ ) {
76+ val uiState = confettiViewModel.collectUiStateValue()
77+
78+ if (! uiState.showing || particleSpawningBounds == null ) {
79+ return
80+ }
81+
82+ val rect = particleSpawningBounds
83+
84+ var started by remember(uiState.showing) { mutableStateOf(false ) }
85+
86+ LaunchedEffect (Unit ) {
87+ started = true
88+ }
89+
90+ // Progress 0 to 1 over 1.2s, used as time 't' in the physics below
91+ val progress by animateFloatAsState(
92+ targetValue = if (started) 1f else 0f ,
93+ animationSpec = tween(durationMillis = 1200 , easing = LinearEasing ),
94+ label = " confettiProgress"
95+ )
96+
97+ // build particles each with spawn, shape, size and velocity
98+ val particles = remember((uiState.showing)) {
99+ List (particleCount) {
100+ // spawn uniformly inside the rect bounds
101+ val x = Random .nextFloat() * rect.width + rect.left
102+ val y = Random .nextFloat() * rect.height + rect.top
103+ val start: Offset = Offset (x,y)
104+ // angled straight up with a random right skew to look more natural
105+ val angle = ((- 90f + Random .nextFloat() * 110f ) * Math .PI / 180f ).toFloat()
106+ // initial speed
107+ val speed = Random .nextFloat() * 700f + 400f
108+ // velocity components
109+ val vx = cos(angle) * speed
110+ val vy0 = sin(angle) * speed
111+ // size in px
112+ val size = Random .nextInt(18 , 34 ).toFloat()
113+
114+ ConfettiParticle2D (
115+ start = start,
116+ vx = vx,
117+ vy0 = vy0,
118+ size = size,
119+ color = colors.random(),
120+ shape = if (Random .nextFloat() < 0.25f ) ConfettiShape .CIRCLE else ConfettiShape .RECTANGLE ,
121+ rotation = Random .nextFloat() * 360f
122+ )
123+ }
124+ }
125+
126+
127+ LaunchedEffect (Unit ) {
128+ delay(1300 )
129+ confettiViewModel.onAnimationFinished()
130+ }
131+
132+ // Renders ballistic motion + fade out for each particle
133+ Canvas (modifier = modifier.fillMaxSize()) {
134+ // gravity and seconds (time)
135+ val gravity = 1750f
136+ val time = progress * 1.2f
137+
138+ particles.forEach { particle ->
139+ // position at time
140+ val posX = particle.start.x + particle.vx * time
141+ val posY = particle.start.y + (particle.vy0 * time + 0.5f * gravity * time * time)
142+
143+ // fade progress
144+ val alpha = 1f - progress
145+
146+ // gradient brush
147+ val brush = Brush .linearGradient(
148+ colors = colors,
149+ start = Offset (posX - particle.size * 0.8f , posY- particle.size * 0.8f ),
150+ end = Offset (posX + particle.size* 0.8f , posY + particle.size * 0.8f )
151+ )
152+
153+ when (particle.shape) {
154+ ConfettiShape .CIRCLE -> {
155+ drawCircle(
156+ brush = brush,
157+ radius = particle.size.toFloat(),
158+ center = Offset (posX, posY),
159+ alpha = alpha
160+ )
161+ }
162+
163+ ConfettiShape .RECTANGLE -> {
164+ withTransform({
165+ rotate(degrees = particle.rotation, pivot = Offset (posX, posY))
166+ }) {
167+ drawRoundRect(
168+ brush = brush,
169+ topLeft = Offset (
170+ posX - (particle.size / 8f ),
171+ posY - (particle.size / 2f )
172+ ),
173+ size = Size (
174+ width = particle.size * 0.6f ,
175+ height = particle.size * 1.8f
176+ ),
177+ cornerRadius = CornerRadius (2f , 2f ),
178+ alpha = alpha
179+ )
180+ }
181+ }
182+ }
183+ }
184+ }
185+ }
0 commit comments