Skip to content

Commit 26b0257

Browse files
Merge pull request #102 from cuappdev/melissa/confetti-ui
Added ConfettiBurst to CheckInPopUp feature
2 parents 4382061 + 6084e8f commit 26b0257

File tree

6 files changed

+322
-23
lines changed

6 files changed

+322
-23
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.cornellappdev.uplift.data.repositories
2+
3+
import androidx.compose.ui.geometry.Rect
4+
import com.cornellappdev.uplift.ui.viewmodels.profile.ConfettiViewModel
5+
import com.cornellappdev.uplift.util.UIEvent
6+
import kotlinx.coroutines.flow.MutableStateFlow
7+
import kotlinx.coroutines.flow.asStateFlow
8+
import javax.inject.Inject
9+
import javax.inject.Singleton
10+
11+
//Source: Resell
12+
13+
@Singleton
14+
class ConfettiRepository @Inject constructor() {
15+
private val _showConfettiEvent: MutableStateFlow<UIEvent<ConfettiViewModel.ConfettiUiState>?> =
16+
MutableStateFlow(null)
17+
val showConfettiEvent = _showConfettiEvent.asStateFlow()
18+
19+
fun showConfetti (event: ConfettiViewModel.ConfettiUiState) {
20+
_showConfettiEvent.value = UIEvent(event)
21+
}
22+
23+
private val _confettiBounds = MutableStateFlow<Rect?>(null)
24+
val confettiBounds = _confettiBounds.asStateFlow()
25+
26+
fun setConfettiBounds(bounds: Rect?) {
27+
_confettiBounds.value = bounds
28+
}
29+
}

app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ import androidx.compose.material.Text
1313
import androidx.compose.runtime.Composable
1414
import androidx.compose.runtime.LaunchedEffect
1515
import androidx.compose.runtime.getValue
16+
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.setValue
1619
import androidx.compose.ui.Alignment
1720
import androidx.compose.ui.Modifier
21+
import androidx.compose.ui.geometry.Rect
1822
import androidx.compose.ui.graphics.Color
19-
import androidx.compose.ui.platform.LocalContext
23+
import androidx.compose.ui.layout.boundsInRoot
24+
import androidx.compose.ui.layout.onGloballyPositioned
2025
import androidx.compose.ui.res.painterResource
2126
import androidx.compose.ui.text.font.FontWeight
2227
import androidx.compose.ui.text.style.TextAlign
@@ -31,6 +36,7 @@ import androidx.navigation.compose.composable
3136
import androidx.navigation.compose.currentBackStackEntryAsState
3237
import androidx.navigation.compose.rememberNavController
3338
import com.cornellappdev.uplift.ui.components.general.CheckInPopUp
39+
import com.cornellappdev.uplift.ui.components.general.ConfettiBurst
3440
import com.cornellappdev.uplift.ui.nav.BottomNavScreens
3541
import com.cornellappdev.uplift.ui.nav.popBackClass
3642
import com.cornellappdev.uplift.ui.nav.popBackGym
@@ -51,6 +57,7 @@ import com.cornellappdev.uplift.ui.viewmodels.gyms.GymDetailViewModel
5157
import com.cornellappdev.uplift.ui.viewmodels.nav.RootNavigationViewModel
5258
import com.cornellappdev.uplift.ui.viewmodels.profile.CheckInMode
5359
import com.cornellappdev.uplift.ui.viewmodels.profile.CheckInViewModel
60+
import com.cornellappdev.uplift.ui.viewmodels.profile.ConfettiViewModel
5461
import com.cornellappdev.uplift.util.PRIMARY_BLACK
5562
import com.cornellappdev.uplift.util.PRIMARY_YELLOW
5663
import com.cornellappdev.uplift.util.montserratFamily
@@ -74,6 +81,7 @@ fun MainNavigationWrapper(
7481

7582
) {
7683

84+
val confettiViewModel: ConfettiViewModel = hiltViewModel()
7785
val checkInViewModel: CheckInViewModel = hiltViewModel()
7886
val rootNavigationUiState = rootNavigationViewModel.collectUiStateValue()
7987
val startDestination = rootNavigationUiState.startDestination
@@ -82,6 +90,7 @@ fun MainNavigationWrapper(
8290
val systemUiController: SystemUiController = rememberSystemUiController()
8391

8492
val checkInUiState = checkInViewModel.collectUiStateValue()
93+
val confettiUiState = confettiViewModel.collectUiStateValue()
8594

8695
val yourShimmerTheme = defaultShimmerTheme.copy(
8796
shaderColors = listOf(
@@ -252,24 +261,37 @@ fun MainNavigationWrapper(
252261
composable<UpliftRootRoute.Favorites> {}
253262
}
254263

255-
AnimatedVisibility(
256-
visible = checkInUiState.showPopUp && isMainScreen(),
257-
modifier = Modifier
258-
.align(Alignment.BottomCenter)
259-
.fillMaxWidth()
260-
.padding(
261-
start = 10.dp,
262-
end = 9.dp,
263-
bottom = it.calculateBottomPadding() + 13.dp
264-
)
265-
){
266-
CheckInPopUp(
267-
gymName = checkInUiState.gymName,
268-
currentTimeText = checkInUiState.timeText,
269-
onDismiss = { checkInViewModel.onDismiss() },
270-
onCheckIn = { checkInViewModel.onCheckIn() },
271-
onClosePopUp = { checkInViewModel.onClose() },
272-
mode = checkInUiState.mode,
264+
Box(modifier = Modifier.fillMaxSize()){
265+
AnimatedVisibility(
266+
visible = checkInUiState.showPopUp && isMainScreen(),
267+
modifier = Modifier
268+
.align(Alignment.BottomCenter)
269+
.fillMaxWidth()
270+
.padding(
271+
start = 10.dp,
272+
end = 9.dp,
273+
bottom = it.calculateBottomPadding() + 13.dp
274+
)
275+
){
276+
Box(
277+
modifier = Modifier.onGloballyPositioned { coords ->
278+
confettiViewModel.setConfettiBounds(coords.boundsInRoot())
279+
}
280+
){
281+
CheckInPopUp(
282+
gymName = checkInUiState.gymName,
283+
currentTimeText = checkInUiState.timeText,
284+
onDismiss = checkInViewModel::onDismiss,
285+
onCheckIn = checkInViewModel::onCheckIn,
286+
onClosePopUp = checkInViewModel::onClose,
287+
mode = checkInUiState.mode,
288+
)
289+
}
290+
}
291+
ConfettiBurst(
292+
confettiViewModel = confettiViewModel,
293+
particleSpawningBounds = confettiUiState.confettiBounds,
294+
modifier = Modifier
273295
)
274296
}
275297

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
}

app/src/main/java/com/cornellappdev/uplift/ui/theme/Color.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,11 @@ object AppColors {
1414
val LightYellow = Color(0xFFFCF5A4)
1515
val TextPrimary = Color(0xFF1B1F23)
1616
val Gray01 = Color(0xFFE5ECED)
17-
}
17+
}
18+
19+
object ConfettiColors{
20+
val Yellow1 = Color(0xFFFFF176)
21+
val Yellow2 = Color(0xFFFFEB3B)
22+
val Yellow3 = Color(0xFFFFD54F)
23+
val Yellow4 = Color(0xFFFFF59D)
24+
}

app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/profile/CheckInViewModel.kt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import android.util.Log
55
import androidx.lifecycle.viewModelScope
66
import com.cornellappdev.uplift.data.repositories.CheckInRepository
7+
import com.cornellappdev.uplift.data.repositories.ConfettiRepository
78
import com.cornellappdev.uplift.data.repositories.LocationRepository
89
import com.cornellappdev.uplift.ui.viewmodels.UpliftViewModel
910
import com.cornellappdev.uplift.util.isOpen
@@ -37,14 +38,14 @@ data class CheckInUiState(
3738
val mode: CheckInMode = CheckInMode.Prompt,
3839
val gymId: String? = "",
3940
val gymName: String = "",
40-
val timeText: String = "",
41-
val showConfetti: Boolean = false
41+
val timeText: String = ""
4242
)
4343

4444
/** A [CheckInViewModel] manages state and actions for the Check-In pop-up on main screens. */
4545
@HiltViewModel
4646
class CheckInViewModel @Inject constructor(
4747
private val checkInRepository: CheckInRepository,
48+
private val confettiRepository: ConfettiRepository
4849
) : UpliftViewModel<CheckInUiState>(CheckInUiState()) {
4950

5051
private var locationJob: Job? = null
@@ -127,7 +128,7 @@ class CheckInViewModel @Inject constructor(
127128
/**
128129
* Marks the user as checked in for the day, triggering a cooldown til the end of day and a
129130
* logworkout mutation through [checkInRepository]. On a successful call, transitions UI into
130-
* [CheckInMode.Complete].
131+
* [CheckInMode.Complete] and bursts confetti from popup through a [confettiRepository].
131132
*
132133
* Note: Temporarily skips over failed backend log workout call to keep functionality while auth and
133134
* sign in are not working.
@@ -148,6 +149,7 @@ class CheckInViewModel @Inject constructor(
148149
mode = CheckInMode.Complete
149150
)
150151
}
152+
confettiRepository.showConfetti(ConfettiViewModel.ConfettiUiState())
151153
checkInRepository.logWorkoutFromCheckIn(gymIdInt)
152154
} catch (e: Exception) {
153155
Log.e(tag, "Error checking in", e)

0 commit comments

Comments
 (0)