Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created February 5, 2026 18:53
Show Gist options
  • Select an option

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

Select an option

Save Kyriakos-Georgiopoulos/43c7bd559e123465381885cece7c510b to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 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
*
* https://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 android.annotation.SuppressLint
import android.app.Activity
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
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.animation.rememberSplineBasedDecay
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.rounded.ArrowDownward
import androidx.compose.material.icons.rounded.ArrowUpward
import androidx.compose.material.icons.rounded.AttachMoney
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.FitnessCenter
import androidx.compose.material.icons.rounded.Movie
import androidx.compose.material.icons.rounded.MusicNote
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.PhoneIphone
import androidx.compose.material.icons.rounded.Savings
import androidx.compose.material.icons.rounded.ShoppingCart
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import androidx.core.view.WindowInsetsControllerCompat
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* State used for the expandable "Dynamic Island" sheet.
*/
enum class BlobState { Collapsed, Expanded }
val SpaceStart = Color(0xFF1E1B4B)
val SpaceEnd = Color(0xFF020617)
val TextPrimary = Color(0xFFF8FAFC)
val TextSecondary = Color(0xFF94A3B8)
val PlatinumBase = Color(0xFFE2E8F0)
val PlatinumDark = Color(0xFFCBD5E1)
val ElectricAccent = Color(0xFF6366F1)
val SilverText = Color(0xFF9CA3AF)
val SpecularWhite = Color(0xFFFFFFFF).copy(alpha = 0.8f)
val IridescentBorder = Brush.linearGradient(
colors = listOf(
Color(0xFF818CF8).copy(alpha = 0.5f),
Color(0xFFC084FC).copy(alpha = 0.3f),
Color(0xFFFFFFFF).copy(alpha = 0.1f)
)
)
val GlassSurface = Color(0xFFFFFFFF).copy(alpha = 0.05f)
/**
* Root wallet experience composed of:
* - Background profile screen
* - Foreground wallet surface that rolls down/up
* - Shared profile avatar that morphs between screens
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun WalletAnimation() {
val density = LocalDensity.current
val config = LocalConfiguration.current
val scope = rememberCoroutineScope()
val view = LocalView.current
val screenHeightDp = config.screenHeightDp.dp
val screenWidthDp = config.screenWidthDp.dp
val rollTargetPx = with(density) { (screenHeightDp * 0.75f).toPx() }
val screenRollOffset = remember { Animatable(0f) }
var isProfileOpen by remember { mutableStateOf(false) }
var islandExpansionProgress by remember { mutableFloatStateOf(0f) }
val baseTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val progress by remember {
derivedStateOf { (screenRollOffset.value / rollTargetPx).coerceIn(0f, 1f) }
}
LaunchedEffect(progress) {
val window = (view.context as Activity).window
val controller = WindowInsetsControllerCompat(window, view)
controller.isAppearanceLightStatusBars = progress < 0.5f
}
fun toggleProfile() {
scope.launch {
if (isProfileOpen) {
screenRollOffset.animateTo(0f, spring(dampingRatio = 0.85f, stiffness = 300f))
isProfileOpen = false
} else {
isProfileOpen = true
screenRollOffset.animateTo(
rollTargetPx,
spring(dampingRatio = 0.75f, stiffness = 160f)
)
}
}
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(SpaceEnd)
) {
ProfileScreenContent(
isOpen = isProfileOpen,
progress = progress,
topPadding = baseTopPadding
)
Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, screenRollOffset.value.roundToInt()) }
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
val rollPx = screenRollOffset.value
if (rollPx <= 1f) {
drawContent()
return@drawWithContent
}
val width = size.width
val cylinderDiameter = (sqrt(rollPx) * 3.6f).coerceAtLeast(1f)
drawContent()
drawRect(
brush = Brush.verticalGradient(
listOf(Color.Black.copy(0.6f), Color.Transparent),
startY = 0f,
endY = cylinderDiameter * 1.5f
),
size = Size(width, cylinderDiameter * 1.5f)
)
drawRect(
brush = Brush.verticalGradient(
0.0f to Color(0xFFD1D5DB),
0.3f to Color(0xFFFFFFFF),
0.6f to Color(0xFFE5E7EB),
1.0f to Color(0xFF9CA3AF),
startY = -cylinderDiameter,
endY = 0f
),
topLeft = Offset(0f, -cylinderDiameter),
size = Size(width, cylinderDiameter)
)
}
) {
HomeContent(
topPadding = baseTopPadding,
onProfileClick = { toggleProfile() },
progress = progress,
onIslandProgress = { islandExpansionProgress = it }
)
}
val avatarAlpha = (1f - (islandExpansionProgress * 6f)).coerceIn(0f, 1f)
if (avatarAlpha > 0f) {
SharedProfileAvatar(
progress = progress,
screenWidth = screenWidthDp,
topPadding = baseTopPadding,
alpha = avatarAlpha,
onClick = { toggleProfile() }
)
}
}
}
/**
* Shared element avatar that interpolates between the wallet and profile states.
*/
@Composable
fun SharedProfileAvatar(
progress: Float,
screenWidth: Dp,
topPadding: Dp,
alpha: Float,
onClick: () -> Unit
) {
val startSize = 52.dp
val startX = 24.dp
val startY = topPadding + 2.dp
val endSize = 100.dp
val endX = (screenWidth - endSize) / 2
val endY = topPadding + 80.dp
val currentSize = lerp(startSize, endSize, progress)
val currentX = lerp(startX, endX, progress)
val currentY = lerp(startY, endY, progress)
val currentBorderWidth = lerp(1.dp, 2.dp, progress)
val currentBorderBrush = if (progress < 0.5f) {
IridescentBorder
} else {
SolidColor(Color.White.copy(alpha = 0.5f))
}
val currentShadow = lerp(0.dp, 20.dp, progress)
val currentIconSize = lerp(24.dp, 48.dp, progress)
Box(
modifier = Modifier
.offset { IntOffset(currentX.roundToPx(), currentY.roundToPx()) }
.size(currentSize)
.graphicsLayer { this.alpha = alpha }
.shadow(currentShadow, CircleShape, spotColor = ElectricAccent)
.background(
Brush.linearGradient(
colors = listOf(SpaceStart, SpaceEnd),
start = Offset(0f, 0f),
end = Offset(0f, 100f)
),
CircleShape
)
.border(currentBorderWidth, currentBorderBrush, CircleShape)
.clip(CircleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Rounded.Person,
contentDescription = "Profile",
tint = Color.White,
modifier = Modifier.size(currentIconSize)
)
}
}
/**
* Profile screen content rendered beneath the rolling wallet surface.
*/
@Composable
fun ProfileScreenContent(
isOpen: Boolean,
progress: Float,
topPadding: Dp
) {
val alpha = progress
val scale = lerp(0.92f, 1f, progress)
val slideY = lerp(50.dp, 0.dp, progress)
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
this.alpha = alpha
this.scaleX = scale
this.scaleY = scale
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding)
.graphicsLayer { translationY = slideY.toPx() }
.verticalScroll(scrollState)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(80.dp + 100.dp))
Spacer(Modifier.height(24.dp))
Text(
"Kyriakos Georgiopoulos",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(4.dp))
Text(
"Verified Account",
color = ElectricAccent,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
ProfileStatBox(
label = "Income",
value = "$8.2k",
icon = Icons.Rounded.ArrowUpward,
color = Color(0xFF22C55E)
)
ProfileStatBox(
label = "Spent",
value = "$3.4k",
icon = Icons.Rounded.ArrowDownward,
color = Color(0xFFEF4444)
)
ProfileStatBox(
label = "Saved",
value = "$12k",
icon = Icons.Rounded.Savings,
color = ElectricAccent
)
}
Spacer(Modifier.height(40.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ProfileMenuItem("Account Settings", Icons.Default.Settings)
ProfileMenuItem("Notifications", Icons.Default.Notifications)
ProfileMenuItem("Privacy & Security", Icons.Default.Security)
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
* Small stat card used in the profile header row.
*/
@Composable
fun ProfileStatBox(label: String, value: String, icon: ImageVector, color: Color) {
Column(
modifier = Modifier
.width(100.dp)
.background(GlassSurface, RoundedCornerShape(16.dp))
.border(1.dp, Color.White.copy(0.1f), RoundedCornerShape(16.dp))
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(icon, null, tint = color, modifier = Modifier.size(20.dp))
Spacer(Modifier.height(8.dp))
Text(value, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(label, color = TextSecondary, fontSize = 12.sp)
}
}
/**
* Clickable menu row used in the profile menu list.
*/
@Composable
fun ProfileMenuItem(text: String, icon: ImageVector, isDestructive: Boolean = false) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(GlassSurface, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(0.05f), RoundedCornerShape(12.dp))
.clickable { }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
icon,
null,
tint = if (isDestructive) Color(0xFFEF4444) else Color.White,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(16.dp))
Text(
text,
color = if (isDestructive) Color(0xFFEF4444) else Color.White,
fontWeight = FontWeight.Medium
)
}
Icon(
Icons.Default.ArrowForwardIos,
null,
tint = TextSecondary.copy(0.5f),
modifier = Modifier.size(14.dp)
)
}
}
/**
* Wallet home content shown on the rolling foreground surface, including the expandable "Dynamic Island".
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeContent(
topPadding: Dp,
onProfileClick: () -> Unit,
progress: Float,
onIslandProgress: (Float) -> Unit
) {
val density = LocalDensity.current
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scope = rememberCoroutineScope()
val startHeight = 56.dp
val startWidth = 220.dp
val startRadius = 28.dp
val endMarginHorizontal = 16.dp
val endMarginBottom = 96.dp
val endHeight = LocalConfiguration.current.screenHeightDp.dp - endMarginBottom
val endWidth = screenWidth - (endMarginHorizontal * 2)
val endRadius = 42.dp
val startHeightPx = with(density) { startHeight.toPx() }
val endHeightPx = with(density) { endHeight.toPx() }
val anchors = DraggableAnchors {
BlobState.Collapsed at startHeightPx
BlobState.Expanded at endHeightPx
}
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val draggableState = remember {
AnchoredDraggableState(
initialValue = BlobState.Collapsed,
anchors = anchors,
positionalThreshold = { it * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
snapAnimationSpec = spring(dampingRatio = 0.85f, stiffness = 250f),
decayAnimationSpec = decayAnimationSpec
)
}
val currentHeightPx =
if (draggableState.offset.isNaN()) startHeightPx else draggableState.offset
val rawProgress =
((currentHeightPx - startHeightPx) / (endHeightPx - startHeightPx)).coerceIn(0f, 1f)
val smoothProgress by animateFloatAsState(targetValue = rawProgress, label = "Smooth")
LaunchedEffect(smoothProgress) {
onIslandProgress(smoothProgress)
}
val widthCurve = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
val widthProgress = widthCurve.transform(smoothProgress)
val currentWidth = lerp(startWidth, endWidth, widthProgress)
val currentRadius = lerp(startRadius, endRadius, widthProgress)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF1F5F9))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(topPadding + startHeight + 24.dp))
WetPaintCard()
Spacer(Modifier.height(32.dp))
BalanceSection()
Spacer(Modifier.height(48.dp))
TransactionsList(modifier = Modifier.weight(1f))
}
val bubbleSize = 52.dp
if (progress == 0f) {
Box(
modifier = Modifier
.padding(top = topPadding + 2.dp, start = 24.dp)
.size(bubbleSize)
.graphicsLayer { alpha = 1f - smoothProgress }
.border(1.dp, IridescentBorder, CircleShape)
.background(Color.Transparent, CircleShape)
.clip(CircleShape)
.clickable { onProfileClick() },
contentAlignment = Alignment.Center
) {}
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = topPadding + 2.dp, end = 24.dp)
.size(bubbleSize)
.graphicsLayer { alpha = 1f - smoothProgress }
.border(1.dp, IridescentBorder, CircleShape)
.background(
Brush.linearGradient(
colors = listOf(SpaceStart, SpaceEnd),
start = Offset(0f, 0f),
end = Offset(0f, 100f)
),
CircleShape
)
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
"Add Money",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
Box(
modifier = Modifier
.padding(top = topPadding)
.offset {
IntOffset(
((screenWidth.toPx() - currentWidth.toPx()) / 2).roundToInt(),
0
)
}
.width(currentWidth)
.height(with(density) { currentHeightPx.toDp() })
.graphicsLayer {
shape = RoundedCornerShape(currentRadius)
clip = true
shadowElevation = (smoothProgress * 40f)
spotShadowColor = Color(0xFF4F46E5).copy(alpha = 0.8f)
ambientShadowColor = Color(0xFFC084FC).copy(alpha = 0.8f)
}
.background(
Brush.linearGradient(
colors = listOf(SpaceStart, SpaceEnd),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
)
)
.border(
width = 1.dp,
brush = IridescentBorder,
shape = RoundedCornerShape(currentRadius)
)
.anchoredDraggable(state = draggableState, orientation = Orientation.Vertical)
) {
BlobContent(
progress = smoothProgress,
onClose = { scope.launch { draggableState.animateTo(BlobState.Collapsed) } }
)
}
}
}
/**
* Content shown inside the expandable "Dynamic Island".
*/
@SuppressLint("RestrictedApi")
@Composable
fun BlobContent(progress: Float, onClose: () -> Unit) {
Box(Modifier.fillMaxSize()) {
if (progress < 0.5f) {
val pillAlpha = (1f - (progress * 5f)).coerceIn(0f, 1f)
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = pillAlpha }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.align(Alignment.Center),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Send", color = TextSecondary, fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(modifier = Modifier.width(4.dp))
Text("to", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
Row(
horizontalArrangement = Arrangement.spacedBy((-8).dp),
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
Box(
modifier = Modifier
.size(26.dp)
.border(2.dp, SpaceStart, CircleShape)
.clip(CircleShape)
.background(getRandomColor(i)),
contentAlignment = Alignment.Center
) {
Text(
listOf("A", "S", "J")[i],
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = Color.Black.copy(0.6f)
)
}
}
}
}
}
}
val contentAlpha = ((progress - 0.2f) / 0.4f).coerceIn(0f, 1f)
val contentParallax = (1f - progress) * 100f
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.graphicsLayer {
alpha = contentAlpha
translationY = contentParallax
}
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column {
Text("Send", color = TextSecondary, fontSize = 20.sp)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Money To",
color = TextPrimary,
fontSize = 34.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-1).sp
)
}
IconButton(
onClick = onClose,
modifier = Modifier
.background(GlassSurface, CircleShape)
.border(1.dp, Color.White.copy(0.1f), CircleShape)
) {
Icon(Icons.Default.Close, null, tint = TextPrimary)
}
}
Spacer(Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val bubbleScale = (progress * 1.2f).coerceIn(0f, 1f)
Row(
modifier = Modifier.graphicsLayer {
scaleX = bubbleScale
scaleY = bubbleScale
},
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ContactCircle("Farid", Color(0xFFFF8A65))
ContactCircle("Shadi", Color(0xFF29B6F6))
ContactCircle("Cyrus", Color(0xFFB9F6CA))
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer {
scaleX = bubbleScale
scaleY = bubbleScale
}
) {
Box(
modifier = Modifier
.size(60.dp)
.border(1.dp, Color.White.copy(0.2f), CircleShape)
.clip(CircleShape)
.background(Color.White.copy(0.05f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Add, null, tint = TextPrimary)
}
Spacer(Modifier.height(8.dp))
Text("Add", color = TextSecondary, fontSize = 12.sp)
}
}
Spacer(Modifier.height(32.dp))
Text("Your Contacts", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(6) { i ->
val start = 0.4f + (i * 0.05f)
val end = 1f
val itemProgress = ((progress - start) / (end - start)).coerceIn(0f, 1f)
val itemScale = lerp(0.8f, 1f, itemProgress)
val itemAlpha = itemProgress
val itemTranslationY = (1f - itemProgress) * 100f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = itemAlpha
scaleX = itemScale
scaleY = itemScale
translationY = itemTranslationY
}
.clip(RoundedCornerShape(22.dp))
.background(GlassSurface)
.padding(12.dp)
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(getRandomColor(i)),
contentAlignment = Alignment.Center
) {
Text(
listOf("A", "C", "J", "W", "D", "M")[i],
color = Color.Black.copy(0.6f),
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
listOf(
"Annette Black",
"Cameron Williamson",
"Jane Cooper",
"Wade Warren",
"Devon Lane",
"Molly Sanders"
)[i],
color = TextPrimary,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
)
Text("Recent transfer", color = TextSecondary, fontSize = 12.sp)
}
Icon(Icons.Rounded.ChevronRight, null, tint = TextSecondary.copy(0.5f))
}
}
}
val buttonScale = (progress * 1.5f - 0.5f).coerceIn(0f, 1f)
Button(
onClick = {},
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.graphicsLayer {
scaleX = buttonScale
scaleY = buttonScale
alpha = buttonScale
},
shape = RoundedCornerShape(32.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Text("Continue", fontWeight = FontWeight.Bold, fontSize = 17.sp)
}
}
}
}
/**
* Interactive card with a 3D flip gesture and shimmering paint-like front.
*/
@Composable
fun WetPaintCard(modifier: Modifier = Modifier) {
val rotation = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val density = LocalDensity.current.density
val normalizedAngle = (rotation.value % 360 + 360) % 360
val isBackVisible = normalizedAngle in 90f..270f
val infiniteTransition = rememberInfiniteTransition(label = "Gloss")
val shimmerPhase by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(3500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "Shimmer"
)
val dragStartFace = remember { mutableStateOf(0f) }
Box(
modifier = modifier
.fillMaxWidth()
.height(240.dp)
.graphicsLayer {
rotationY = rotation.value
cameraDistance = 14f * density
}
.draggable(
state = rememberDraggableState { delta ->
scope.launch {
val direction = if (isBackVisible) -1f else 1f
val current = rotation.value
val angleInHalfTurn = ((current - dragStartFace.value) % 180f + 180f) % 180f
val distanceFromMid = kotlin.math.abs(angleInHalfTurn - 90f)
val magneticFactor = androidx.compose.ui.util.lerp(
start = 0.25f,
stop = 1f,
fraction = (distanceFromMid / 90f).coerceIn(0f, 1f)
)
val proposed = current + delta * 0.6f * magneticFactor * direction
val clamped = proposed.coerceIn(
minimumValue = dragStartFace.value - 180f,
maximumValue = dragStartFace.value + 180f
)
rotation.snapTo(clamped)
}
},
orientation = Orientation.Horizontal,
onDragStarted = {
dragStartFace.value = (rotation.value / 180f).roundToInt() * 180f
},
onDragStopped = { velocity ->
val current = rotation.value
val base = dragStartFace.value
val offset = current - base
val target = when {
velocity > 800f -> base + 180f
velocity < -800f -> base - 180f
offset > 60f -> base + 180f
offset < -60f -> base - 180f
else -> base
}
scope.launch {
rotation.animateTo(
targetValue = target,
animationSpec = spring(dampingRatio = 0.65f, stiffness = 420f)
)
}
}
)
) {
if (isBackVisible) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { rotationY = 180f }
) {
WetPaintCardBack()
}
} else {
WetPaintCardFront(shimmerPhase = shimmerPhase)
}
}
}
/**
* Front face of the card with paint path and shimmer.
*/
@Composable
fun WetPaintCardFront(shimmerPhase: Float) {
Box(
modifier = Modifier
.fillMaxSize()
.shadow(
elevation = 20.dp,
shape = RoundedCornerShape(24.dp),
spotColor = Color.Black.copy(0.4f),
ambientColor = Color.Black.copy(0.2f)
)
.clip(RoundedCornerShape(24.dp))
.background(
Brush.linearGradient(
colors = listOf(PlatinumBase, PlatinumDark),
start = Offset(0f, 0f),
end = Offset(0f, Float.POSITIVE_INFINITY)
)
)
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.alpha(0.08f)
) {
drawRect(
brush = Brush.radialGradient(
colors = listOf(Color.Black, Color.Transparent),
radius = size.width
),
blendMode = BlendMode.Overlay
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val width = size.width
val height = size.height
val paintPath = Path().apply {
moveTo(0f, 0f)
lineTo(width, 0f)
lineTo(width, height * 0.15f)
cubicTo(
width * 0.96f,
height * 0.15f,
width * 0.94f,
height * 0.20f,
width * 0.92f,
height * 0.50f
)
cubicTo(
width * 0.90f,
height * 0.65f,
width * 0.84f,
height * 0.65f,
width * 0.82f,
height * 0.40f
)
cubicTo(
width * 0.78f,
height * 0.25f,
width * 0.72f,
height * 0.25f,
width * 0.68f,
height * 0.60f
)
cubicTo(
width * 0.66f,
height * 0.92f,
width * 0.56f,
height * 0.92f,
width * 0.54f,
height * 0.60f
)
cubicTo(
width * 0.50f,
height * 0.30f,
width * 0.45f,
height * 0.30f,
width * 0.42f,
height * 0.70f
)
cubicTo(
width * 0.40f,
height * 0.85f,
width * 0.30f,
height * 0.85f,
width * 0.28f,
height * 0.55f
)
cubicTo(
width * 0.25f,
height * 0.25f,
width * 0.20f,
height * 0.25f,
width * 0.18f,
height * 0.45f
)
cubicTo(
width * 0.16f,
height * 0.55f,
width * 0.10f,
height * 0.55f,
width * 0.08f,
height * 0.35f
)
cubicTo(width * 0.04f, height * 0.15f, 0f, height * 0.20f, 0f, height * 0.15f)
close()
}
drawPath(
path = paintPath,
brush = Brush.verticalGradient(
colors = listOf(Color.Black.copy(0.6f), Color.Transparent),
startY = height * 0.1f,
endY = height
),
style = Stroke(width = 10f)
)
drawPath(
path = paintPath,
brush = Brush.verticalGradient(
colors = listOf(SpaceStart, SpaceEnd),
startY = 0f,
endY = height
)
)
drawPath(
path = paintPath,
brush = Brush.radialGradient(
colors = listOf(Color.Transparent, Color.Black.copy(0.4f)),
center = Offset(width * 0.6f, height * 0.9f),
radius = width * 0.5f
)
)
clipPath(paintPath) {
val shimmerStart = -width + (width * 3 * shimmerPhase)
drawRect(
brush = Brush.linearGradient(
colors = listOf(
Color.Transparent,
Color.White.copy(0.15f),
Color.Transparent
),
start = Offset(shimmerStart, 0f),
end = Offset(shimmerStart + width * 0.5f, height)
)
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(28.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(20.dp)
.background(Color.White, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
"NEXUS",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp
)
}
Text("VIRTUAL", color = SilverText, fontSize = 10.sp, fontWeight = FontWeight.Bold)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"••",
color = ElectricAccent,
fontSize = 24.sp,
fontWeight = FontWeight.Black,
letterSpacing = 2.sp
)
Text(
"1234",
color = SpaceStart,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(start = 6.dp, top = 2.dp)
)
}
Text(
"VISA",
color = SpaceStart,
fontWeight = FontWeight.Black,
fontSize = 28.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
letterSpacing = (-1).sp
)
}
}
}
}
/**
* Back face of the card.
*/
@Composable
fun WetPaintCardBack() {
Box(
modifier = Modifier
.fillMaxSize()
.shadow(
elevation = 20.dp,
shape = RoundedCornerShape(24.dp),
spotColor = Color.Black.copy(0.4f),
ambientColor = Color.Black.copy(0.2f)
)
.clip(RoundedCornerShape(24.dp))
.background(
Brush.linearGradient(
colors = listOf(PlatinumBase, PlatinumDark),
start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
)
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.alpha(0.05f)
) {
drawRect(
brush = Brush.radialGradient(
colors = listOf(Color.Black, Color.Transparent),
radius = size.width
),
blendMode = BlendMode.Overlay
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = 0.0f),
Color.White.copy(alpha = 0.4f),
Color.White.copy(alpha = 0.0f)
),
start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
)
)
Column(Modifier.fillMaxSize()) {
Spacer(Modifier.height(30.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.background(
Brush.verticalGradient(
listOf(
Color(0xFF303030),
Color(0xFF000000),
Color(0xFF000000),
Color(0xFF303030)
)
)
)
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.height(40.dp)
.background(Color.White.copy(alpha = 0.8f), RoundedCornerShape(4.dp))
.border(1.dp, Color.White, RoundedCornerShape(4.dp)),
contentAlignment = Alignment.CenterStart
) {
Text(
"Authorized Signature",
color = Color.Black.copy(0.7f),
fontSize = 10.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
modifier = Modifier.padding(start = 12.dp)
)
}
Spacer(Modifier.width(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"CVC",
color = SpaceStart.copy(0.6f),
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 6.dp)
)
Box(
modifier = Modifier
.width(64.dp)
.height(36.dp)
.background(Color.White, RoundedCornerShape(6.dp))
.shadow(2.dp, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center
) {
Text(
"892",
color = ElectricAccent,
fontSize = 16.sp,
fontWeight = FontWeight.ExtraBold,
fontFamily = FontFamily.Monospace,
letterSpacing = 2.sp
)
}
}
}
Spacer(Modifier.weight(1f))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 24.dp)
) {
Text(
"CARD HOLDER",
color = SpaceStart.copy(0.5f),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp
)
Spacer(Modifier.height(4.dp))
Text(
"KYRIAKOS GEORGIOPOULOS",
color = SpaceStart,
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Serif,
letterSpacing = 0.5.sp,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}
}
}
}
/**
* Balance header with a subtle shimmering percentage badge.
*/
@Composable
fun BalanceSection() {
val infiniteTransition = rememberInfiniteTransition(label = "BadgeShimmer")
val shimmerTranslate by infiniteTransition.animateFloat(
initialValue = -100f,
targetValue = 400f,
animationSpec = infiniteRepeatable(
animation = tween(3500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "Shimmer"
)
val badgeBrush = Brush.linearGradient(
colors = listOf(Color(0xFFDCFCE7), Color(0xFFF0FDF4), Color(0xFFDCFCE7)),
start = Offset(shimmerTranslate, 0f),
end = Offset(shimmerTranslate + 50f, 100f),
tileMode = TileMode.Clamp
)
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Total Balance",
color = TextSecondary,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Spacer(modifier = Modifier.width(65.dp))
Text(
text = "$24,500.00",
color = SpaceStart,
fontSize = 34.sp,
fontWeight = FontWeight.ExtraBold,
letterSpacing = (-1).sp
)
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(badgeBrush)
.border(1.dp, Color(0xFF166534).copy(alpha = 0.1f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.ArrowUpward,
contentDescription = null,
tint = Color(0xFF166534),
modifier = Modifier.size(12.dp)
)
Spacer(Modifier.width(2.dp))
Text(
"2.4%",
color = Color(0xFF166534),
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
/**
* Scrollable list of recent transactions.
*/
@Composable
fun TransactionsList(modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp)
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Recent Transactions",
color = SpaceStart,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Text(
"See All",
color = ElectricAccent,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
}
}
item {
TransactionItem(
icon = Icons.Rounded.ShoppingCart,
title = "Whole Foods Market",
subtitle = "Groceries • Today",
amount = "-$124.50",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.Movie,
title = "Netflix Subscription",
subtitle = "Entertainment • Yesterday",
amount = "-$15.99",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.Bolt,
title = "Electric Bill",
subtitle = "Utilities • Feb 12",
amount = "-$85.00",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.AttachMoney,
title = "Salary Deposit",
subtitle = "Income • Feb 01",
amount = "+$4,250.00",
isPositive = true,
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.DirectionsCar,
title = "Uber Ride",
subtitle = "Transport • Jan 30",
amount = "-$24.20",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.PhoneIphone,
title = "Apple Store",
subtitle = "Electronics • Jan 28",
amount = "-$999.00",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.FitnessCenter,
title = "Equinox Gym",
subtitle = "Health • Jan 25",
amount = "-$180.00",
iconColor = SpaceStart
)
}
item {
TransactionItem(
icon = Icons.Rounded.MusicNote,
title = "Spotify Premium",
subtitle = "Subscription • Jan 24",
amount = "-$12.99",
iconColor = SpaceStart
)
}
}
}
/**
* Single transaction row.
*/
@Composable
fun TransactionItem(
icon: ImageVector,
title: String,
subtitle: String,
amount: String,
isPositive: Boolean = false,
iconColor: Color = SpaceStart
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.shadow(
elevation = 2.dp,
shape = RoundedCornerShape(14.dp),
spotColor = Color.Black.copy(0.1f)
)
.background(Color.White, RoundedCornerShape(14.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
}
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, color = SpaceStart, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text(subtitle, color = TextSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
Text(
amount,
color = if (isPositive) Color(0xFF166534) else SpaceStart,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
/**
* Circular contact chip used in the expanded island contact row.
*/
@Composable
fun ContactCircle(name: String, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(contentAlignment = Alignment.TopEnd) {
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(Brush.verticalGradient(listOf(color, color.copy(alpha = 0.6f)))),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Rounded.Person,
null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Box(
modifier = Modifier
.padding(2.dp)
.size(14.dp)
.border(2.dp, SpaceEnd, CircleShape)
.clip(CircleShape)
.background(Color(0xFF00E676))
)
}
Spacer(Modifier.height(8.dp))
Text(name, color = TextSecondary, fontSize = 12.sp)
}
}
/**
* Deterministic color selection used for placeholder avatars.
*/
fun getRandomColor(index: Int): Color {
val colors = listOf(
Color(0xFFFFCC80),
Color(0xFFEF9A9A),
Color(0xFF80CBC4),
Color(0xFF9FA8DA),
Color(0xFFB39DDB),
Color(0xFFFFAB91)
)
return colors[index % colors.size]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment