Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active February 20, 2026 20:52
Show Gist options
  • Select an option

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

Select an option

Save Kyriakos-Georgiopoulos/b2a163c65d8738c4c32d22173658c2d3 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
*
* 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 android.app.Activity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
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.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.rounded.AirlineSeatLegroomExtra
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.FlightTakeoff
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material.icons.rounded.Wallet
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
data class PlaneSeat(
val id: String,
val label: String,
val type: SeatClass,
val status: SeatStatus,
val x: Float,
val y: Float,
val isExitRow: Boolean = false,
val legroom: String = "31\"",
val price: Int = 120
)
enum class SeatClass { BUSINESS, ECONOMY, ECONOMY_PLUS }
enum class SeatStatus { AVAILABLE, OCCUPIED, SELECTED }
/**
* A highly interactive seat map visualization for an airline booking flow.
*
* This composable handles the full user journey:
* 1. Interactive Seat Selection (Pan/Zoom canvas).
* 2. Seat Detail Bottom Sheet with playful entrance/exit animations.
* 3. A "Takeoff" animation where the plane flies off-screen upon confirmation.
* 4. A transition to a final digital boarding pass.
*
* Note: This component manages global system UI visibility (Status Bar icons) internally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun SeatMap() {
val seats = remember { generatePreciseLayout() }
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var isInteracting by remember { mutableStateOf(false) }
var isConfirmed by remember { mutableStateOf(false) }
var showBoardingPass by remember { mutableStateOf(false) }
var confirmedSeat by remember { mutableStateOf<PlaneSeat?>(null) }
var isHeaderExpanded by remember { mutableStateOf(false) }
val view = LocalView.current
DisposableEffect(showBoardingPass) {
val window = (view.context as? Activity)?.window
if (window != null) {
val wic = WindowCompat.getInsetsController(window, view)
wic.isAppearanceLightStatusBars = !showBoardingPass
}
onDispose {
val window = (view.context as? Activity)?.window
if (window != null) {
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
}
}
}
val minZoom = 0.5f
val maxZoom = 3.0f
var layoutReady by remember { mutableStateOf(false) }
var selectedSeat by remember { mutableStateOf<PlaneSeat?>(null) }
val textMeasurer = rememberTextMeasurer()
val sharedPath = remember { Path() }
val bgColor = Color(0xFFF1F5F9)
LaunchedEffect(isConfirmed) {
if (isConfirmed) {
delay(500)
val startScale = scale
val startOffset = offset
val zoomOutTargetScale = 0.35f
val zoomOutTargetY = startOffset.y + 300f
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(
durationMillis = 1200,
easing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f)
)
) { value, _ ->
scale = startScale + (zoomOutTargetScale - startScale) * value
val currentY = startOffset.y + (zoomOutTargetY - startOffset.y) * value
offset = Offset(startOffset.x, currentY)
}
delay(10)
val launchStartOffset = offset
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(
durationMillis = 1000,
easing = CubicBezierEasing(0.5f, 0.0f, 0.8f, 0.5f)
)
) { value, _ ->
val targetY = launchStartOffset.y - 6000f
val newY = launchStartOffset.y + (targetY - launchStartOffset.y) * value
offset = Offset(launchStartOffset.x, newY)
scale = zoomOutTargetScale * (1f - (value * 0.1f))
}
showBoardingPass = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(bgColor)
.onSizeChanged { layoutSize ->
if (layoutReady) return@onSizeChanged
val fuselageWidth = 900f
val screenWidth = layoutSize.width.toFloat()
val idealScale = (screenWidth * 0.85f) / fuselageWidth
scale = idealScale
offset = Offset(layoutSize.width / 2f, layoutSize.height / 3f)
layoutReady = true
}
.pointerInput(Unit) {
detectTapGestures { tapOffset ->
if (isConfirmed) return@detectTapGestures
val localTap = (tapOffset - offset) / scale
val hitSeat = seats.find { seat ->
val seatW = 95f
val seatH = 105f
localTap.x >= seat.x && localTap.x <= seat.x + seatW &&
localTap.y >= seat.y && localTap.y <= seat.y + seatH
}
if (hitSeat != null && hitSeat.status == SeatStatus.AVAILABLE) {
selectedSeat = if (selectedSeat?.id == hitSeat.id) null else hitSeat
if (selectedSeat != null) isHeaderExpanded = false
} else {
selectedSeat = null
}
}
}
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { centroid, pan, zoom, _ ->
if (isConfirmed) return@detectTransformGestures
isInteracting = true
val oldScale = scale
val newScale = (scale * zoom).coerceIn(minZoom, maxZoom)
val contentCentroid = (centroid - offset) / oldScale
scale = newScale
offset = centroid - (contentCentroid * newScale) + pan
}
)
}
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.changes.all { !it.pressed }) {
isInteracting = false
}
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationX = offset.x
translationY = offset.y
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
}
) {
val drawCenterX = 0f
drawSoftWings(drawCenterX, wingY = 2200f)
drawRefinedFuselage(drawCenterX, length = 5600f, width = 840f)
seats.forEach { seat ->
if (seat.isExitRow && seat.label.contains("A")) {
drawExitDashes(drawCenterX, seat.y - 65f)
}
val isSelected = selectedSeat?.id == seat.id
drawModernSeat(
centerX = drawCenterX,
seat = seat,
isSelected = isSelected,
textMeasurer = textMeasurer,
reusablePath = sharedPath
)
}
}
AnimatedVisibility(
visible = selectedSeat != null && !isConfirmed,
enter = fadeIn(animationSpec = spring(stiffness = Spring.StiffnessLow)),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0F172A).copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { selectedSeat = null }
)
}
Column(
Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.statusBars))
AnimatedVisibility(
visible = selectedSeat == null && (!isInteracting || isHeaderExpanded) && !isConfirmed,
enter = slideInVertically { -it } + fadeIn(animationSpec = tween(500)),
exit = slideOutVertically { -it } + fadeOut(animationSpec = tween(300))
) {
ModernFloatingHeader(
isExpanded = isHeaderExpanded,
onExpandChange = { isHeaderExpanded = it }
)
}
Spacer(Modifier.weight(1f))
AnimatedVisibility(
visible = selectedSeat != null && !isConfirmed,
enter = slideInVertically(
initialOffsetY = { it },
animationSpec = spring(dampingRatio = 0.8f, stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = slideOutVertically(
targetOffsetY = { it },
animationSpec = spring(dampingRatio = 1f, stiffness = Spring.StiffnessLow)
) + fadeOut()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.shadow(
50.dp,
spotColor = Color(0xFF000000),
shape = RoundedCornerShape(40.dp)
),
shape = RoundedCornerShape(40.dp),
border = BorderStroke(1.dp, Color(0xFF334155)),
colors = CardDefaults.cardColors(containerColor = Color(0xFF0F172A))
) {
AnimatedContent(
targetState = selectedSeat,
label = "SeatAnimation"
) { seat ->
if (seat != null) {
SeatInfoContent(
seat = seat,
onClose = { selectedSeat = null },
onConfirm = {
confirmedSeat = seat
isConfirmed = true
}
)
}
}
}
}
}
}
if (showBoardingPass && confirmedSeat != null) {
BoardingPassTicket(seat = confirmedSeat!!)
}
}
LaunchedEffect(scale, offset) {
if (isInteracting) {
delay(150)
isInteracting = false
}
}
}
/**
* The final screen shown after confirmation.
* Displays flight details and QR code in a split-layout ticket style.
*/
@Composable
fun BoardingPassTicket(seat: PlaneSeat) {
var headerVisible by remember { mutableStateOf(false) }
var bodyVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
headerVisible = true
delay(250)
bodyVisible = true
}
val fluidEasing = FastOutSlowInEasing
Column(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(
visible = headerVisible,
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(durationMillis = 800, easing = fluidEasing)
),
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(420.dp)
.background(
Brush.verticalGradient(
listOf(Color(0xFF0F172A), Color(0xFF1E1B4B))
),
shape = RectangleShape
)
) {
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.05f }) {
val space = 40f
for (x in 0..(size.width / space).toInt()) {
for (y in 0..(size.height / space).toInt()) {
drawCircle(
Color.White,
radius = 2f,
center = Offset(x * space, y * space)
)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
AnimatedVisibility(
visible = headerVisible,
enter = fadeIn(tween(600, delayMillis = 200))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
"LHR",
fontSize = 42.sp,
fontWeight = FontWeight.Black,
color = Color.White
)
Spacer(Modifier.width(24.dp))
Icon(
Icons.Rounded.FlightTakeoff,
null,
tint = Color(0xFFA78BFA),
modifier = Modifier.size(36.dp)
)
Spacer(Modifier.width(24.dp))
Text(
"HND",
fontSize = 42.sp,
fontWeight = FontWeight.Black,
color = Color.White
)
}
}
AnimatedVisibility(
visible = headerVisible,
enter = fadeIn(tween(600, delayMillis = 400))
) {
Text(
"Feb 28, 2026 • 12h 40m",
color = Color(0xFF94A3B8),
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(Modifier.height(8.dp))
AnimatedVisibility(
visible = headerVisible,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically(
tween(
600,
delayMillis = 500
)
) { 20 }) {
HorizontalDivider(
color = Color(0xFF334155).copy(alpha = 0.5f)
)
}
Spacer(Modifier.height(8.dp))
AnimatedVisibility(
visible = headerVisible,
enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically(
tween(
600,
delayMillis = 600
)
) { 20 }) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
DetailColumnLarge("Flight", "JL44", Alignment.Start)
DetailColumnLarge(
"Class",
if (seat.type == SeatClass.ECONOMY_PLUS) "Eco +" else "Eco",
Alignment.CenterHorizontally
)
DetailColumnLarge("Seat", seat.label, Alignment.End)
}
}
AnimatedVisibility(
visible = headerVisible,
enter = fadeIn(tween(600, delayMillis = 800)) + slideInVertically(
tween(
600,
delayMillis = 800
)
) { 20 }) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
DetailColumnLarge("Passenger", "Kyriakos G.", Alignment.Start)
DetailColumnLarge("Gate", "42", Alignment.CenterHorizontally)
DetailColumnLarge("Time", "10:40", Alignment.End)
}
}
Spacer(Modifier.height(12.dp))
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
androidx.compose.animation.AnimatedVisibility(
visible = bodyVisible,
enter = slideInVertically(
initialOffsetY = { 200 },
animationSpec = tween(durationMillis = 800, easing = fluidEasing)
) + fadeIn(tween(500)),
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.weight(1f))
Box(
modifier = Modifier
.size(240.dp)
.shadow(24.dp, RoundedCornerShape(32.dp), spotColor = Color(0xFFCBD5E1))
.clip(RoundedCornerShape(32.dp))
.background(Color.White)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
GenerativeGradientQrCode()
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Rounded.FlightTakeoff,
null,
tint = Color.Black,
modifier = Modifier.size(28.dp)
)
}
}
Spacer(Modifier.weight(1f))
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val buttonScale by animateFloatAsState(targetValue = if (isPressed) 0.95f else 1f)
Button(
onClick = {},
interactionSource = interactionSource,
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.graphicsLayer {
scaleX = buttonScale
scaleY = buttonScale
}
.shadow(
16.dp,
RoundedCornerShape(20.dp),
spotColor = Color(0xFF4F46E5)
),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
shape = RoundedCornerShape(20.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(Color(0xFF4F46E5), Color(0xFF06B6D4))
)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Wallet, null, tint = Color.White)
Spacer(Modifier.width(12.dp))
Text(
"Add to Wallet",
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(Modifier
.navigationBarsPadding()
.height(48.dp))
}
}
}
}
}
@Composable
fun DetailColumnLarge(
label: String,
value: String,
alignment: Alignment.Horizontal
) {
Column(horizontalAlignment = alignment) {
Text(label, color = Color(0xFF94A3B8), fontSize = 13.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(4.dp))
Text(value, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
fun GenerativeGradientQrCode() {
val brush = Brush.linearGradient(
colors = listOf(Color(0xFF2E1065), Color(0xFF3B82F6), Color(0xFF06B6D4)),
start = Offset.Zero,
end = Offset(500f, 500f)
)
Spacer(
modifier = Modifier
.fillMaxSize()
.padding(12.dp)
.drawWithCache {
val gridSize = 25
val cellSize = size.width / gridSize
val r = Random(12345)
val gridPoints = mutableListOf<Offset>()
for (row in 0 until gridSize) {
for (col in 0 until gridSize) {
val inTopLeft = row < 8 && col < 8
val inTopRight = row < 8 && col >= gridSize - 8
val inBottomLeft = row >= gridSize - 8 && col < 8
if (!inTopLeft && !inTopRight && !inBottomLeft) {
if (r.nextBoolean()) {
gridPoints.add(Offset(col * cellSize + 1.5f, row * cellSize + 1.5f))
}
}
}
}
onDrawBehind {
fun drawEye(row: Int, col: Int) {
val offset = Offset(col * cellSize, row * cellSize)
val size = 7 * cellSize
drawRoundRect(
brush = brush,
topLeft = offset,
size = Size(size, size),
cornerRadius = CornerRadius(16f)
)
drawRect(
Color.White,
topLeft = offset + Offset(cellSize, cellSize),
size = Size(size - 2 * cellSize, size - 2 * cellSize)
)
drawRoundRect(
brush = brush,
topLeft = offset + Offset(2 * cellSize, 2 * cellSize),
size = Size(size - 4 * cellSize, size - 4 * cellSize),
cornerRadius = CornerRadius(8f)
)
}
drawEye(0, 0)
drawEye(0, gridSize - 7)
drawEye(gridSize - 7, 0)
gridPoints.forEach { point ->
drawRoundRect(
brush = brush,
topLeft = point,
size = Size(cellSize - 3f, cellSize - 3f),
cornerRadius = CornerRadius(4f)
)
}
}
}
)
}
fun DrawScope.drawSoftWings(centerX: Float, wingY: Float) {
val wingSpan = 1700f
val wingRoot = 380f
val wingTipY = wingY + 1100f
val wingPath = Path().apply {
moveTo(centerX + wingRoot, wingY)
lineTo(centerX + wingSpan, wingTipY)
quadraticBezierTo(
centerX + wingSpan + 50f,
wingTipY + 200f,
centerX + wingSpan - 50f,
wingTipY + 250f
)
lineTo(centerX + wingRoot, wingY + 900f)
moveTo(centerX - wingRoot, wingY)
lineTo(centerX - wingSpan, wingTipY)
quadraticBezierTo(
centerX - wingSpan - 50f,
wingTipY + 200f,
centerX - wingSpan + 50f,
wingTipY + 250f
)
lineTo(centerX - wingRoot, wingY + 900f)
}
drawPath(wingPath, Color.White, style = Fill)
drawPath(
wingPath,
brush = Brush.linearGradient(
colors = listOf(Color(0xFF2E1065), Color(0xFF3B82F6)),
start = Offset(centerX, wingY),
end = Offset(centerX + wingSpan, wingTipY)
),
style = Stroke(width = 3f)
)
}
fun DrawScope.drawRefinedFuselage(centerX: Float, length: Float, width: Float) {
val noseHeight = 450f
val taperStart = length - 300f
val fuselagePath = Path().apply {
moveTo(centerX, -noseHeight)
cubicTo(
centerX + width * 0.5f,
-noseHeight,
centerX + width / 2,
0f,
centerX + width / 2,
600f
)
lineTo(centerX + width / 2, taperStart)
cubicTo(
centerX + width / 2,
length,
centerX + width / 6,
length + 150f,
centerX,
length + 150f
)
cubicTo(
centerX - width / 6,
length + 150f,
centerX - width / 2,
length,
centerX - width / 2,
taperStart
)
lineTo(centerX - width / 2, 600f)
cubicTo(centerX - width / 2, 0f, centerX - width * 0.5f, -noseHeight, centerX, -noseHeight)
}
drawPath(
fuselagePath,
Color.Black.copy(alpha = 0.05f),
style = Stroke(width = 60f, cap = StrokeCap.Round)
)
drawPath(fuselagePath, Color.White, style = Fill)
drawPath(
fuselagePath,
brush = Brush.linearGradient(
colors = listOf(
Color(0xFF312E81),
Color(0xFFA78BFA).copy(alpha = 0.5f),
Color(0xFF312E81)
),
start = Offset(centerX, -noseHeight),
end = Offset(centerX, length)
),
style = Stroke(width = 5f)
)
val windowPath = Path().apply {
moveTo(centerX - 10f, -noseHeight + 120f)
lineTo(centerX - 90f, -noseHeight + 140f)
quadraticBezierTo(centerX - 120f, -noseHeight + 180f, centerX - 100f, -noseHeight + 210f)
lineTo(centerX - 15f, -noseHeight + 170f)
close()
moveTo(centerX + 10f, -noseHeight + 120f)
lineTo(centerX + 90f, -noseHeight + 140f)
quadraticBezierTo(centerX + 120f, -noseHeight + 180f, centerX + 100f, -noseHeight + 210f)
lineTo(centerX + 15f, -noseHeight + 170f)
close()
}
drawPath(
windowPath,
Brush.linearGradient(
listOf(Color(0xFF020617), Color(0xFF0EA5E9), Color(0xFF0F172A)),
start = Offset(centerX - 100f, -noseHeight + 100f),
end = Offset(centerX + 100f, -noseHeight + 220f)
),
style = Fill
)
val glintPath = Path().apply {
moveTo(centerX + 20f, -noseHeight + 130f)
quadraticBezierTo(centerX + 60f, -noseHeight + 135f, centerX + 80f, -noseHeight + 150f)
quadraticBezierTo(centerX + 50f, -noseHeight + 145f, centerX + 20f, -noseHeight + 130f)
moveTo(centerX - 20f, -noseHeight + 130f)
quadraticBezierTo(centerX - 60f, -noseHeight + 135f, centerX - 80f, -noseHeight + 150f)
quadraticBezierTo(centerX - 50f, -noseHeight + 145f, centerX - 20f, -noseHeight + 130f)
}
drawPath(glintPath, Color.White.copy(alpha = 0.7f), style = Fill)
drawArc(
Color(0xFFCBD5E1),
0f,
180f,
false,
Offset(centerX - 40f, -noseHeight + 30f),
Size(80f, 40f),
style = Stroke(width = 2f)
)
}
fun DrawScope.drawExitDashes(centerX: Float, y: Float) {
drawLine(
brush = Brush.horizontalGradient(
colors = listOf(Color(0xFFFF6B6B), Color(0xFFFF8F8F), Color(0xFFFF6B6B))
),
start = Offset(centerX - 350f, y),
end = Offset(centerX + 350f, y),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 15f)),
strokeWidth = 3f
)
}
fun DrawScope.drawModernSeat(
centerX: Float,
seat: PlaneSeat,
isSelected: Boolean,
textMeasurer: TextMeasurer,
reusablePath: Path
) {
val width = 95f
val height = 105f
val absX = centerX + seat.x
val absY = seat.y
val cornerRadius = CornerRadius(22f)
if (seat.status == SeatStatus.OCCUPIED) {
drawRoundRect(Color(0xFFF1F5F9), Offset(absX, absY), Size(width, height), cornerRadius)
clipRect(left = absX, top = absY, right = absX + width, bottom = absY + height) {
drawLine(
Color(0xFFCBD5E1),
Offset(absX + 20f, absY + height - 20f),
Offset(absX + width - 20f, absY + 20f),
strokeWidth = 3f
)
}
} else {
if (isSelected) {
drawRoundRect(
Brush.linearGradient(
listOf(Color(0xFF2E1065), Color(0xFF3B82F6)),
start = Offset(absX, absY),
end = Offset(absX + width, absY + height)
),
topLeft = Offset(absX, absY),
size = Size(width, height),
cornerRadius = cornerRadius
)
} else {
drawRoundRect(Color.White, Offset(absX, absY), Size(width, height), cornerRadius)
drawRoundRect(
Brush.verticalGradient(
colors = if (seat.type == SeatClass.ECONOMY_PLUS) listOf(
Color(0xFF6366F1),
Color(0xFF8B5CF6)
) else listOf(Color(0xFFCBD5E1), Color(0xFF94A3B8))
),
topLeft = Offset(absX, absY),
size = Size(width, height),
cornerRadius = cornerRadius,
style = Stroke(width = if (seat.type == SeatClass.ECONOMY_PLUS) 3f else 2f)
)
}
reusablePath.rewind()
reusablePath.moveTo(absX + 16f, absY + 34f)
reusablePath.quadraticBezierTo(absX + width / 2, absY + 24f, absX + width - 16f, absY + 34f)
if (!isSelected) {
drawPath(reusablePath, Color(0xFFCBD5E1), style = Stroke(width = 2f))
} else {
drawPath(reusablePath, Color.White.copy(alpha = 0.4f), style = Stroke(width = 2f))
}
val textResult = textMeasurer.measure(
text = seat.label,
style = TextStyle(
color = if (isSelected) Color.White else Color(0xFF334155),
fontWeight = if (isSelected) FontWeight.Black else FontWeight.SemiBold,
fontSize = 14.sp
)
)
drawText(
textResult,
topLeft = Offset(
absX + width / 2 - textResult.size.width / 2,
(absY + height * 0.62f) - textResult.size.height / 2
)
)
}
}
@Composable
fun SeatInfoContent(
seat: PlaneSeat,
onClose: () -> Unit,
onConfirm: () -> Unit
) {
var contentVisible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
LaunchedEffect(Unit) {
contentVisible = true
}
fun performExit(afterExit: () -> Unit) {
contentVisible = false
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch {
delay(400)
afterExit()
}
}
Column(Modifier.padding(32.dp)) {
AnimatableItem(visible = contentVisible, enterDelay = 0, exitDelay = 200) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(20.dp))
.background(
Brush.linearGradient(
listOf(
Color(0xFF2E1065),
Color(0xFF3B82F6)
)
)
), contentAlignment = Alignment.Center
) {
Text(
seat.label,
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Black
)
}
Spacer(Modifier.width(20.dp))
Column {
Text(
if (seat.type == SeatClass.ECONOMY_PLUS) "Economy +" else "Economy",
fontWeight = FontWeight.Bold,
color = Color.White,
fontSize = 22.sp
)
Text(
"Boeing 787-9",
color = Color(0xFF94A3B8),
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
}
}
IconButton(
onClick = { performExit(onClose) },
modifier = Modifier
.size(44.dp)
.border(1.dp, Color(0xFF334155), CircleShape)
.background(Color(0xFF1E293B), CircleShape)
) {
Icon(
Icons.Rounded.Close,
null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
}
Spacer(Modifier.height(36.dp))
AnimatableItem(visible = contentVisible, enterDelay = 100, exitDelay = 150) {
HorizontalDivider(color = Color(0xFF334155))
}
Spacer(Modifier.height(36.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
AnimatableItem(visible = contentVisible, enterDelay = 150, exitDelay = 100) {
InfoItem(Icons.Rounded.AirlineSeatLegroomExtra, "Legroom", seat.legroom)
}
AnimatableItem(visible = contentVisible, enterDelay = 250, exitDelay = 100) {
InfoItem(Icons.Rounded.Bolt, "Power", "USB-C")
}
AnimatableItem(visible = contentVisible, enterDelay = 350, exitDelay = 100) {
InfoItem(Icons.Rounded.Wifi, "Wi-Fi", "Starlink")
}
}
Spacer(Modifier.height(48.dp))
AnimatableItem(visible = contentVisible, enterDelay = 450, exitDelay = 0) {
Button(
onClick = { performExit(onConfirm) },
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.shadow(16.dp, RoundedCornerShape(20.dp), spotColor = Color(0xFF4F46E5)),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
shape = RoundedCornerShape(20.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.horizontalGradient(
listOf(
Color(0xFF4F46E5),
Color(0xFF06B6D4)
)
)
),
contentAlignment = Alignment.Center
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Confirm Selection ",
fontSize = 17.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
"$${seat.price}",
fontSize = 17.sp,
fontWeight = FontWeight.Black,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
}
}
@Composable
fun AnimatableItem(
visible: Boolean,
enterDelay: Int,
exitDelay: Int,
content: @Composable () -> Unit
) {
val scale by animateFloatAsState(
targetValue = if (visible) 1f else 0.4f,
animationSpec = if (visible) spring(
dampingRatio = 0.6f,
stiffness = Spring.StiffnessLow
) else tween(
durationMillis = 250,
delayMillis = exitDelay,
easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
),
label = "scale"
)
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(
durationMillis = if (visible) 300 else 150,
delayMillis = if (visible) enterDelay else exitDelay
),
label = "alpha"
)
if (alpha > 0f || visible) {
Box(modifier = Modifier.graphicsLayer {
scaleX = scale; scaleY = scale; this.alpha = alpha
}) {
content()
}
} else {
Box(modifier = Modifier.graphicsLayer { this.alpha = 0f }) { content() }
}
}
@Composable
fun InfoItem(icon: ImageVector, label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xFF1E293B))
.border(1.dp, Color(0xFF334155), RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center
) {
Icon(icon, null, tint = Color(0xFFA78BFA), modifier = Modifier.size(26.dp))
}
Spacer(Modifier.height(14.dp))
Text(label, color = Color(0xFF64748B), fontSize = 13.sp, fontWeight = FontWeight.Medium)
Text(value, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
}
}
fun generatePreciseLayout(): List<PlaneSeat> {
val list = mutableListOf<PlaneSeat>()
var yCursor = 180f
val random = Random(42)
val rightX = listOf(30f, 135f, 240f)
val leftX = listOf(-345f, -240f, -135f)
val allX = leftX + rightX
val labels = listOf("A", "B", "C", "D", "E", "F")
for (row in 5..40) {
val isExit = row == 16 || row == 17
if (isExit) yCursor += 100f
allX.forEachIndexed { index, x ->
val status = if (random.nextFloat() > 0.7) SeatStatus.OCCUPIED else SeatStatus.AVAILABLE
val legroom = if (isExit) "36\"" else "31\""
val price = if (isExit) 165 else 120
val type = if (isExit || row < 8) SeatClass.ECONOMY_PLUS else SeatClass.ECONOMY
val finalPrice = if (type == SeatClass.ECONOMY_PLUS) price + 30 else price
list.add(
PlaneSeat(
"$row-${labels[index]}",
"${row}${labels[index]}",
type,
status,
x,
yCursor,
isExit,
legroom,
finalPrice
)
)
}
yCursor += 140f
}
return list
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ModernFloatingHeader(
isExpanded: Boolean,
onExpandChange: (Boolean) -> Unit
) {
val scope = rememberCoroutineScope()
val expansionProgress = remember { Animatable(0f) }
val collapsedWidth = 220.dp
val collapsedHeight = 56.dp
val config = LocalConfiguration.current
val expandedWidth = (config.screenWidthDp.dp - 32.dp)
val expandedHeight = 340.dp
val fluidEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
LaunchedEffect(isExpanded) {
expansionProgress.animateTo(
targetValue = if (isExpanded) 1f else 0f,
animationSpec = tween(durationMillis = 600, easing = fluidEasing)
)
}
val currentWidth by remember {
derivedStateOf {
lerp(
collapsedWidth,
expandedWidth,
expansionProgress.value
)
}
}
val currentHeight by remember {
derivedStateOf {
lerp(
collapsedHeight,
expandedHeight,
expansionProgress.value
)
}
}
val cornerRadius by remember { derivedStateOf { lerp(28.dp, 32.dp, expansionProgress.value) } }
val collapsedContentAlpha = (1f - expansionProgress.value * 3f).coerceIn(0f, 1f)
val expandedContentAlpha = ((expansionProgress.value - 0.3f) * 2f).coerceIn(0f, 1f)
Box(
modifier = Modifier
.padding(top = 0.dp)
.fillMaxWidth()
.padding(horizontal = if (isExpanded) 16.dp else 0.dp),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(currentWidth)
.height(currentHeight)
.shadow(20.dp, RoundedCornerShape(cornerRadius), spotColor = Color(0x80000000))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onExpandChange(!isExpanded) },
shape = RoundedCornerShape(cornerRadius),
color = Color.Transparent,
border = BorderStroke(1.dp, Color(0xFF4338CA).copy(alpha = 0.5f))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.linearGradient(listOf(Color(0xFF0F172A), Color(0xFF312E81))))
) {
if (collapsedContentAlpha > 0) {
Row(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = collapsedContentAlpha },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
"LHR",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(Modifier.width(12.dp))
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
null,
tint = Color(0xFF94A3B8),
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(12.dp))
Text(
"HND",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(Modifier.width(16.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(20))
.background(Color(0xFF000000).copy(alpha = 0.3f))
.border(
1.dp,
Brush.linearGradient(
listOf(
Color(0xFFA78BFA),
Color(0xFF22D3EE)
)
),
RoundedCornerShape(20)
)
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Text(
"12h 40m",
style = TextStyle(
brush = Brush.linearGradient(
colors = listOf(
Color(0xFFA78BFA), Color(0xFF22D3EE)
)
), fontSize = 11.sp, fontWeight = FontWeight.ExtraBold
)
)
}
}
}
if (expandedContentAlpha > 0) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.graphicsLayer {
alpha = expandedContentAlpha; translationY =
(1f - expandedContentAlpha) * 20f
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"LHR",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(Modifier.width(16.dp))
Icon(
Icons.Rounded.FlightTakeoff,
null,
tint = Color(0xFFA78BFA),
modifier = Modifier.size(28.dp)
)
Spacer(Modifier.width(16.dp))
Text(
"HND",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
Text(
"Feb 28, 2026 • 12h 40m",
color = Color(0xFF94A3B8),
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = Color(0xFF334155))
Spacer(Modifier.height(24.dp))
Row(Modifier.fillMaxWidth()) {
DetailColumn("Flight", "JL44", Alignment.Start, Modifier.weight(1f))
DetailColumn(
"Class",
"Economy",
Alignment.CenterHorizontally,
Modifier.weight(1f)
)
DetailColumn("Seat", "--", Alignment.End, Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth()) {
DetailColumn(
"Passenger",
"Kyriakos G.",
Alignment.Start,
Modifier.weight(1f)
)
DetailColumn(
"Terminal",
"3",
Alignment.CenterHorizontally,
Modifier.weight(1f)
)
DetailColumn("Gate", "42", Alignment.End, Modifier.weight(1f))
}
Spacer(Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Rounded.QrCode2,
null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(12.dp))
Column {
Text(
"Boarding",
color = Color(0xFF94A3B8),
fontSize = 11.sp
)
Text(
"10:40 AM",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(100))
.background(Color(0xFF059669).copy(alpha = 0.2f))
.border(1.dp, Color(0xFF059669), RoundedCornerShape(100))
.padding(horizontal = 12.dp, vertical = 6.dp)
) {
Text(
"On Time",
color = Color(0xFF34D399),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
}
}
@Composable
fun DetailColumn(
label: String,
value: String,
alignment: Alignment.Horizontal = Alignment.CenterHorizontally,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = alignment
) {
Text(label, color = Color(0xFF94A3B8), fontSize = 12.sp, fontWeight = FontWeight.Medium)
Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
@Preview
@Composable
fun PreviewMotion() {
SeatMap()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment