Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created December 9, 2025 15:39
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/fe44f238e74669e32769c215bf3b292c to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/fe44f238e74669e32769c215bf3b292c to your computer and use it in GitHub Desktop.
/*
* Copyright 2025 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.zengrip.R
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
/**
* Represents a selectable welcome gift option.
*
* @property title Primary title of the gift offer.
* @property subtitle Secondary descriptive text for the gift offer.
* @property color Background color used to render the gift card.
* @property icon Drawable resource ID used as the central icon of the gift card.
*/
data class GiftOffer(
val title: String,
val subtitle: String,
val color: Color,
val icon: Int
)
/**
* Displays an interactive arc of gift cards that the user can drag to select a welcome gift.
*
* An icon is shown at the top, followed by a title and a curved row of [GiftCard] items.
* When the user finishes dragging horizontally, the nearest card is snapped to the center and
* [onOfferSelected] is invoked with the selected [GiftOffer].
*
* On long press of the centered card, a glowing light appears from the bottom edge of the screen.
* The selected card can then be dragged vertically into this light to activate the gift. Once
* activated, the card disappears, the header icon and title switch to an activated state, and
* further dragging is disabled.
*
* @param offers List of [GiftOffer] items to display in the arc.
* @param onOfferSelected Callback invoked when an offer is snapped to the center or activated.
*/
@Composable
fun WelcomeGiftArcScreen(
offers: List<GiftOffer>,
onOfferSelected: (GiftOffer) -> Unit = {}
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
if (offers.isEmpty()) return
val angleOffset = remember { Animatable(0f) }
val cardCount = offers.size
val dragSensitivity = 0.008f
val maxArcAngle = PI.toFloat() * 0.72f
val stepAngle = if (cardCount > 1) {
(2f * maxArcAngle) / (cardCount - 1)
} else {
0f
}
var isPortalVisible by remember { mutableStateOf(false) }
var draggingIndex by remember { mutableStateOf<Int?>(null) }
var activatedOffer by remember { mutableStateOf<GiftOffer?>(null) }
val portalTriggerOffsetPx = with(density) { 220.dp.toPx() }
val isActivated = activatedOffer != null
val headerTitle = if (isActivated) {
"Your gift is activated"
} else {
"Choose your welcome gift"
}
val headerIconRes = if (isActivated) {
R.drawable.check
} else {
R.drawable.ic_welcome_gift
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color(0xFF111111), Color(0xFF1C140A))
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(16.dp))
Image(
painter = painterResource(id = headerIconRes),
contentDescription = null,
modifier = Modifier.size(84.dp)
)
Spacer(Modifier.height(16.dp))
Text(
text = headerTitle,
style = MaterialTheme.typography.titleLarge.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold
),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
val frameYOffset = 10.dp
Text(
text = "Drag to choose your gift",
modifier = Modifier
.align(Alignment.Center)
.offset(y = frameYOffset + 160.dp)
.alpha(0.8f),
style = MaterialTheme.typography.bodyMedium.copy(
color = Color(0xFFFFD66B),
fontWeight = FontWeight.Medium
)
)
YellowSelectionFrame(
modifier = Modifier.offset(y = frameYOffset)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(320.dp)
.offset(y = frameYOffset)
.draggable(
orientation = Orientation.Horizontal,
enabled = !isActivated,
state = rememberDraggableState { deltaX ->
if (!isActivated) {
scope.launch {
val rawDelta = deltaX * dragSensitivity
val target = angleOffset.value + rawDelta
val alpha = 0.35f
val filtered =
angleOffset.value + alpha * (target - angleOffset.value)
angleOffset.snapTo(filtered)
}
}
},
onDragStopped = {
if (!isActivated) {
scope.launch {
val baseAngle = -maxArcAngle
val angles = offers.indices.map { index ->
val angle =
baseAngle + index * stepAngle + angleOffset.value
index to angle
}
val (closestIndex, closestAngle) =
angles.minBy { (_, angle) -> abs(angle) }
val targetOffset = angleOffset.value - closestAngle
angleOffset.animateTo(
targetOffset,
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
onOfferSelected(offers[closestIndex])
}
}
}
),
contentAlignment = Alignment.Center
) {
val midIndex = (cardCount - 1) / 2f
val cardAngles = offers.indices.map { index ->
val angle = (index - midIndex) * stepAngle + angleOffset.value
index to angle
}
val selectedIndex = cardAngles
.minBy { (_, angle) -> abs(angle) }
.first
cardAngles.forEach { (index, angle) ->
val offer = offers[index]
if (activatedOffer == offer) return@forEach
val isSelected = index == selectedIndex
val isBeingDragged = draggingIndex == index
val dragOffsetY = remember(index) { Animatable(0f) }
val t = (angle / maxArcAngle).coerceIn(-1f, 1f)
val cardWidth = 160.dp
val cardWidthPx = with(density) { cardWidth.toPx() }
val overlapFactor = 1.3f
val maxHorizontalOffset =
(cardWidthPx * (cardCount - 1) / 2f) * overlapFactor
val arcDepthPx = with(density) { 240.dp.toPx() }
val x = maxHorizontalOffset * t
val baseY = -arcDepthPx * t * t
val y = baseY + dragOffsetY.value
val rawDepth = (1f - abs(t)).coerceIn(0f, 1f)
val baseScale = lerp(0.7f, 1f, rawDepth)
val selectionScale by animateFloatAsState(
targetValue = if (isBeingDragged) 1.15f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "selectionScale"
)
val scale = baseScale * selectionScale
val alphaDepth = rawDepth.coerceIn(0.4f, 1f)
val alpha = (alphaDepth - 0.4f) / 0.6f
val maxTiltDeg = -45f
val rotationZ = t * maxTiltDeg
val baseModifier = Modifier.graphicsLayer {
translationX = x
translationY = y
scaleX = scale
scaleY = scale
this.alpha = alpha
this.rotationZ = rotationZ
}
val interactiveModifier =
if (isSelected && !isActivated) {
baseModifier
.pointerInput(isPortalVisible, draggingIndex) {
detectTapGestures(
onLongPress = {
isPortalVisible = true
draggingIndex = index
scope.launch {
dragOffsetY.stop()
dragOffsetY.snapTo(0f)
}
}
)
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { deltaY ->
if (isPortalVisible && draggingIndex == index) {
scope.launch {
dragOffsetY.snapTo(dragOffsetY.value + deltaY)
}
}
},
onDragStopped = {
if (isPortalVisible && draggingIndex == index) {
isPortalVisible = false
if (dragOffsetY.value >= portalTriggerOffsetPx) {
activatedOffer = offer
onOfferSelected(offer)
draggingIndex = null
scope.launch {
dragOffsetY.snapTo(0f)
}
} else {
draggingIndex = null
scope.launch {
dragOffsetY.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessVeryLow
)
)
}
}
}
}
)
} else {
baseModifier
}
GiftCard(
offer = offer,
isSelected = isSelected,
modifier = interactiveModifier
)
}
}
}
Spacer(Modifier.height(32.dp))
}
if (isPortalVisible && !isActivated) {
GiftPortalLight(
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
/**
* Renders a single gift card for a [GiftOffer].
*
* @param offer Data model describing the gift.
* @param isSelected Whether this card is currently selected in the arc.
* @param modifier Modifier applied to the card container.
*/
@Composable
private fun GiftCard(
offer: GiftOffer,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
val cardWidth = 160.dp
val cardHeight = 210.dp
Surface(
modifier = modifier
.width(cardWidth)
.height(cardHeight),
shape = RoundedCornerShape(20.dp),
tonalElevation = if (isSelected) 16.dp else 8.dp,
color = Color.Transparent,
shadowElevation = 10.dp
) {
Box(
modifier = Modifier
.background(
brush = Brush.verticalGradient(
colors = listOf(
offer.color.copy(alpha = 0.96f),
offer.color.copy(alpha = 0.88f)
)
),
shape = RoundedCornerShape(20.dp)
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(20.dp)
)
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Image(
painter = painterResource(id = offer.icon),
contentDescription = null,
modifier = Modifier
.size(52.dp)
.align(Alignment.Center)
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween
) {
Text(
text = offer.title,
style = MaterialTheme.typography.titleMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold
)
)
Text(
text = offer.subtitle,
style = MaterialTheme.typography.bodySmall.copy(
color = Color.White.copy(alpha = 0.9f)
)
)
}
}
}
}
/**
* Displays a static glowing yellow stroke behind the centered card.
*
* The glow is simulated using a thick, low-alpha outer stroke and a
* thinner, fully opaque inner stroke.
*
* @param modifier Modifier applied to the frame container.
*/
@Composable
private fun YellowSelectionFrame(
modifier: Modifier = Modifier
) {
val glowColor = Color(0xFFFFD66B)
Box(
modifier = modifier
.size(width = 174.dp, height = 226.dp)
) {
Box(
modifier = Modifier
.matchParentSize()
.border(
width = 8.dp,
color = glowColor.copy(alpha = 0.25f),
shape = RoundedCornerShape(32.dp)
)
)
Box(
modifier = Modifier
.matchParentSize()
.border(
width = 2.dp,
color = glowColor,
shape = RoundedCornerShape(28.dp)
)
)
}
}
/**
* Bottom light shaped like a shallow tray, touching the bottom of the screen.
*
* Uses a soft, semi-transparent trapezoid beam that gently breathes so it
* feels like real light, without any dome above the portal.
*
* @param modifier Modifier applied to the portal container.
*/
@Composable
private fun GiftPortalLight(
modifier: Modifier = Modifier
) {
val glowColor = Color(0xFFFFD66B)
val transition = rememberInfiniteTransition(label = "portalGlow")
val beamIntensity = transition.animateFloat(
initialValue = 0.6f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1300, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "beamIntensity"
).value
Canvas(
modifier = modifier
.width(220.dp)
.height(72.dp)
) {
val w = size.width
val h = size.height
val bottomY = h
val topY = h * 0.18f
val leftX = 0f
val rightX = w
val innerLeftX = w * 0.18f
val innerRightX = w * 0.82f
val beamPath = Path().apply {
moveTo(innerLeftX, topY)
lineTo(innerRightX, topY)
lineTo(rightX, bottomY)
lineTo(leftX, bottomY)
close()
}
drawPath(
path = beamPath,
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
glowColor.copy(alpha = 0.32f * beamIntensity),
glowColor.copy(alpha = 0.14f * beamIntensity),
Color.Transparent
),
startY = topY,
endY = bottomY
),
style = Fill
)
}
}
/**
* Linearly interpolates between [start] and [stop] using [fraction].
*
* @param start Start value.
* @param stop End value.
* @param fraction Interpolation factor in [0, 1].
*/
private fun lerp(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction
}
/**
* Preview of [WelcomeGiftArcScreen] with sample data.
*/
@Preview(showBackground = true, backgroundColor = 0xFF101010)
@Composable
private fun WelcomeGiftArcPreview() {
val offers = listOf(
GiftOffer(
title = "50% Cashback",
subtitle = "Up to €10 on your first order",
color = Color(0xFFF39C12),
icon = R.drawable.refund
),
GiftOffer(
title = "Free Delivery",
subtitle = "On your first 3 orders over €25",
color = Color(0xFF3498DB),
icon = R.drawable.delivery
),
GiftOffer(
title = "Rewards Booster",
subtitle = "Earn 2× points for 7 days",
color = Color(0xFFE74C3C),
icon = R.drawable.rocket
),
GiftOffer(
title = "Premium Trial",
subtitle = "Enjoy 30 days of premium for free",
color = Color(0xFF9B59B6),
icon = R.drawable.crown
),
GiftOffer(
title = "Buy 1 Get 1",
subtitle = "Applies to selected everyday essentials",
color = Color(0xFF1ABC9C),
icon = R.drawable.plus_one
),
GiftOffer(
title = "Welcome Bundle",
subtitle = "Exclusive discounts on your first 5 orders",
color = Color(0xFFe67e22),
icon = R.drawable.contacts
),
GiftOffer(
title = "Mystery Reward",
subtitle = "A surprise credit on your next order",
color = Color(0xFFf39c12),
icon = R.drawable.box
)
)
WelcomeGiftArcScreen(offers = offers)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment