diff --git a/Confetti/build.gradle b/Confetti/build.gradle
new file mode 100644
index 00000000..d1db8c07
--- /dev/null
+++ b/Confetti/build.gradle
@@ -0,0 +1,22 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.jetbrains.kotlin.android)
+}
+
+android {
+
+ namespace 'com.infomaniak.lib.confetti'
+ compileSdk rootProject.ext.coreTargetSdk
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.coreMinSdk
+ targetSdkVersion rootProject.ext.coreTargetSdk
+ }
+
+ compileOptions {
+ sourceCompatibility rootProject.ext.javaVersion
+ targetCompatibility rootProject.ext.javaVersion
+ }
+
+ kotlinOptions { jvmTarget = rootProject.ext.javaVersion }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt
new file mode 100644
index 00000000..ea433e97
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/CommonConfetti.kt
@@ -0,0 +1,172 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+import android.content.res.Resources
+import android.graphics.Rect
+import android.view.ViewGroup
+import com.infomaniak.lib.confetti.Utils.defaultAlphaInterpolator
+import com.infomaniak.lib.confetti.Utils.generateConfettiBitmaps
+import com.infomaniak.lib.confetti.confetto.BitmapConfetto
+import com.infomaniak.lib.confetti.confetto.Confetto
+import java.util.Random
+
+class CommonConfetti private constructor(container: ViewGroup) {
+
+ lateinit var confettiManager: ConfettiManager
+ private set
+
+ init {
+ ensureStaticResources(container.resources)
+ }
+
+ /**
+ * Starts a one-shot animation that emits all of the confetti at once.
+ *
+ * @return the resulting [ConfettiManager] that's performing the animation.
+ */
+ fun oneShot(): ConfettiManager = confettiManager!!
+ .setNumInitialCount(100)
+ .setEmissionDuration(0L)
+ .animate(false)
+
+ /**
+ * Starts a stream of confetti that animates for the provided duration.
+ *
+ * @param durationInMillis how long to animate the confetti for.
+ * @return the resulting [ConfettiManager] that's performing the animation.
+ */
+ fun stream(durationInMillis: Long): ConfettiManager = confettiManager!!
+ .setNumInitialCount(0)
+ .setEmissionDuration(durationInMillis)
+ .setEmissionRate(50.0f)
+ .animate(false)
+
+ /**
+ * Starts an infinite stream of confetti.
+ *
+ * @return the resulting [ConfettiManager] that's performing the animation.
+ */
+ fun infinite(): ConfettiManager = confettiManager!!
+ .setNumInitialCount(0)
+ .setEmissionDuration(ConfettiManager.INFINITE_DURATION)
+ .setEmissionRate(50.0f)
+ .animate(false)
+
+ private fun getDefaultGenerator(colors: IntArray): ConfettoGenerator {
+ val bitmaps = generateConfettiBitmaps(colors, defaultConfettiSize)
+ val numBitmaps = bitmaps.size
+ return object : ConfettoGenerator {
+ override fun generateConfetto(random: Random): Confetto = BitmapConfetto(bitmaps[random.nextInt(numBitmaps)])
+ }
+ }
+
+ private fun configureRainingConfetti(container: ViewGroup, confettiSource: ConfettiSource, colors: IntArray) {
+ val generator = getDefaultGenerator(colors)
+ confettiManager = ConfettiManager(container.context, generator, confettiSource, container)
+ .setVelocityX(0.0f, defaultVelocitySlow.toFloat())
+ .setVelocityY(defaultVelocityNormal.toFloat(), defaultVelocitySlow.toFloat())
+ .setInitialRotation(180, 180)
+ .setRotationalAcceleration(360.0f, 180.0f)
+ .setTargetRotationalVelocity(360.0f)
+ }
+
+ private fun configureExplosion(container: ViewGroup, x: Int, y: Int, colors: IntArray) {
+ val generator = getDefaultGenerator(colors)
+ val confettiSource = ConfettiSource(x, y)
+ confettiManager = ConfettiManager(container.context, generator, confettiSource, container)
+ .setTTL(1_000L)
+ .setBound(Rect(x - explosionRadius, y - explosionRadius, x + explosionRadius, y + explosionRadius))
+ .setVelocityX(0.0f, defaultVelocityFast.toFloat())
+ .setVelocityY(0.0f, defaultVelocityFast.toFloat())
+ .enableFadeOut(defaultAlphaInterpolator)
+ .setInitialRotation(180, 180)
+ .setRotationalAcceleration(360.0f, 180.0f)
+ .setTargetRotationalVelocity(360.0f)
+ }
+
+ companion object {
+
+ private var defaultConfettiSize = 0
+ private var defaultVelocitySlow = 0
+ private var defaultVelocityNormal = 0
+ private var defaultVelocityFast = 0
+ private var explosionRadius = 0
+
+ // region Pre-configured confetti animations
+ /**
+ * @param container the container viewgroup to host the confetti animation.
+ * @param colors the set of colors to colorize the confetti bitmaps.
+ * @return the created common confetti object.
+ * @see .rainingConfetti
+ */
+ fun rainingConfetti(
+ container: ViewGroup,
+ colors: IntArray,
+ ) = CommonConfetti(container).apply {
+ val confettiSource = ConfettiSource(0, -defaultConfettiSize, container.width, -defaultConfettiSize)
+ configureRainingConfetti(container, confettiSource, colors)
+ }
+
+ /**
+ * Configures a confetti manager that has confetti falling from the provided confetti source.
+ *
+ * @param container the container viewgroup to host the confetti animation.
+ * @param confettiSource the source of the confetti animation.
+ * @param colors the set of colors to colorize the confetti bitmaps.
+ * @return the created common confetti object.
+ */
+ fun rainingConfetti(
+ container: ViewGroup,
+ confettiSource: ConfettiSource,
+ colors: IntArray,
+ ) = CommonConfetti(container).apply {
+ configureRainingConfetti(container, confettiSource, colors)
+ }
+
+ /**
+ * Configures a confetti manager that has confetti exploding out in all directions from the
+ * provided x and y coordinates.
+ *
+ * @param container the container viewgroup to host the confetti animation.
+ * @param x the x coordinate of the explosion source.
+ * @param y the y coordinate of the explosion source.
+ * @param colors the set of colors to colorize the confetti bitmaps.
+ * @return the created common confetti object.
+ */
+ fun explosion(
+ container: ViewGroup,
+ x: Int,
+ y: Int,
+ colors: IntArray,
+ ) = CommonConfetti(container).apply {
+ configureExplosion(container, x, y, colors)
+ }
+ // endregion
+
+ private fun ensureStaticResources(resources: Resources) = with(resources) {
+ if (defaultConfettiSize == 0) {
+ defaultConfettiSize = getDimensionPixelSize(R.dimen.confetti_size)
+ defaultVelocitySlow = getDimensionPixelOffset(R.dimen.confetti_velocity_slow)
+ defaultVelocityNormal = getDimensionPixelOffset(R.dimen.confetti_velocity_normal)
+ defaultVelocityFast = getDimensionPixelOffset(R.dimen.confetti_velocity_fast)
+ explosionRadius = getDimensionPixelOffset(R.dimen.confetti_explosion_radius)
+ }
+ }
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt
new file mode 100644
index 00000000..4aefa0c4
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiManager.kt
@@ -0,0 +1,598 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.Rect
+import android.view.View
+import android.view.View.OnAttachStateChangeListener
+import android.view.ViewGroup
+import android.view.animation.Interpolator
+import com.infomaniak.lib.confetti.confetto.Confetto
+import java.util.LinkedList
+import java.util.Queue
+import java.util.Random
+import kotlin.math.roundToInt
+
+/**
+ * A helper manager class for configuring a set of confetti and displaying them on the UI.
+ */
+class ConfettiManager(
+ private val confettoGenerator: ConfettoGenerator,
+ private val confettiSource: ConfettiSource,
+ private val parentView: ViewGroup,
+ private val confettiView: ConfettiView,
+) {
+
+ private val random = Random()
+ private val recycledConfetti: Queue = LinkedList()
+ private val confetti: MutableList = ArrayList(300)
+ private var animator: ValueAnimator? = null
+ private var lastEmittedTimestamp: Long = 0
+ // All of the below configured values are in milliseconds despite the setter methods take them
+ // in seconds as the parameters. The parameters for the setters are in seconds to allow for
+ // users to better understand/visualize the dimensions.
+ // Configured attributes for the entire confetti group
+ private var numInitialCount = 0
+ private var emissionDuration: Long = 0
+ private var emissionRate = 0f
+ private var emissionRateInverse = 0f
+ private var fadeOutInterpolator: Interpolator? = null
+ private var bound: Rect
+ // Configured attributes for each confetto
+ private var velocityX = 0f
+ private var velocityDeviationX = 0f
+ private var velocityY = 0f
+ private var velocityDeviationY = 0f
+ private var accelerationX = 0f
+ private var accelerationDeviationX = 0f
+ private var accelerationY = 0f
+ private var accelerationDeviationY = 0f
+ private var targetVelocityX: Float? = null
+ private var targetVelocityXDeviation: Float? = null
+ private var targetVelocityY: Float? = null
+ private var targetVelocityYDeviation: Float? = null
+ private var initialRotation = 0
+ private var initialRotationDeviation = 0
+ private var rotationalVelocity = 0f
+ private var rotationalVelocityDeviation = 0f
+ private var rotationalAcceleration = 0f
+ private var rotationalAccelerationDeviation = 0f
+ private var targetRotationalVelocity: Float? = null
+ private var targetRotationalVelocityDeviation: Float? = null
+ private var ttl: Long
+ private var animationListener: ConfettiAnimationListener? = null
+
+ constructor(
+ context: Context,
+ confettoGenerator: ConfettoGenerator,
+ confettiSource: ConfettiSource,
+ parentView: ViewGroup,
+ ) : this(confettoGenerator, confettiSource, parentView, ConfettiView(context))
+
+ init {
+
+ confettiView.apply {
+ bind(confetti)
+ addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) {
+ // No-op
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ terminate()
+ }
+ })
+ }
+
+ // Set the defaults
+ ttl = -1
+ bound = Rect(0, 0, parentView.width, parentView.height)
+ }
+
+ /**
+ * The number of confetti initially emitted before any time has elapsed.
+ *
+ * @param numInitialCount the number of initial confetti.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setNumInitialCount(numInitialCount: Int) = apply {
+ this.numInitialCount = numInitialCount
+ }
+
+ /**
+ * Configures how long this manager will emit new confetti after the animation starts.
+ *
+ * @param emissionDurationInMillis how long to emit new confetti in millis. This value can be
+ * [.INFINITE_DURATION] for a never-ending emission.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setEmissionDuration(emissionDurationInMillis: Long) = apply {
+ emissionDuration = emissionDurationInMillis
+ }
+
+ /**
+ * Configures how frequently this manager will emit new confetti after the animation starts
+ * if [.emissionDuration] is a positive value.
+ *
+ * @param emissionRate the rate of emission in # of confetti per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setEmissionRate(emissionRate: Float) = apply {
+ this.emissionRate = emissionRate / 1_000.0f
+ emissionRateInverse = 1.0f / this.emissionRate
+ }
+
+ /**
+ * @param velocityX the X velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setVelocityX
+ */
+ fun setVelocityX(velocityX: Float) = setVelocityX(velocityX, velocityDeviationX = 0.0f)
+
+ /**
+ * Set the velocityX used by this manager. This value defines the initial X velocity
+ * for the generated confetti. The actual confetti's X velocity will be
+ * (velocityX +- [0, velocityDeviationX]).
+ *
+ * @param velocityX the X velocity in pixels per second.
+ * @param velocityDeviationX the deviation from X velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setVelocityX(velocityX: Float, velocityDeviationX: Float) = apply {
+ this.velocityX = velocityX / 1_000.0f
+ this.velocityDeviationX = velocityDeviationX / 1_000.0f
+ }
+
+ /**
+ * @param velocityY the Y velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setVelocityY
+ */
+ fun setVelocityY(velocityY: Float) = setVelocityY(velocityY, velocityDeviationY = 0.0f)
+
+ /**
+ * Set the velocityY used by this manager. This value defines the initial Y velocity
+ * for the generated confetti. The actual confetti's Y velocity will be
+ * (velocityY +- [0, velocityDeviationY]). A positive Y velocity means that the velocity
+ * is going down (because Y coordinate increases going down).
+ *
+ * @param velocityY the Y velocity in pixels per second.
+ * @param velocityDeviationY the deviation from Y velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setVelocityY(velocityY: Float, velocityDeviationY: Float) = apply {
+ this.velocityY = velocityY / 1_000.0f
+ this.velocityDeviationY = velocityDeviationY / 1_000.0f
+ }
+
+ /**
+ * @param accelerationX the X acceleration in pixels per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setAccelerationX
+ */
+ fun setAccelerationX(accelerationX: Float) = setAccelerationX(accelerationX, accelerationDeviationX = 0.0f)
+
+ /**
+ * Set the accelerationX used by this manager. This value defines the X acceleration
+ * for the generated confetti. The actual confetti's X acceleration will be
+ * (accelerationX +- [0, accelerationDeviationX]).
+ *
+ * @param accelerationX the X acceleration in pixels per second^2.
+ * @param accelerationDeviationX the deviation from X acceleration in pixels per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setAccelerationX(accelerationX: Float, accelerationDeviationX: Float) = apply {
+ this.accelerationX = accelerationX / 1_000_000.0f
+ this.accelerationDeviationX = accelerationDeviationX / 1_000_000.0f
+ }
+
+ /**
+ * @param accelerationY the Y acceleration in pixels per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setAccelerationY
+ */
+ fun setAccelerationY(accelerationY: Float) = setAccelerationY(accelerationY, accelerationDeviationY = 0.0f)
+
+ /**
+ * Set the accelerationY used by this manager. This value defines the Y acceleration
+ * for the generated confetti. The actual confetti's Y acceleration will be
+ * (accelerationY +- [0, accelerationDeviationY]). A positive Y acceleration means that the
+ * confetto will be accelerating downwards.
+ *
+ * @param accelerationY the Y acceleration in pixels per second^2.
+ * @param accelerationDeviationY the deviation from Y acceleration in pixels per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setAccelerationY(accelerationY: Float, accelerationDeviationY: Float) = apply {
+ this.accelerationY = accelerationY / 1_000_000.0f
+ this.accelerationDeviationY = accelerationDeviationY / 1_000_000.0f
+ }
+
+ /**
+ * @param targetVelocityX the target X velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setTargetVelocityX
+ */
+ fun setTargetVelocityX(targetVelocityX: Float) = setTargetVelocityX(targetVelocityX, targetVelocityXDeviation = 0.0f)
+
+ /**
+ * Set the target X velocity that confetti can reach during the animation. The actual confetti's
+ * target X velocity will be (targetVelocityX +- [0, targetVelocityXDeviation]).
+ *
+ * @param targetVelocityX the target X velocity in pixels per second.
+ * @param targetVelocityXDeviation the deviation from target X velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setTargetVelocityX(targetVelocityX: Float, targetVelocityXDeviation: Float) = apply {
+ this.targetVelocityX = targetVelocityX / 1_000.0f
+ this.targetVelocityXDeviation = targetVelocityXDeviation / 1_000.0f
+ }
+
+ /**
+ * @param targetVelocityY the target Y velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setTargetVelocityY
+ */
+ fun setTargetVelocityY(targetVelocityY: Float) = setTargetVelocityY(targetVelocityY, targetVelocityYDeviation = 0.0f)
+
+ /**
+ * Set the target Y velocity that confetti can reach during the animation. The actual confetti's
+ * target Y velocity will be (targetVelocityY +- [0, targetVelocityYDeviation]).
+ *
+ * @param targetVelocityY the target Y velocity in pixels per second.
+ * @param targetVelocityYDeviation the deviation from target Y velocity in pixels per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setTargetVelocityY(targetVelocityY: Float, targetVelocityYDeviation: Float) = apply {
+ this.targetVelocityY = targetVelocityY / 1_000.0f
+ this.targetVelocityYDeviation = targetVelocityYDeviation / 1_000.0f
+ }
+
+ /**
+ * @param initialRotation the initial rotation in degrees.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setInitialRotation
+ */
+ fun setInitialRotation(initialRotation: Int) = setInitialRotation(initialRotation, initialRotationDeviation = 0)
+
+ /**
+ * Set the initialRotation used by this manager. This value defines the initial rotation in
+ * degrees for the generated confetti. The actual confetti's initial rotation will be
+ * (initialRotation +- [0, initialRotationDeviation]).
+ *
+ * @param initialRotation the initial rotation in degrees.
+ * @param initialRotationDeviation the deviation from initial rotation in degrees.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setInitialRotation(initialRotation: Int, initialRotationDeviation: Int) = apply {
+ this.initialRotation = initialRotation
+ this.initialRotationDeviation = initialRotationDeviation
+ }
+
+ /**
+ * @param rotationalVelocity the initial rotational velocity in degrees per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setRotationalVelocity
+ */
+ fun setRotationalVelocity(rotationalVelocity: Float) =
+ setRotationalVelocity(rotationalVelocity, rotationalVelocityDeviation = 0.0f)
+
+ /**
+ * Set the rotationalVelocity used by this manager. This value defines the the initial
+ * rotational velocity for the generated confetti. The actual confetti's initial
+ * rotational velocity will be (rotationalVelocity +- [0, rotationalVelocityDeviation]).
+ *
+ * @param rotationalVelocity the initial rotational velocity in degrees per second.
+ * @param rotationalVelocityDeviation the deviation from initial rotational velocity in
+ * degrees per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setRotationalVelocity(rotationalVelocity: Float, rotationalVelocityDeviation: Float) = apply {
+ this.rotationalVelocity = rotationalVelocity / 1_000.0f
+ this.rotationalVelocityDeviation = rotationalVelocityDeviation / 1_000.0f
+ }
+
+ /**
+ * @param rotationalAcceleration the rotational acceleration in degrees per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setRotationalAcceleration
+ */
+ fun setRotationalAcceleration(rotationalAcceleration: Float) =
+ setRotationalAcceleration(rotationalAcceleration, rotationalAccelerationDeviation = 0.0f)
+
+ /**
+ * Set the rotationalAcceleration used by this manager. This value defines the the
+ * acceleration of the rotation for the generated confetti. The actual confetti's rotational
+ * acceleration will be (rotationalAcceleration +- [0, rotationalAccelerationDeviation]).
+ *
+ * @param rotationalAcceleration the rotational acceleration in degrees per second^2.
+ * @param rotationalAccelerationDeviation the deviation from rotational acceleration in degrees
+ * per second^2.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setRotationalAcceleration(rotationalAcceleration: Float, rotationalAccelerationDeviation: Float) = apply {
+ this.rotationalAcceleration = rotationalAcceleration / 1_000_000.0f
+ this.rotationalAccelerationDeviation = rotationalAccelerationDeviation / 1_000_000.0f
+ }
+
+ /**
+ * @param targetRotationalVelocity the target rotational velocity in degrees per second.
+ * @return the confetti manager so that the set calls can be chained.
+ * @see .setTargetRotationalVelocity
+ */
+ fun setTargetRotationalVelocity(targetRotationalVelocity: Float) =
+ setTargetRotationalVelocity(targetRotationalVelocity, targetRotationalVelocityDeviation = 0.0f)
+
+ /**
+ * Set the target rotational velocity that confetti can reach during the animation. The actual
+ * confetti's target rotational velocity will be
+ * (targetRotationalVelocity +- [0, targetRotationalVelocityDeviation]).
+ *
+ * @param targetRotationalVelocity the target rotational velocity in degrees per second.
+ * @param targetRotationalVelocityDeviation the deviation from target rotational velocity
+ * in degrees per second.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setTargetRotationalVelocity(targetRotationalVelocity: Float, targetRotationalVelocityDeviation: Float) = apply {
+ this.targetRotationalVelocity = targetRotationalVelocity / 1_000.0f
+ this.targetRotationalVelocityDeviation = targetRotationalVelocityDeviation / 1_000.0f
+ }
+
+ /**
+ * Specifies a custom bound that the confetti will clip to. By default, the confetti will be
+ * able to animate throughout the entire screen. The dimensions specified in bound is
+ * global dimensions, e.g. x=0 is the top of the screen, rather than relative dimensions.
+ *
+ * @param bound the bound that clips the confetti as they animate.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setBound(bound: Rect) = apply {
+ this.bound = bound
+ }
+
+ /**
+ * Specifies a custom time to live for the confetti generated by this manager. When a confetti
+ * reaches its time to live timer, it will disappear and terminate its animation.
+ *
+ *
+ * The time to live value does not include the initial delay of the confetti.
+ *
+ * @param ttlInMillis the custom time to live in milliseconds.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setTTL(ttlInMillis: Long) = apply {
+ ttl = ttlInMillis
+ }
+
+ /**
+ * Enables fade out for all of the confetti generated by this manager. Fade out means that
+ * the confetti will animate alpha according to the fadeOutInterpolator according
+ * to its TTL or, if TTL is not set, its bounds.
+ *
+ * @param fadeOutInterpolator an interpolator that interpolates animation progress [0, 1] into
+ * an alpha value [0, 1], 0 being transparent and 1 being opaque.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun enableFadeOut(fadeOutInterpolator: Interpolator?) = apply {
+ this.fadeOutInterpolator = fadeOutInterpolator
+ }
+
+ /**
+ * Disables fade out for all of the confetti generated by this manager.
+ *
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun disableFadeOut() = apply {
+ fadeOutInterpolator = null
+ }
+
+ /**
+ * Enables or disables touch events for the confetti generated by this manager. By enabling
+ * touch, the user can touch individual confetto and drag/fling them on the screen independent
+ * of their original animation state.
+ *
+ * @param touchEnabled whether or not to enable touch.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setTouchEnabled(touchEnabled: Boolean) = apply {
+ confettiView.setTouchEnabled(touchEnabled)
+ }
+
+ /**
+ * Sets a [ConfettiAnimationListener] for this confetti manager.
+ *
+ * @param listener the animation listener, or null to clear out the existing listener.
+ * @return the confetti manager so that the set calls can be chained.
+ */
+ fun setConfettiAnimationListener(listener: ConfettiAnimationListener?) = apply {
+ animationListener = listener
+ }
+
+ /**
+ * Start the confetti animation configured by this manager.
+ *
+ * @return the confetti manager itself that just started animating.
+ */
+ fun animate(useGaussian: Boolean) = apply {
+ if (animationListener != null) animationListener!!.onAnimationStart(this)
+ cleanupExistingAnimation()
+ attachConfettiViewToParent()
+ addNewConfetti(numInitialCount, initialDelay = 0L, useGaussian)
+ startNewAnimation(useGaussian)
+ }
+
+ /**
+ * Terminate the currently running animation if there is any.
+ */
+ fun terminate() {
+ animator?.cancel()
+ confettiView.terminate()
+ animationListener?.onAnimationEnd(this)
+ }
+
+ private fun cleanupExistingAnimation() {
+ animator?.cancel()
+ lastEmittedTimestamp = 0
+ val iterator = confetti.iterator()
+ while (iterator.hasNext()) {
+ removeConfetto(iterator.next())
+ iterator.remove()
+ }
+ }
+
+ private fun attachConfettiViewToParent() {
+ val currentParent = confettiView.parent
+ if (currentParent == null) {
+ parentView.addView(confettiView)
+ } else if (currentParent !== parentView) {
+ (currentParent as ViewGroup).removeView(confettiView)
+ parentView.addView(confettiView)
+ }
+ confettiView.reset()
+ }
+
+ private fun addNewConfetti(numConfetti: Int, initialDelay: Long, useGaussian: Boolean) {
+ for (i in 0 until numConfetti) {
+ var confetto = recycledConfetti.poll()
+ if (confetto == null) confetto = confettoGenerator.generateConfetto(random)
+ confetto.reset()
+ confetto.configure(confettiSource, random, initialDelay, useGaussian)
+ confetto.prepare(bound)
+ addConfetto(confetto)
+ }
+ }
+
+ private fun startNewAnimation(useGaussian: Boolean) {
+ // Never-ending animator, we will cancel once the termination condition is reached.
+ animator = ValueAnimator.ofInt(0).setDuration(Long.MAX_VALUE).also {
+ it.addUpdateListener { valueAnimator: ValueAnimator ->
+ val elapsedTime = valueAnimator.currentPlayTime
+ processNewEmission(elapsedTime, useGaussian)
+ updateConfetti(elapsedTime)
+ if (confetti.size == 0 && elapsedTime >= emissionDuration) {
+ terminate()
+ } else {
+ confettiView.invalidate()
+ }
+ }
+ it.start()
+ }
+ }
+
+ private fun processNewEmission(elapsedTime: Long, useGaussian: Boolean) {
+ if (elapsedTime < emissionDuration) {
+ if (lastEmittedTimestamp == 0L) {
+ lastEmittedTimestamp = elapsedTime
+ } else {
+ val timeSinceLastEmission = elapsedTime - lastEmittedTimestamp
+ // Randomly determine how many confetti to emit
+ val numNewConfetti = (random.nextFloat() * emissionRate * timeSinceLastEmission).toInt()
+ if (numNewConfetti > 0) {
+ lastEmittedTimestamp += (emissionRateInverse * numNewConfetti).roundToInt().toLong()
+ addNewConfetti(numNewConfetti, elapsedTime, useGaussian)
+ }
+ }
+ }
+ }
+
+ private fun updateConfetti(elapsedTime: Long) {
+ val iterator = confetti.iterator()
+ while (iterator.hasNext()) {
+ val confetto = iterator.next()
+ if (!confetto.applyUpdate(elapsedTime)) {
+ iterator.remove()
+ removeConfetto(confetto)
+ }
+ }
+ }
+
+ private fun addConfetto(confetto: Confetto) {
+ confetti.add(confetto)
+ animationListener?.onConfettoEnter(confetto)
+ }
+
+ private fun removeConfetto(confetto: Confetto) {
+ animationListener?.onConfettoExit(confetto)
+ recycledConfetti.add(confetto)
+ }
+
+ private fun Confetto.configure(
+ confettiSource: ConfettiSource,
+ random: Random,
+ initialDelay: Long,
+ useGaussian: Boolean,
+ ) {
+ setInitialDelay(initialDelay)
+ setInitialX(confettiSource.getInitialX(random.nextFloat()))
+ setInitialY(confettiSource.getInitialY(random.nextFloat()))
+ setInitialVelocityX(getVarianceAmount(velocityX, velocityDeviationX, random, useGaussian))
+ setInitialVelocityY(getVarianceAmount(velocityY, velocityDeviationY, random, useGaussian))
+ setAccelerationX(getVarianceAmount(accelerationX, accelerationDeviationX, random, useGaussian))
+ setAccelerationY(getVarianceAmount(accelerationY, accelerationDeviationY, random, useGaussian))
+ setTargetVelocityX(targetVelocityX?.let { getVarianceAmount(it, targetVelocityXDeviation!!, random, useGaussian) })
+ setTargetVelocityY(targetVelocityY?.let { getVarianceAmount(it, targetVelocityYDeviation!!, random, useGaussian) })
+ setInitialRotation(getVarianceAmount(initialRotation.toFloat(), initialRotationDeviation.toFloat(), random, useGaussian))
+ setInitialRotationalVelocity(getVarianceAmount(rotationalVelocity, rotationalVelocityDeviation, random, useGaussian))
+ setRotationalAcceleration(getVarianceAmount(rotationalAcceleration, rotationalAccelerationDeviation, random, useGaussian))
+ setTargetRotationalVelocity(
+ targetRotationalVelocity?.let { getVarianceAmount(it, targetRotationalVelocityDeviation!!, random, useGaussian) },
+ )
+ setTTL(ttl)
+ setFadeOut(fadeOutInterpolator)
+ }
+
+ private fun getVarianceAmount(base: Float, deviation: Float, random: Random, useGaussian: Boolean): Float {
+ // Normalize random to be [-1, 1] rather than [0, 1]
+ return if (useGaussian) {
+ base + deviation * (random.nextGaussian().toFloat() * 2 - 1)
+ } else {
+ base + deviation * (random.nextFloat() * 2 - 1)
+ }
+ }
+
+ interface ConfettiAnimationListener {
+ fun onAnimationStart(confettiManager: ConfettiManager?)
+ fun onAnimationEnd(confettiManager: ConfettiManager?)
+ fun onConfettoEnter(confetto: Confetto?)
+ fun onConfettoExit(confetto: Confetto?)
+ }
+
+ class ConfettiAnimationListenerAdapter : ConfettiAnimationListener {
+ override fun onAnimationStart(confettiManager: ConfettiManager?) {
+ // No-op
+ }
+
+ override fun onAnimationEnd(confettiManager: ConfettiManager?) {
+ // No-op
+ }
+
+ override fun onConfettoEnter(confetto: Confetto?) {
+ // No-op
+ }
+
+ override fun onConfettoExit(confetto: Confetto?) {
+ // No-op
+ }
+ }
+
+ companion object {
+ const val INFINITE_DURATION = Long.MAX_VALUE
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt
new file mode 100644
index 00000000..38b9cc01
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiSource.kt
@@ -0,0 +1,47 @@
+/*
+ * Infomaniak Mail - Android
+ * Copyright (C) 2023-2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+/**
+ * The source from which confetti will appear. This can be either a line or a point.
+ *
+ * Please note that the specified source represents the top left corner of the drawn
+ * confetti. If you want the confetti to appear from off-screen, you'll have to offset it
+ * with the confetti's size.
+ *
+ * Specifies a line source from which all confetti will emit from.
+ *
+ * @param x0 x-coordinate of the first point relative to the [ConfettiView]'s parent.
+ * @param y0 y-coordinate of the first point relative to the [ConfettiView]'s parent.
+ * @param x1 x-coordinate of the second point relative to the [ConfettiView]'s parent.
+ * @param y1 y-coordinate of the second point relative to the [ConfettiView]'s parent.
+ */
+class ConfettiSource(private val x0: Int, private val y0: Int, private val x1: Int, private val y1: Int) {
+
+ /**
+ * Specifies a point source from which all confetti will emit from.
+ *
+ * @param x x-coordinate of the point relative to the [ConfettiView]'s parent.
+ * @param y y-coordinate of the point relative to the [ConfettiView]'s parent.
+ */
+ constructor(x: Int, y: Int) : this(x, y, x, y)
+
+ fun getInitialX(random: Float): Float = x0 + (x1 - x0) * random
+
+ fun getInitialY(random: Float): Float = y0 + (y1 - y0) * random
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt
new file mode 100644
index 00000000..8b6db1e5
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettiView.kt
@@ -0,0 +1,144 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+import android.content.Context
+import android.graphics.Canvas
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import android.view.View.OnLayoutChangeListener
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams
+import com.infomaniak.lib.confetti.confetto.Confetto
+
+/**
+ * A helper temporary view that helps render the confetti. This view will attach itself to the
+ * view root, perform the animation, and then once all of the confetti has completed its animation,
+ * it will automatically remove itself from the parent.
+ */
+class ConfettiView(context: Context, attrs: AttributeSet? = null) : View(context, attrs), OnLayoutChangeListener {
+
+ private var confetti: List? = null
+ private var terminated = false
+ private var touchEnabled = false
+ private var draggedConfetto: Confetto? = null
+
+ init {
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ elevation = context.resources.getDimensionPixelOffset(R.dimen.confetti_elevation).toFloat()
+ }
+
+ /**
+ * Sets the list of confetti to be animated by this view.
+ *
+ * @param confetti the list of confetti to be animated.
+ */
+ fun bind(confetti: List?) {
+ this.confetti = confetti
+ }
+
+ /**
+ * @param touchEnabled whether or not to enable touch
+ * @see ConfettiManager.setTouchEnabled
+ */
+ fun setTouchEnabled(touchEnabled: Boolean) {
+ this.touchEnabled = touchEnabled
+ }
+
+ /**
+ * Terminate the current running animation (if any) and remove this view from the parent.
+ */
+ fun terminate() {
+ if (!terminated) {
+ terminated = true
+ parent.requestLayout()
+ }
+ }
+
+ /**
+ * Reset the internal state of this view to allow for a new confetti animation.
+ */
+ fun reset() {
+ terminated = false
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+
+ (parent as ViewGroup).apply {
+ removeOnLayoutChangeListener(this@ConfettiView)
+ addOnLayoutChangeListener(this@ConfettiView)
+ }
+
+ // If we did not bind before attaching to the window, that means this ConfettiView no longer
+ // has a ConfettiManager backing it and should just be terminated.
+ if (confetti == null) terminate()
+ }
+
+ override fun onLayoutChange(view: View, l: Int, t: Int, r: Int, b: Int, oldL: Int, oldT: Int, oldR: Int, oldB: Int) {
+ if (terminated) {
+ (parent as ViewGroup).apply {
+ removeViewInLayout(this@ConfettiView)
+ removeOnLayoutChangeListener(this@ConfettiView)
+ invalidate()
+ }
+ }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ if (!terminated) {
+ canvas.save()
+ for (confetto in confetti!!) confetto.draw(canvas)
+ canvas.restore()
+ }
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ var handled = false
+ if (!touchEnabled) return super.onTouchEvent(event)
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ for (confetto in confetti!!) {
+ if (confetto.onTouchDown(event)) {
+ draggedConfetto = confetto
+ handled = true
+ break
+ }
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (draggedConfetto != null) {
+ draggedConfetto!!.onTouchMove(event)
+ handled = true
+ }
+ }
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ if (draggedConfetto != null) {
+ draggedConfetto!!.onTouchUp(event)
+ draggedConfetto = null
+ handled = true
+ }
+ }
+ }
+
+ return handled || super.onTouchEvent(event)
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt
new file mode 100644
index 00000000..8ac6abc8
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/ConfettoGenerator.kt
@@ -0,0 +1,31 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+import com.infomaniak.lib.confetti.confetto.Confetto
+import java.util.Random
+
+interface ConfettoGenerator {
+ /**
+ * Generate a random confetto to animate.
+ *
+ * @param random a [Random] that can be used to generate random confetto.
+ * @return the randomly generated confetto.
+ */
+ fun generateConfetto(random: Random): Confetto
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt
new file mode 100644
index 00000000..b4f8993d
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/Utils.kt
@@ -0,0 +1,87 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import android.view.animation.Interpolator
+import kotlin.math.tan
+
+object Utils {
+
+ private val paint = Paint().apply { style = Paint.Style.FILL }
+
+ @JvmStatic
+ var defaultAlphaInterpolator: Interpolator? = null
+ get() {
+ if (field == null) field = Interpolator { v: Float -> if (v >= 0.9f) 1.0f - (v - 0.9f) * 10.0f else 1.0f }
+ return field
+ }
+ private set
+
+ @JvmStatic
+ fun generateConfettiBitmaps(colors: IntArray, size: Int): List = mutableListOf().apply {
+ for (color in colors) {
+ add(createCircleBitmap(color, size))
+ add(createSquareBitmap(color, size))
+ add(createTriangleBitmap(color, size))
+ }
+ }
+
+ fun createCircleBitmap(color: Int, size: Int): Bitmap {
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ val radius = size / 2.0f
+ paint.color = color
+ canvas.drawCircle(radius, radius, radius, paint)
+ return bitmap
+ }
+
+ fun createSquareBitmap(color: Int, size: Int): Bitmap {
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ val path = Path().apply {
+ moveTo(0.0f, 0.0f)
+ lineTo(size.toFloat(), 0.0f)
+ lineTo(size.toFloat(), size.toFloat())
+ lineTo(0.0f, size.toFloat())
+ close()
+ }
+ paint.color = color
+ canvas.drawPath(path, paint)
+ return bitmap
+ }
+
+ fun createTriangleBitmap(color: Int, size: Int): Bitmap {
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ // Generate equilateral triangle (http://mathworld.wolfram.com/EquilateralTriangle.html).
+ val path = Path().apply {
+ val point = tan(15.0f / 180.0f * Math.PI).toFloat() * size
+ moveTo(0.0f, 0.0f)
+ lineTo(size.toFloat(), point)
+ lineTo(point, size.toFloat())
+ close()
+ }
+ paint.color = color
+ canvas.drawPath(path, paint)
+ return bitmap
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt
new file mode 100644
index 00000000..de3eb632
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/BitmapConfetto.kt
@@ -0,0 +1,49 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti.confetto
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Paint
+
+open class BitmapConfetto(private val bitmap: Bitmap) : Confetto() {
+
+ private val bitmapCenterX: Float = bitmap.width / 2.0f
+ private val bitmapCenterY: Float = bitmap.height / 2.0f
+
+ override val width: Int
+ get() = bitmap.width
+
+ override val height: Int
+ get() = bitmap.height
+
+ override fun drawInternal(
+ canvas: Canvas,
+ matrix: Matrix,
+ paint: Paint,
+ x: Float,
+ y: Float,
+ rotation: Float,
+ percentAnimated: Float,
+ ) {
+ matrix.preTranslate(x, y)
+ matrix.preRotate(rotation, bitmapCenterX, bitmapCenterY)
+ canvas.drawBitmap(bitmap, matrix, paint)
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt
new file mode 100644
index 00000000..5f24fd23
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/CircleConfetto.kt
@@ -0,0 +1,54 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti.confetto
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Paint
+
+/**
+ * A lightly more optimal way to draw a circle shape that doesn't require the use of a bitmap.
+ */
+class CircleConfetto(private val color: Int, private val radius: Float) : Confetto() {
+
+ private val diameter: Int = (radius * 2.0f).toInt()
+
+ override val width: Int
+ get() = diameter
+
+ override val height: Int
+ get() = diameter
+
+ override fun configurePaint(paint: Paint) {
+ super.configurePaint(paint)
+ paint.style = Paint.Style.FILL
+ paint.color = color
+ }
+
+ override fun drawInternal(
+ canvas: Canvas,
+ matrix: Matrix,
+ paint: Paint,
+ x: Float,
+ y: Float,
+ rotation: Float,
+ percentAnimated: Float,
+ ) {
+ canvas.drawCircle(x + radius, y + radius, radius, paint)
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt
new file mode 100644
index 00000000..4c8db228
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/Confetto.kt
@@ -0,0 +1,491 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti.confetto
+
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Rect
+import android.view.MotionEvent
+import android.view.VelocityTracker
+import android.view.animation.Interpolator
+import kotlin.math.sqrt
+
+/**
+ * Abstract class that represents a single confetto on the screen. This class holds all of the
+ * internal states for the confetto to help it animate.
+ *
+ * All of the configured states are in milliseconds, e.g. pixels per millisecond for velocity.
+ */
+abstract class Confetto {
+
+ private val matrix = Matrix()
+ private val workPaint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val workPairs = FloatArray(2)
+ private var currentVelocityX = 0.0f
+ private var currentVelocityY = 0.0f
+ private var currentRotationalVelocity = 0.0f
+
+ // Configured coordinate states
+ private var bound: Rect? = null
+ private var initialDelay = 0L
+ private var initialX = 0.0f
+ private var initialY = 0.0f
+ private var initialVelocityX = 0.0f
+ private var initialVelocityY = 0.0f
+ private var accelerationX = 0.0f
+ private var accelerationY = 0.0f
+ private var targetVelocityX: Float? = null
+ private var targetVelocityY: Float? = null
+ private var millisToReachTargetVelocityX: Long? = null
+ private var millisToReachTargetVelocityY: Long? = null
+
+ // Configured rotation states
+ private var initialRotation = 0.0f
+ private var initialRotationalVelocity = 0.0f
+ private var rotationalAcceleration = 0.0f
+ private var targetRotationalVelocity: Float? = null
+ private var millisToReachTargetRotationalVelocity: Long? = null
+
+ // Configured animation states
+ private var ttl = 0L
+ private var fadeOutInterpolator: Interpolator? = null
+ private var millisToReachBound = 0.0f
+ private var percentageAnimated = 0.0f
+
+ // Current draw states
+ private var currentX = 0.0f
+ private var currentY = 0.0f
+ private var currentRotation = 0.0f
+ private var alpha = 0 // alpha is [0, 255]
+ private var startedAnimation = false
+ private var terminated = false
+
+ // Touch events
+ private var touchOverride = false
+ private var velocityTracker: VelocityTracker? = null
+ private var overrideX = 0.0f
+ private var overrideY = 0.0f
+ private var overrideVelocityX = 0.0f
+ private var overrideVelocityY = 0.0f
+ private var overrideDeltaX = 0.0f
+ private var overrideDeltaY = 0.0f
+
+ /**
+ * This method should be called after all of the confetto's state variables are configured
+ * and before the confetto gets animated.
+ *
+ * @param bound the space in which the confetto can display in.
+ */
+ fun prepare(bound: Rect?) {
+ this.bound = bound
+
+ millisToReachTargetVelocityX = computeMillisToReachTarget(targetVelocityX, initialVelocityX, accelerationX)
+ millisToReachTargetVelocityY = computeMillisToReachTarget(targetVelocityY, initialVelocityY, accelerationY)
+ millisToReachTargetRotationalVelocity =
+ computeMillisToReachTarget(targetRotationalVelocity, initialRotationalVelocity, rotationalAcceleration)
+
+ // Compute how long it would take to reach x/y bounds or reach TTL.
+ millisToReachBound = (if (ttl >= 0) ttl else Long.MAX_VALUE).toFloat()
+ val timeToReachXBound = computeBound(
+ initialPos = initialX,
+ velocity = initialVelocityX,
+ acceleration = accelerationX,
+ targetTime = millisToReachTargetVelocityX,
+ targetVelocity = targetVelocityX,
+ minBound = bound!!.left - width,
+ maxBound = bound.right,
+ )
+
+ millisToReachBound = timeToReachXBound.toFloat().coerceAtMost(millisToReachBound)
+ val timeToReachYBound = computeBound(
+ initialPos = initialY,
+ velocity = initialVelocityY,
+ acceleration = accelerationY,
+ targetTime = millisToReachTargetVelocityY,
+ targetVelocity = targetVelocityY,
+ minBound = bound.top - height,
+ maxBound = bound.bottom,
+ )
+
+ millisToReachBound = timeToReachYBound.toFloat().coerceAtMost(millisToReachBound)
+ configurePaint(workPaint)
+ }
+
+ private fun doesLocationIntercept(x: Float, y: Float): Boolean {
+ return currentX <= x && x <= currentX + width && currentY <= y && y <= currentY + height
+ }
+
+ fun onTouchDown(event: MotionEvent): Boolean {
+ val x = event.x
+ val y = event.y
+ return if (doesLocationIntercept(x, y)) {
+ touchOverride = true
+ overrideX = x
+ overrideY = y
+ overrideDeltaX = currentX - x
+ overrideDeltaY = currentY - y
+ velocityTracker = VelocityTracker.obtain().also { it.addMovement(event) }
+ true
+ } else {
+ false
+ }
+ }
+
+ fun onTouchMove(event: MotionEvent) {
+ overrideX = event.x
+ overrideY = event.y
+ velocityTracker!!.addMovement(event)
+ velocityTracker!!.computeCurrentVelocity(1)
+ overrideVelocityX = velocityTracker!!.xVelocity
+ overrideVelocityY = velocityTracker!!.yVelocity
+ }
+
+ fun onTouchUp(event: MotionEvent) {
+ velocityTracker!!.addMovement(event)
+ velocityTracker!!.computeCurrentVelocity(1)
+ initialDelay = RESET_ANIMATION_INITIAL_DELAY
+ initialX = event.x + overrideDeltaX
+ initialY = event.y + overrideDeltaY
+ initialVelocityX = velocityTracker!!.xVelocity
+ initialVelocityY = velocityTracker!!.yVelocity
+ initialRotation = currentRotation
+ velocityTracker!!.recycle()
+ velocityTracker = null
+ prepare(bound)
+ touchOverride = false
+ }
+
+ /**
+ * @return the width of the confetto.
+ */
+ abstract val width: Int
+
+ /**
+ * @return the height of the confetto.
+ */
+ abstract val height: Int
+
+ /**
+ * Reset this confetto object's internal states so that it can be re-used.
+ */
+ fun reset() {
+ initialDelay = 0L
+ initialY = 0.0f
+ initialX = 0.0f
+ initialVelocityY = 0.0f
+ initialVelocityX = 0.0f
+ accelerationY = 0.0f
+ accelerationX = 0.0f
+ targetVelocityY = null
+ targetVelocityX = null
+ millisToReachTargetVelocityY = null
+ millisToReachTargetVelocityX = null
+ initialRotation = 0.0f
+ initialRotationalVelocity = 0.0f
+ rotationalAcceleration = 0.0f
+ targetRotationalVelocity = null
+ millisToReachTargetRotationalVelocity = null
+ ttl = 0L
+ millisToReachBound = 0.0f
+ percentageAnimated = 0.0f
+ fadeOutInterpolator = null
+ currentY = 0.0f
+ currentX = currentY
+ currentVelocityY = 0.0f
+ currentVelocityX = currentVelocityY
+ currentRotation = 0.0f
+ alpha = MAX_ALPHA
+ startedAnimation = false
+ terminated = false
+ }
+
+ /**
+ * Hook to configure the global paint states before any animation happens.
+ *
+ * @param paint the paint object that will be used to perform all draw operations.
+ */
+ open fun configurePaint(paint: Paint) {
+ paint.alpha = alpha
+ }
+
+ /**
+ * Update the confetto internal state based on the provided passed time.
+ *
+ * @param passedTime time since the beginning of the animation.
+ * @return whether this particular confetto is still animating.
+ */
+ fun applyUpdate(passedTime: Long): Boolean {
+ if (initialDelay == RESET_ANIMATION_INITIAL_DELAY) initialDelay = passedTime
+ val animatedTime = passedTime - initialDelay
+ startedAnimation = animatedTime >= 0
+ if (startedAnimation && !terminated) {
+ computeDistance(
+ pair = workPairs,
+ t = animatedTime,
+ xi = initialX,
+ vi = initialVelocityX,
+ ai = accelerationX,
+ targetTime = millisToReachTargetVelocityX,
+ vTarget = targetVelocityX,
+ )
+ currentX = workPairs[0]
+ currentVelocityX = workPairs[1]
+ computeDistance(
+ pair = workPairs,
+ t = animatedTime,
+ xi = initialY,
+ vi = initialVelocityY,
+ ai = accelerationY,
+ targetTime = millisToReachTargetVelocityY,
+ vTarget = targetVelocityY,
+ )
+ currentY = workPairs[0]
+ currentVelocityY = workPairs[1]
+ computeDistance(
+ pair = workPairs,
+ t = animatedTime,
+ xi = initialRotation,
+ vi = initialRotationalVelocity,
+ ai = rotationalAcceleration,
+ targetTime = millisToReachTargetRotationalVelocity,
+ vTarget = targetRotationalVelocity,
+ )
+ currentRotation = workPairs[0]
+ currentRotationalVelocity = workPairs[1]
+ alpha = if (fadeOutInterpolator != null) {
+ val interpolatedTime = fadeOutInterpolator!!.getInterpolation(animatedTime / millisToReachBound)
+ (interpolatedTime * MAX_ALPHA).toInt()
+ } else {
+ MAX_ALPHA
+ }
+ terminated = !touchOverride && animatedTime >= millisToReachBound
+ percentageAnimated = 1.0f.coerceAtMost(animatedTime / millisToReachBound)
+ }
+ return !terminated
+ }
+
+ private fun computeDistance(pair: FloatArray, t: Long, xi: Float, vi: Float, ai: Float, targetTime: Long?, vTarget: Float?) {
+ // velocity with constant acceleration
+ val vX = ai * t + vi
+ pair[1] = vX
+ val x = if (targetTime == null || t < targetTime) {
+ // distance covered with constant acceleration
+ xi + vi * t + 0.5f * ai * t * t
+ } else {
+ // distance covered with constant acceleration + distance covered with max velocity
+ xi + vi * targetTime + 0.5f * ai * targetTime * targetTime + (t - targetTime) * vTarget!!
+ }
+ pair[0] = x
+ }
+
+ /**
+ * Primary method for rendering this confetto on the canvas.
+ *
+ * @param canvas the canvas to draw on.
+ */
+ fun draw(canvas: Canvas) {
+ if (touchOverride) {
+ // Replace time-calculated velocities with touch-velocities
+ currentVelocityX = overrideVelocityX
+ currentVelocityY = overrideVelocityY
+ draw(canvas, overrideX + overrideDeltaX, overrideY + overrideDeltaY, currentRotation, percentageAnimated)
+ } else if (startedAnimation && !terminated) {
+ draw(canvas, currentX, currentY, currentRotation, percentageAnimated)
+ }
+ }
+
+ private fun draw(canvas: Canvas, x: Float, y: Float, rotation: Float, percentAnimated: Float) {
+ canvas.save()
+ canvas.clipRect(bound!!)
+ matrix.reset()
+ workPaint.alpha = alpha
+ drawInternal(canvas, matrix, workPaint, x, y, rotation, percentAnimated)
+ canvas.restore()
+ }
+
+ /**
+ * Subclasses need to override this method to optimize for the way to draw the appropriate
+ * confetto on the canvas.
+ *
+ * @param canvas the canvas to draw on.
+ * @param matrix an identity matrix to use for draw manipulations.
+ * @param paint the paint to perform canvas draw operations on. This paint has already been
+ * configured via [.configurePaint].
+ * @param x the x position of the confetto relative to the canvas.
+ * @param y the y position of the confetto relative to the canvas.
+ * @param rotation the rotation (in degrees) to draw the confetto.
+ * @param percentAnimated the percentage [0.0f, 1f] of animation progress for this confetto.
+ */
+ abstract fun drawInternal(
+ canvas: Canvas,
+ matrix: Matrix,
+ paint: Paint,
+ x: Float,
+ y: Float,
+ rotation: Float,
+ percentAnimated: Float,
+ )
+
+ // region Helper methods to set all of the necessary values for the confetto.
+ fun setInitialDelay(value: Long) {
+ initialDelay = value
+ }
+
+ fun setInitialX(value: Float) {
+ initialX = value
+ }
+
+ fun setInitialY(value: Float) {
+ initialY = value
+ }
+
+ fun setInitialVelocityX(value: Float) {
+ initialVelocityX = value
+ }
+
+ fun setInitialVelocityY(value: Float) {
+ initialVelocityY = value
+ }
+
+ fun setAccelerationX(value: Float) {
+ accelerationX = value
+ }
+
+ fun setAccelerationY(value: Float) {
+ accelerationY = value
+ }
+
+ fun setTargetVelocityX(value: Float?) {
+ targetVelocityX = value
+ }
+
+ fun setTargetVelocityY(value: Float?) {
+ targetVelocityY = value
+ }
+
+ fun setInitialRotation(value: Float) {
+ initialRotation = value
+ }
+
+ fun setInitialRotationalVelocity(value: Float) {
+ initialRotationalVelocity = value
+ }
+
+ fun setRotationalAcceleration(value: Float) {
+ rotationalAcceleration = value
+ }
+
+ fun setTargetRotationalVelocity(value: Float?) {
+ targetRotationalVelocity = value
+ }
+
+ fun setTTL(value: Long) {
+ ttl = value
+ }
+
+ fun setFadeOut(fadeOutInterpolator: Interpolator?) {
+ this.fadeOutInterpolator = fadeOutInterpolator
+ }
+ // endregion
+
+ private companion object {
+ const val MAX_ALPHA = 255
+ const val RESET_ANIMATION_INITIAL_DELAY: Long = -1
+
+ fun computeMillisToReachTarget(targetVelocity: Float?, initialVelocity: Float, acceleration: Float): Long? {
+ return if (targetVelocity != null) {
+ if (acceleration != 0.0f) {
+ val time = ((targetVelocity - initialVelocity) / acceleration).toLong()
+ if (time > 0) time else 0
+ } else {
+ if (targetVelocity < initialVelocity) 0L else null
+ }
+ } else {
+ null
+ }
+ }
+
+ fun computeBound(
+ initialPos: Float,
+ velocity: Float,
+ acceleration: Float,
+ targetTime: Long?,
+ targetVelocity: Float?,
+ minBound: Int,
+ maxBound: Int,
+ ): Long {
+ return if (acceleration == 0.0f) {
+ computeBoundWithoutAcceleration(initialPos, velocity, targetTime, targetVelocity, minBound, maxBound)
+ } else {
+ // non-zero acceleration
+ val bound = if (acceleration > 0) maxBound else minBound
+ if (targetTime == null || targetTime < 0) {
+ computeBoundWithoutTargetTime(initialPos, velocity, acceleration, bound)
+ } else {
+ computeBoundWithTargetTime(initialPos, velocity, acceleration, targetTime, targetVelocity, bound)
+ }
+ }
+ }
+
+ fun computeBoundWithoutAcceleration(
+ initialPos: Float,
+ velocity: Float,
+ targetTime: Long?,
+ targetVelocity: Float?,
+ minBound: Int,
+ maxBound: Int,
+ ): Long {
+ val actualVelocity = if (targetTime == null) velocity else targetVelocity!!
+ val bound = if (actualVelocity > 0) maxBound else minBound
+ return if (actualVelocity == 0.0f) {
+ Long.MAX_VALUE
+ } else {
+ val time = ((bound - initialPos) / actualVelocity).toDouble()
+ if (time > 0) time.toLong() else Long.MAX_VALUE
+ }
+ }
+
+ fun computeBoundWithoutTargetTime(initialPos: Float, velocity: Float, acceleration: Float, bound: Int): Long {
+ // https://www.wolframalpha.com/input/
+ // ?i=solve+for+t+in+(d+%3D+x+%2B+v+*+t+%2B+0.5+*+a+*+t+*+t)
+ val tmp = sqrt((2 * acceleration * bound - 2 * acceleration * initialPos + velocity * velocity).toDouble())
+ val firstTime = (-tmp - velocity) / acceleration
+ if (firstTime > 0) return firstTime.toLong()
+ val secondTime = (tmp - velocity) / acceleration
+ return if (secondTime > 0.0f) secondTime.toLong() else Long.MAX_VALUE
+ }
+
+ fun computeBoundWithTargetTime(
+ initialPos: Float,
+ velocity: Float,
+ acceleration: Float,
+ targetTime: Long,
+ targetVelocity: Float?,
+ bound: Int,
+ ): Long {
+ // d = x + v * tm + 0.5 * a * tm * tm + tv * (t - tm)
+ // d - x - v * tm - 0.5 * a * tm * tm = tv * t - tv * tm
+ // d - x - v * tm - 0.5 * a * tm * tm + tv * tm = tv * t
+ // t = (d - x - v * tm - 0.5 * a * tm * tm + tv * tm) / tv
+ val time = (bound - initialPos - velocity * targetTime - 0.5 * acceleration
+ * targetTime * targetTime + targetVelocity!! * targetTime) / targetVelocity
+ return if (time > 0.0f) time.toLong() else Long.MAX_VALUE
+ }
+ }
+}
diff --git a/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt
new file mode 100644
index 00000000..8bf5d523
--- /dev/null
+++ b/Confetti/src/main/java/com/infomaniak/lib/confetti/confetto/ShimmeringConfetto.kt
@@ -0,0 +1,63 @@
+/*
+ * Infomaniak Core - Android
+ * Copyright (C) 2023 Infomaniak Network SA
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package com.infomaniak.lib.confetti.confetto
+
+import android.animation.ArgbEvaluator
+import android.graphics.*
+import android.os.SystemClock
+import java.util.Random
+import kotlin.math.abs
+
+class ShimmeringConfetto(
+ bitmap: Bitmap?,
+ private val fromColor: Int,
+ private val toColor: Int,
+ private val waveLength: Long,
+ random: Random,
+) : BitmapConfetto(bitmap!!) {
+
+ private val evaluator = ArgbEvaluator()
+ private val halfWaveLength: Long = waveLength / 2L
+ private val randomStart: Long
+
+ init {
+ val currentTime = abs(SystemClock.elapsedRealtime().toInt())
+ randomStart = (currentTime - random.nextInt(currentTime)).toLong()
+ }
+
+ override fun drawInternal(
+ canvas: Canvas,
+ matrix: Matrix,
+ paint: Paint,
+ x: Float,
+ y: Float,
+ rotation: Float,
+ percentAnimated: Float,
+ ) {
+ val currentTime = SystemClock.elapsedRealtime()
+ val fraction = (currentTime - randomStart) % waveLength
+ val animated = if (fraction < halfWaveLength) {
+ fraction.toFloat() / halfWaveLength
+ } else {
+ (waveLength.toFloat() - fraction) / halfWaveLength
+ }
+ val color = evaluator.evaluate(animated, fromColor, toColor) as Int
+ paint.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP)
+ super.drawInternal(canvas, matrix, paint, x, y, rotation, percentAnimated)
+ }
+}
diff --git a/Confetti/src/main/res/values/dimens.xml b/Confetti/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..dedb7e9b
--- /dev/null
+++ b/Confetti/src/main/res/values/dimens.xml
@@ -0,0 +1,28 @@
+
+
+ 9999dp
+ 6dp
+ 100dp
+ 20dp
+ 50dp
+ 100dp
+ 200dp
+ 275dp
+ 2375dp
+