Created
December 9, 2025 15:39
-
-
Save Kyriakos-Georgiopoulos/fe44f238e74669e32769c215bf3b292c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * 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