Last active
February 20, 2026 20:52
-
-
Save Kyriakos-Georgiopoulos/b2a163c65d8738c4c32d22173658c2d3 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 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