Forked from Kyriakos-Georgiopoulos/CinemaBookingExperience.kt
Created
May 1, 2026 08:16
-
-
Save brianmwadime/c219dacf23d0a27a2f85d49b54ac4c4d 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 android.content.ContextWrapper | |
| import android.view.TextureView | |
| import android.view.ViewGroup | |
| import androidx.annotation.OptIn | |
| import androidx.compose.animation.AnimatedContent | |
| import androidx.compose.animation.AnimatedVisibility | |
| import androidx.compose.animation.BoundsTransform | |
| import androidx.compose.animation.ExperimentalSharedTransitionApi | |
| import androidx.compose.animation.SharedTransitionLayout | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.LinearOutSlowInEasing | |
| import androidx.compose.animation.core.RepeatMode | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateFloat | |
| 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.fadeIn | |
| import androidx.compose.animation.fadeOut | |
| import androidx.compose.animation.slideInVertically | |
| import androidx.compose.animation.slideOutVertically | |
| import androidx.compose.animation.togetherWith | |
| 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.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.BoxScope | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.statusBarsPadding | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material3.Card | |
| import androidx.compose.material3.CardDefaults | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.DisposableEffect | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableFloatStateOf | |
| import androidx.compose.runtime.mutableIntStateOf | |
| 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.blur | |
| 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.Rect | |
| 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.Outline | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.PathEffect | |
| import androidx.compose.ui.graphics.Shape | |
| 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.Stroke | |
| import androidx.compose.ui.graphics.drawscope.withTransform | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.layout.onSizeChanged | |
| import androidx.compose.ui.platform.LocalContext | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.platform.LocalView | |
| import androidx.compose.ui.text.TextStyle | |
| import androidx.compose.ui.text.font.FontFamily | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.unit.Density | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.IntOffset | |
| import androidx.compose.ui.unit.LayoutDirection | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.compose.ui.util.fastForEach | |
| import androidx.compose.ui.viewinterop.AndroidView | |
| import androidx.core.view.WindowCompat | |
| import androidx.media3.common.MediaItem | |
| import androidx.media3.common.Player | |
| import androidx.media3.common.util.UnstableApi | |
| import androidx.media3.exoplayer.ExoPlayer | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.launch | |
| import kotlin.math.PI | |
| import kotlin.math.abs | |
| import kotlin.math.roundToInt | |
| import kotlin.math.sin | |
| import kotlin.math.sqrt | |
| import kotlin.random.Random | |
| // ─── Shared-element seat → ticket-stub spec ────────────────────────────────── | |
| /** | |
| * The arc the seat icon travels when it morphs into the ticket stub. | |
| * | |
| * A soft spring (low stiffness, 0.7 damping) so the seat doesn't snap — it | |
| * *settles* into the ticket like a paper coupon dropped into a slot. | |
| */ | |
| private val SeatToTicketBoundsTransform: BoundsTransform = BoundsTransform { _, _ -> | |
| spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessLow) | |
| } | |
| private const val SEAT_TICKET_KEY = "seat-stub" | |
| // ─── Ticket dimensions ─────────────────────────────────────────────────────── | |
| private val TicketWidth = 360.dp | |
| private val TicketHalfHeight = 270.dp | |
| private val TicketTotalHeight = TicketHalfHeight * 2 | |
| private val TicketCornerRadius = 32.dp | |
| private val TicketSideNotchRadius = 12.dp | |
| // ─── Screen / reflection geometry (pre-density local pixels) ───────────────── | |
| // All values are pre-density "design pixels" inside the zoomable content space. | |
| // The outer Box's graphicsLayer scales them to screen pixels. | |
| private const val ScreenBottomWidth = 540f | |
| private const val ScreenPerspective = 1.35f | |
| private const val ScreenTopWidth = ScreenBottomWidth * ScreenPerspective | |
| private const val ScreenCanvasHeight = 145f | |
| private const val ScreenCurveDepth = 50f | |
| private const val ReflectionHeight = 250f | |
| private const val ScreenInsetX = (ScreenTopWidth - ScreenBottomWidth) / 2f | |
| private const val VideoHeight = ScreenTopWidth * (9f / 16f) | |
| private const val MainVideoOffsetY = (ScreenCanvasHeight - VideoHeight) / 2f | |
| private const val ReflectionVideoOffsetY = MainVideoOffsetY + ScreenCurveDepth | |
| // ─── Seat geometry / hit testing ───────────────────────────────────────────── | |
| private const val SeatWidthPx = 38f | |
| private const val SeatHeightPx = 34f | |
| private const val SeatHitRadiusSq = 30f * 30f // squared radius — skip sqrt on every tap | |
| private const val SeatGlideTravelPx = 1500f // explosion distance for unselected seats | |
| private val SeatColorAvailable = Color(color = 0xFF64748B) | |
| private val SeatColorOccupied = Color(color = 0xFF1E293B) | |
| /** | |
| * The U-shape that draws every theater seat (back rails + cushion). | |
| * | |
| * Allocated once at class-load. ~95 seats redrawn every animation frame | |
| * would otherwise allocate ~5,700 [Path] objects per second on the GC. | |
| * Compose's [DrawScope.drawPath] only reads the path; never mutate this. | |
| */ | |
| private val SeatBackPath: Path = Path().apply { | |
| val w = SeatWidthPx | |
| val h = SeatHeightPx | |
| moveTo(x = 0f, y = 0f) | |
| lineTo(x = 0f, y = h - 6f) | |
| quadraticTo(x1 = 0f, y1 = h, x2 = 6f, y2 = h) | |
| lineTo(x = w - 6f, y = h) | |
| quadraticTo(x1 = w, y1 = h, x2 = w, y2 = h - 6f) | |
| lineTo(x = w, y = 0f) | |
| } | |
| // ─── Shared brushes (Compose Color is a value class — these are cheap) ─────── | |
| private val PremiumGoldGradient = Brush.linearGradient( | |
| colors = listOf( | |
| Color(color = 0xFFFFD166), // bright highlight | |
| Color(color = 0xFFF4C430), // saffron core | |
| Color(color = 0xFFD4AF37) // metallic edge | |
| ) | |
| ) | |
| private val RoomBackgroundBrush = Brush.radialGradient( | |
| colors = listOf( | |
| Color(color = 0xFF16213E), | |
| Color(color = 0xFF080B12), | |
| Color(color = 0xFF000000) | |
| ), | |
| radius = 2000f | |
| ) | |
| private val ScreenBottomShadeBrush = Brush.verticalGradient( | |
| colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.4f)) | |
| ) | |
| /** | |
| * Gradient stops that mimic real *light scatter* from a screen reflection. | |
| * | |
| * Light obeys the inverse-square law: intensity falls off as `1 / d²`. | |
| * Sampling 50 stops over a normalized distance `[1, 3.2]` gives the | |
| * reflection a physically plausible falloff — bright near the screen, | |
| * fading rapidly into the floor — without baking a bitmap. | |
| * | |
| * Multiplied by `0.65` to keep the brightest sample inside a believable | |
| * ambient range. Cached at process load; safe to share across draws. | |
| */ | |
| private val InverseSquareReflectionStops: Array<Pair<Float, Color>> = run { | |
| val steps = 50 | |
| val scatteringRate = 2.2f | |
| Array(size = steps + 1) { i -> | |
| val fraction = i / steps.toFloat() | |
| val distance = 1f + (fraction * scatteringRate) | |
| val intensity = 1f / (distance * distance) | |
| val finalAlpha = (intensity * 0.65f).coerceIn(minimumValue = 0f, maximumValue = 1f) | |
| fraction to Color.Black.copy(alpha = finalAlpha) | |
| } | |
| } | |
| private val ReflectionEdgeMaskStops: Array<Pair<Float, Color>> = arrayOf( | |
| 0.0f to Color.Transparent, | |
| 0.08f to Color.Black, | |
| 0.92f to Color.Black, | |
| 1.0f to Color.Transparent, | |
| ) | |
| private val OriginTopLeft = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 0f) | |
| // ─── Stateless Shape singletons (Compose caches outline-per-size internally) ─ | |
| private val ImaxScreenShape: Shape = IMAXScreenShape() | |
| private val ReflectionShape: Shape = | |
| PhysicsReflectionShape(curveDepth = ScreenCurveDepth, insetX = ScreenInsetX) | |
| private val PremiumGoldTextStyle = TextStyle(brush = PremiumGoldGradient) | |
| // --- Data Models --- | |
| enum class SeatStatus { AVAILABLE, OCCUPIED, VIP } | |
| data class TheaterSeat( | |
| val id: String, val row: String, val number: Int, | |
| val status: SeatStatus, val x: Float, val y: Float, val angle: Float | |
| ) | |
| // --- Flawless Interlocking Shapes --- | |
| /** | |
| * The CinemaScope screen silhouette: a perspective-warped rectangle whose | |
| * top edge is *wider* than its bottom edge, with both horizontal edges | |
| * curved as quadratic Bézier arcs. | |
| */ | |
| class IMAXScreenShape : Shape { | |
| override fun createOutline( | |
| size: Size, | |
| layoutDirection: LayoutDirection, | |
| density: Density | |
| ): Outline { | |
| val path = Path().apply { | |
| val w = size.width | |
| val h = size.height | |
| val bottomW = w / 1.35f | |
| val insetX = (w - bottomW) / 2f | |
| val bottomCtrlY = h - h * (50f / 145f) | |
| val topCornerY = h * (25f / 145f) | |
| val topCtrlY = -h * (25f / 145f) | |
| moveTo(x = insetX, y = h) | |
| quadraticTo(x1 = w / 2f, y1 = bottomCtrlY, x2 = w - insetX, y2 = h) | |
| lineTo(x = w, y = topCornerY) | |
| quadraticTo(x1 = w / 2f, y1 = topCtrlY, x2 = 0f, y2 = topCornerY) | |
| close() | |
| } | |
| return Outline.Generic(path) | |
| } | |
| } | |
| /** | |
| * The mirror of the screen — but bowed downward like a still pool of water. | |
| */ | |
| class PhysicsReflectionShape(private val curveDepth: Float, private val insetX: Float) : Shape { | |
| override fun createOutline( | |
| size: Size, | |
| layoutDirection: LayoutDirection, | |
| density: Density | |
| ): Outline { | |
| return Outline.Generic(Path().apply { | |
| val w = size.width | |
| val h = size.height | |
| moveTo(x = insetX, y = curveDepth) | |
| quadraticTo(x1 = w / 2f, y1 = 0f, x2 = w - insetX, y2 = curveDepth) | |
| quadraticTo(x1 = w - (insetX * 0.1f), y1 = h * 0.5f, x2 = w, y2 = h) | |
| lineTo(x = 0f, y = h) | |
| quadraticTo(x1 = insetX * 0.1f, y1 = h * 0.5f, x2 = insetX, y2 = curveDepth) | |
| close() | |
| }) | |
| } | |
| } | |
| /** | |
| * A single-screen cinema booking flow with four physical "moments": | |
| * | |
| * 1. **Pre-show** — a 3-2-1 countdown plays inside an IMAX-shaped screen. | |
| * 2. **Selection** — pinch / pan to inspect the seat grid. | |
| * 3. **Confirmation** — the screen *collapses* with a CRT shutoff. | |
| * 4. **Ticket** — the chosen seat morphs into the ticket stub. | |
| */ | |
| @OptIn(ExperimentalSharedTransitionApi::class) | |
| @Composable | |
| fun CinemaBookingExperience() { | |
| val context = LocalContext.current | |
| val scope = rememberCoroutineScope() | |
| val view = LocalView.current | |
| // --- Dynamic Dark Theme Status Bar --- | |
| if (!view.isInEditMode) { | |
| LaunchedEffect(key1 = Unit) { | |
| var currentContext: android.content.Context = context | |
| var activity: Activity? = null | |
| while (currentContext is ContextWrapper) { | |
| if (currentContext is Activity) { | |
| activity = currentContext | |
| break | |
| } | |
| currentContext = currentContext.baseContext | |
| } | |
| activity?.window?.let { window -> | |
| window.statusBarColor = android.graphics.Color.parseColor("#16213E") | |
| WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false | |
| } | |
| } | |
| } | |
| val seats = remember(key1 = "modern_cinema_grid_v5") { generatePerfectCinemaSeats() } | |
| var initialScale by remember { mutableFloatStateOf(value = 0f) } | |
| var initialOffset by remember { mutableStateOf(value = Offset.Unspecified) } | |
| var scale by remember { mutableFloatStateOf(value = 1f) } | |
| var offset by remember { mutableStateOf(value = Offset.Unspecified) } | |
| var selectedSeat by remember { mutableStateOf<TheaterSeat?>(value = null) } | |
| var isConfirmed by remember { mutableStateOf(value = false) } | |
| var showTicket by remember { mutableStateOf(value = false) } | |
| var showThankYou by remember { mutableStateOf(value = false) } | |
| val crtProgress = remember { Animatable(initialValue = 0f) } | |
| val seatGlideProgress = remember { Animatable(initialValue = 0f) } | |
| val videoUrl = "https://www.w3schools.com/html/mov_bbb.mp4" | |
| var isCountdownActive by remember { mutableStateOf(value = true) } | |
| var currentCountdownNumber by remember { mutableIntStateOf(value = 3) } | |
| val mainPlayer = remember { | |
| ExoPlayer.Builder(context).build().apply { | |
| setMediaItem(MediaItem.fromUri(videoUrl)) | |
| repeatMode = Player.REPEAT_MODE_ALL | |
| volume = 0f | |
| prepare() | |
| } | |
| } | |
| val reflectionPlayer = remember { | |
| ExoPlayer.Builder(context).build().apply { | |
| setMediaItem(MediaItem.fromUri(videoUrl)) | |
| repeatMode = Player.REPEAT_MODE_ALL | |
| volume = 0f | |
| prepare() | |
| } | |
| } | |
| LaunchedEffect(key1 = Unit) { | |
| for (i in 3 downTo 1) { | |
| currentCountdownNumber = i | |
| delay(timeMillis = 1000) | |
| } | |
| isCountdownActive = false | |
| mainPlayer.playWhenReady = true | |
| reflectionPlayer.playWhenReady = true | |
| } | |
| DisposableEffect(key1 = Unit) { | |
| onDispose { | |
| mainPlayer.release() | |
| reflectionPlayer.release() | |
| } | |
| } | |
| LaunchedEffect(key1 = isConfirmed) { | |
| if (isConfirmed) { | |
| showThankYou = true | |
| delay(timeMillis = 1200) | |
| crtProgress.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(durationMillis = 750, easing = LinearEasing) | |
| ) | |
| scope.launch { | |
| seatGlideProgress.animateTo( | |
| targetValue = 1f, | |
| animationSpec = spring( | |
| dampingRatio = Spring.DampingRatioNoBouncy, | |
| stiffness = 20f | |
| ) | |
| ) | |
| } | |
| delay(timeMillis = 1000) | |
| showTicket = true | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(brush = RoomBackgroundBrush) | |
| .statusBarsPadding() | |
| .onSizeChanged { size -> | |
| if (initialScale == 0f && size.width > 0) { | |
| initialScale = size.width / 880f | |
| val localContentCenterY = 320f | |
| initialOffset = Offset( | |
| x = size.width / 2f, | |
| y = (size.height / 2f) - (localContentCenterY * initialScale) | |
| ) | |
| scale = initialScale | |
| offset = initialOffset | |
| } | |
| } | |
| .pointerInput(key1 = Unit) { | |
| detectTransformGestures { centroid, pan, zoom, _ -> | |
| if (isConfirmed || offset == Offset.Unspecified) return@detectTransformGestures | |
| val oldScale = scale | |
| scale = (scale * zoom).coerceIn(minimumValue = 0.5f, maximumValue = 3f) | |
| val contentCentroid = (centroid - offset) / oldScale | |
| offset = centroid - (contentCentroid * scale) + pan | |
| } | |
| } | |
| .pointerInput(key1 = Unit) { | |
| detectTapGestures { tapOffset -> | |
| if (isConfirmed || offset == Offset.Unspecified) return@detectTapGestures | |
| val localTap = (tapOffset - offset) / scale | |
| val hit = seats.find { s -> | |
| val dx = localTap.x - s.x | |
| val dy = localTap.y - s.y | |
| (dx * dx + dy * dy) < SeatHitRadiusSq | |
| } | |
| selectedSeat = if (hit != null && hit.status != SeatStatus.OCCUPIED) { | |
| if (selectedSeat?.id == hit.id) null else hit | |
| } else null | |
| } | |
| } | |
| ) { | |
| // --- 0. Spatial Title Animation --- | |
| // Optimization: Zero recompositions during drag. Alpha is deferred directly to the graphicsLayer. | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(top = 56.dp) | |
| .graphicsLayer { | |
| if (isConfirmed || initialScale == 0f || initialOffset == Offset.Unspecified) { | |
| alpha = 0f | |
| } else { | |
| val byZoom = (1f - (abs(scale - initialScale) * 5f)).coerceIn( | |
| minimumValue = 0f, | |
| maximumValue = 1f | |
| ) | |
| val byPan = (1f - ((initialOffset.y - offset.y) / 150f)).coerceIn( | |
| minimumValue = 0f, | |
| maximumValue = 1f | |
| ) | |
| alpha = minOf(a = byZoom, b = byPan) | |
| } | |
| }, | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Text( | |
| text = "JETPACK COMPOSE THEATERS", | |
| color = Color.White, | |
| fontSize = 22.sp, | |
| textAlign = TextAlign.Center, | |
| fontWeight = FontWeight.Black, | |
| letterSpacing = 6.sp | |
| ) | |
| Spacer(modifier = Modifier.height(height = 6.dp)) | |
| Text( | |
| text = "SELECT YOUR SEAT", | |
| color = Color.White.copy(alpha = 0.5f), | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Medium, | |
| letterSpacing = 4.sp | |
| ) | |
| } | |
| if (initialOffset != Offset.Unspecified) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| translationX = offset.x | |
| translationY = offset.y | |
| scaleX = scale | |
| scaleY = scale | |
| transformOrigin = OriginTopLeft | |
| } | |
| ) { | |
| val screenDensity = LocalDensity.current.density | |
| // Avoids 'remember' overhead for simple arithmetic | |
| val topWidthDp = (ScreenTopWidth / screenDensity).dp | |
| val canvasMaxHeightDp = (ScreenCanvasHeight / screenDensity).dp | |
| val reflectionHeightDp = (ReflectionHeight / screenDensity).dp | |
| val videoHeightDp = (VideoHeight / screenDensity).dp | |
| val reflectionVideoOffsetDp = (ReflectionVideoOffsetY / screenDensity).dp | |
| // --- 1. THE MAIN SCREEN --- | |
| // CRT shutoff physics deferred to graphicsLayer. | |
| Box( | |
| modifier = Modifier | |
| .offset { | |
| IntOffset( | |
| x = (-ScreenTopWidth / 2f).roundToInt(), | |
| y = (-ScreenCanvasHeight).roundToInt() | |
| ) | |
| } | |
| .size(width = topWidthDp, height = canvasMaxHeightDp) | |
| .clip(shape = ImaxScreenShape) | |
| .graphicsLayer { | |
| val p = crtProgress.value | |
| val yPhase = (p / 0.6f).coerceIn(minimumValue = 0f, maximumValue = 1f) | |
| scaleY = 1f - (sin(x = yPhase * PI / 2).toFloat() * 0.995f) | |
| scaleX = if (p < 0.4f) { | |
| 1f + (sin(x = (p / 0.4f) * PI).toFloat() * 0.03f) | |
| } else { | |
| val xPhase = ((p - 0.4f) / 0.6f).coerceIn( | |
| minimumValue = 0f, | |
| maximumValue = 1f | |
| ) | |
| 1f - sin(x = xPhase * PI / 2).toFloat() | |
| } | |
| alpha = (1f - ((p - 0.9f) * 10f)).coerceIn( | |
| minimumValue = 0f, | |
| maximumValue = 1f | |
| ) | |
| } | |
| ) { | |
| Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | |
| Box(modifier = Modifier.size(width = topWidthDp, height = videoHeightDp)) { | |
| CinemaVideoPlayer( | |
| player = mainPlayer, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| AnimatedVisibility( | |
| visible = isCountdownActive, | |
| enter = fadeIn(animationSpec = tween(durationMillis = 300)), | |
| exit = fadeOut(animationSpec = tween(durationMillis = 500)), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(color = Color.Black), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| ClassicCountdownLoader( | |
| currentNumber = currentCountdownNumber, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(brush = ScreenBottomShadeBrush) | |
| ) | |
| AnimatedVisibility( | |
| visible = showThankYou, | |
| enter = fadeIn(animationSpec = tween(durationMillis = 600)), | |
| modifier = Modifier.align(alignment = Alignment.Center) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(color = Color.Black.copy(alpha = 0.7f)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = "THANK YOU\nENJOY THE SHOW", | |
| color = Color.White.copy(alpha = 0.4f), | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Black, | |
| letterSpacing = 6.sp, | |
| textAlign = TextAlign.Center, | |
| lineHeight = 22.sp, | |
| modifier = Modifier.graphicsLayer { | |
| rotationX = 45f | |
| scaleY = 0.85f | |
| cameraDistance = 8f | |
| } | |
| ) | |
| } | |
| } | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| val p = crtProgress.value | |
| if (p in 0.3f..0.9f) { | |
| val flashAlpha = sin(x = ((p - 0.3f) / 0.6f) * PI).toFloat() * 0.8f | |
| drawRect(color = Color.White.copy(alpha = flashAlpha)) | |
| } | |
| } | |
| } | |
| // --- 2. THE REALISTIC VIDEO REFLECTION --- | |
| Box( | |
| modifier = Modifier | |
| .offset { | |
| IntOffset( | |
| x = (-ScreenTopWidth / 2f).roundToInt(), | |
| y = (-ScreenCurveDepth).roundToInt() | |
| ) | |
| } | |
| .size(width = topWidthDp, height = reflectionHeightDp) | |
| .graphicsLayer { | |
| alpha = 0.85f * (1f - (crtProgress.value * 4f)).coerceIn( | |
| minimumValue = 0f, | |
| maximumValue = 1f | |
| ) | |
| compositingStrategy = CompositingStrategy.Offscreen | |
| } | |
| .drawWithCache { | |
| val verticalMask = Brush.verticalGradient( | |
| colorStops = InverseSquareReflectionStops, | |
| startY = ScreenCurveDepth, | |
| endY = size.height | |
| ) | |
| val horizontalMask = Brush.horizontalGradient( | |
| colorStops = ReflectionEdgeMaskStops, | |
| startX = 0f, | |
| endX = size.width | |
| ) | |
| onDrawWithContent { | |
| drawContent() | |
| drawRect(brush = verticalMask, blendMode = BlendMode.DstIn) | |
| drawRect(brush = horizontalMask, blendMode = BlendMode.DstIn) | |
| } | |
| } | |
| .clip(shape = ReflectionShape) | |
| .blur(radius = 28.dp) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .offset(y = reflectionVideoOffsetDp) | |
| .size(width = topWidthDp, height = videoHeightDp) | |
| .graphicsLayer { | |
| scaleY = -1f | |
| scaleX = 1.05f | |
| } | |
| ) { | |
| CinemaVideoPlayer( | |
| player = reflectionPlayer, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| AnimatedVisibility( | |
| visible = isCountdownActive, | |
| enter = fadeIn(animationSpec = tween(durationMillis = 300)), | |
| exit = fadeOut(animationSpec = tween(durationMillis = 500)), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(color = Color.Black), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| ClassicCountdownLoader( | |
| currentNumber = currentCountdownNumber, | |
| modifier = Modifier.fillMaxSize() | |
| ) | |
| } | |
| } | |
| AnimatedVisibility( | |
| visible = showThankYou, | |
| enter = fadeIn(animationSpec = tween(durationMillis = 600)), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| Box(modifier = Modifier.background(color = Color.Black.copy(alpha = 0.7f))) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // --- 3. The Canvas Overlay (Seats) --- | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| if (offset == Offset.Unspecified) return@Canvas | |
| val origin = selectedSeat | |
| val originId = origin?.id | |
| val originX = origin?.x ?: 0f | |
| val originY = origin?.y ?: 0f | |
| val glide = seatGlideProgress.value | |
| val glideAlpha = (1f - (glide * 3f)).coerceIn(minimumValue = 0f, maximumValue = 1f) | |
| val glideDistance = SeatGlideTravelPx * glide | |
| withTransform({ | |
| translate(left = offset.x, top = offset.y) | |
| scale(scaleX = scale, scaleY = scale, pivot = Offset.Zero) | |
| }) { | |
| seats.fastForEach { seat -> | |
| val isSelected = originId == seat.id | |
| if (isConfirmed && isSelected) return@fastForEach | |
| var renderX = seat.x | |
| var renderY = seat.y | |
| var alpha = 1f | |
| if (isConfirmed && origin != null && !isSelected) { | |
| val dx = seat.x - originX | |
| val dy = seat.y - originY | |
| // Avoiding sqrt if the distance is 0 to guard against NaN | |
| if (dx != 0f || dy != 0f) { | |
| val dist = sqrt(x = dx * dx + dy * dy) | |
| renderX += (dx / dist) * glideDistance | |
| renderY += (dy / dist) * glideDistance | |
| } | |
| alpha = glideAlpha | |
| } | |
| if (alpha > 0f) { | |
| drawTheaterSeat( | |
| seat = seat, | |
| isSelected = isSelected, | |
| targetX = renderX, | |
| targetY = renderY, | |
| alpha = alpha | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| // --- 4. UI Overlays --- | |
| AnimatedVisibility( | |
| visible = selectedSeat != null && !isConfirmed, | |
| enter = slideInVertically( | |
| initialOffsetY = { it }, | |
| animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessLow) | |
| ) + fadeIn(), | |
| exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(), | |
| modifier = Modifier.align(alignment = Alignment.BottomCenter) | |
| ) { | |
| selectedSeat?.let { seat -> | |
| CinemaSeatBottomSheet( | |
| seat = seat, | |
| onConfirm = { isConfirmed = true } | |
| ) | |
| } | |
| } | |
| // --- 5. Split Ticket Transition --- | |
| SharedTransitionLayout(modifier = Modifier.fillMaxSize()) { | |
| val onDismiss: () -> Unit = { | |
| scope.launch { | |
| showTicket = false | |
| isConfirmed = false | |
| showThankYou = false | |
| crtProgress.snapTo(targetValue = 0f) | |
| seatGlideProgress.snapTo(targetValue = 0f) | |
| scale = initialScale | |
| offset = initialOffset | |
| selectedSeat = null | |
| } | |
| } | |
| AnimatedVisibility( | |
| visible = isConfirmed && !showTicket && selectedSeat != null, | |
| enter = fadeIn(animationSpec = tween(durationMillis = 120)), | |
| exit = fadeOut(animationSpec = tween(durationMillis = 120)), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| val seat = selectedSeat | |
| if (seat != null) { | |
| val densityMultiplier = LocalDensity.current.density | |
| val seatPxW = 38f * scale | |
| val seatPxH = 34f * scale | |
| val seatWDp = (seatPxW / densityMultiplier).dp | |
| val seatHDp = (seatPxH / densityMultiplier).dp | |
| Box(modifier = Modifier.fillMaxSize()) { | |
| SeatChip( | |
| modifier = Modifier | |
| .offset { | |
| // Deferring layout calculations here prevents recomposition | |
| if (offset == Offset.Unspecified) return@offset IntOffset.Zero | |
| val seatLeft = offset.x + scale * seat.x - seatPxW / 2f | |
| val seatTop = offset.y + scale * seat.y - seatPxH / 2f | |
| IntOffset(x = seatLeft.roundToInt(), y = seatTop.roundToInt()) | |
| } | |
| .size(width = seatWDp, height = seatHDp) | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = SEAT_TICKET_KEY), | |
| animatedVisibilityScope = this@AnimatedVisibility, | |
| boundsTransform = SeatToTicketBoundsTransform | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .align(alignment = Alignment.Center) | |
| .size(width = TicketWidth, height = TicketTotalHeight) | |
| ) { | |
| val halfSlideSpring = spring<IntOffset>( | |
| dampingRatio = 0.7f, | |
| stiffness = Spring.StiffnessLow | |
| ) | |
| AnimatedVisibility( | |
| visible = showTicket, | |
| enter = slideInVertically( | |
| initialOffsetY = { -(it + 270) }, | |
| animationSpec = halfSlideSpring | |
| ) + fadeIn(animationSpec = tween(durationMillis = 180)), | |
| exit = slideOutVertically( | |
| targetOffsetY = { -(it + 270) }, | |
| animationSpec = tween(durationMillis = 320, easing = LinearOutSlowInEasing) | |
| ) + fadeOut(animationSpec = tween(durationMillis = 220)), | |
| modifier = Modifier.align(alignment = Alignment.TopCenter) | |
| ) { | |
| selectedSeat?.let { seat -> | |
| TicketTopHalf( | |
| seat = seat, | |
| modifier = Modifier | |
| .size(width = TicketWidth, height = TicketHalfHeight) | |
| .pointerInput(key1 = Unit) { detectTapGestures { onDismiss() } } | |
| ) | |
| } | |
| } | |
| AnimatedVisibility( | |
| visible = showTicket, | |
| enter = slideInVertically( | |
| initialOffsetY = { it + 270 }, | |
| animationSpec = halfSlideSpring | |
| ) + fadeIn(animationSpec = tween(durationMillis = 180)), | |
| exit = slideOutVertically( | |
| targetOffsetY = { it + 270 }, | |
| animationSpec = tween(durationMillis = 320, easing = LinearOutSlowInEasing) | |
| ) + fadeOut(animationSpec = tween(durationMillis = 220)), | |
| modifier = Modifier.align(alignment = Alignment.BottomCenter) | |
| ) { | |
| selectedSeat?.let { seat -> | |
| TicketBottomHalf( | |
| seat = seat, | |
| sharedSeatSlot = { | |
| SeatChip( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .sharedElement( | |
| sharedContentState = rememberSharedContentState(key = SEAT_TICKET_KEY), | |
| animatedVisibilityScope = this@AnimatedVisibility, | |
| boundsTransform = SeatToTicketBoundsTransform | |
| ) | |
| ) | |
| }, | |
| modifier = Modifier | |
| .size(width = TicketWidth, height = TicketHalfHeight) | |
| .pointerInput(key1 = Unit) { detectTapGestures { onDismiss() } } | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // --- Classic Countdown Loader --- | |
| @Composable | |
| fun ClassicCountdownLoader(currentNumber: Int, modifier: Modifier = Modifier) { | |
| val sweepAngle = remember { Animatable(initialValue = 360f) } | |
| LaunchedEffect(key1 = currentNumber) { | |
| sweepAngle.snapTo(targetValue = 360f) | |
| sweepAngle.animateTo( | |
| targetValue = 0f, | |
| animationSpec = tween(durationMillis = 1000, easing = LinearEasing) | |
| ) | |
| } | |
| Box( | |
| modifier = modifier.graphicsLayer { | |
| rotationX = 45f | |
| scaleY = 0.85f | |
| cameraDistance = 8f | |
| }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| val radius = (size.minDimension / 2f) * 0.85f | |
| val center = Offset(x = size.width / 2f, y = size.height / 2f) | |
| val strokeColor = Color.White.copy(alpha = 0.6f) | |
| drawLine( | |
| color = strokeColor, | |
| start = Offset(x = center.x, y = 0f), | |
| end = Offset(x = center.x, y = size.height), | |
| strokeWidth = 3f | |
| ) | |
| drawLine( | |
| color = strokeColor, | |
| start = Offset(x = 0f, y = center.y), | |
| end = Offset(x = size.width, y = center.y), | |
| strokeWidth = 3f | |
| ) | |
| drawCircle(color = strokeColor, radius = radius, style = Stroke(width = 3f)) | |
| drawCircle(color = strokeColor, radius = radius * 0.9f, style = Stroke(width = 6f)) | |
| drawArc( | |
| color = Color.White.copy(alpha = 0.15f), | |
| startAngle = -90f, | |
| sweepAngle = sweepAngle.value, | |
| useCenter = true, | |
| topLeft = Offset(x = center.x - radius, y = center.y - radius), | |
| size = Size(width = radius * 2f, height = radius * 2f) | |
| ) | |
| } | |
| Text( | |
| text = currentNumber.toString(), | |
| color = Color.White.copy(alpha = 0.55f), | |
| fontSize = 40.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| } | |
| // --- Video Player Composable --- | |
| @OptIn(UnstableApi::class) | |
| @Composable | |
| fun CinemaVideoPlayer(player: ExoPlayer, modifier: Modifier = Modifier) { | |
| AndroidView( | |
| factory = { ctx -> | |
| TextureView(ctx).apply { | |
| layoutParams = ViewGroup.LayoutParams( | |
| ViewGroup.LayoutParams.MATCH_PARENT, | |
| ViewGroup.LayoutParams.MATCH_PARENT | |
| ) | |
| } | |
| }, | |
| update = { view -> player.setVideoTextureView(view) }, | |
| onRelease = { view -> player.clearVideoTextureView(view) }, | |
| modifier = modifier | |
| ) | |
| } | |
| // --- Drawing Extensions --- | |
| private val SeatStrokeStyle = Stroke(width = 4f, cap = StrokeCap.Round) | |
| private val SeatCushionTopLeft = Offset(x = 6f, y = SeatHeightPx - 14f) | |
| private val SeatCushionSize = Size(width = SeatWidthPx - 12f, height = 10f) | |
| private val SeatCushionCorner = CornerRadius(x = 2f, y = 2f) | |
| fun DrawScope.drawTheaterSeat( | |
| seat: TheaterSeat, isSelected: Boolean, targetX: Float, targetY: Float, alpha: Float | |
| ) { | |
| withTransform({ | |
| translate(left = targetX - SeatWidthPx / 2f, top = targetY - SeatHeightPx / 2f) | |
| }) { | |
| if (isSelected) { | |
| drawPath( | |
| path = SeatBackPath, | |
| brush = PremiumGoldGradient, | |
| alpha = alpha, | |
| style = SeatStrokeStyle | |
| ) | |
| drawRoundRect( | |
| brush = PremiumGoldGradient, | |
| alpha = alpha, | |
| topLeft = SeatCushionTopLeft, | |
| size = SeatCushionSize, | |
| cornerRadius = SeatCushionCorner | |
| ) | |
| } else { | |
| val seatColor = | |
| if (seat.status == SeatStatus.OCCUPIED) SeatColorOccupied else SeatColorAvailable | |
| drawPath( | |
| path = SeatBackPath, | |
| color = seatColor, | |
| alpha = alpha, | |
| style = SeatStrokeStyle | |
| ) | |
| drawRoundRect( | |
| color = seatColor, | |
| alpha = alpha, | |
| topLeft = SeatCushionTopLeft, | |
| size = SeatCushionSize, | |
| cornerRadius = SeatCushionCorner | |
| ) | |
| } | |
| } | |
| } | |
| fun generatePerfectCinemaSeats(): List<TheaterSeat> { | |
| val seats = mutableListOf<TheaterSeat>() | |
| val rows = listOf("A", "B", "C", "D", "E", "F", "G", "H", "I") | |
| val stepX = 64f | |
| val stepY = 70f | |
| val layoutPattern = List(size = 11) { true } | |
| val totalWidth = layoutPattern.size * stepX | |
| val startX = -totalWidth / 2f + (stepX / 2f) | |
| var currentY = 190f | |
| rows.forEachIndexed { rowIndex, rowStr -> | |
| val currentPattern = if (rowIndex == 0 || rowIndex == rows.lastIndex) { | |
| listOf(false) + List(size = 9) { true } + listOf(false) | |
| } else layoutPattern | |
| currentPattern.forEachIndexed { colIndex, isSeat -> | |
| if (isSeat) { | |
| val x = startX + (colIndex * stepX) | |
| val status = | |
| if (Random.nextFloat() > 0.85f) SeatStatus.OCCUPIED else SeatStatus.AVAILABLE | |
| seats.add( | |
| TheaterSeat( | |
| id = "$rowStr${colIndex + 1}", | |
| row = rowStr, | |
| number = colIndex + 1, | |
| status = status, | |
| x = x, | |
| y = currentY, | |
| angle = 0f | |
| ) | |
| ) | |
| } | |
| } | |
| currentY += stepY | |
| } | |
| return seats | |
| } | |
| // --- UI Components --- | |
| private val TopHalfShape = PerforatedHalfShape( | |
| cornerRadius = TicketCornerRadius, | |
| sideNotchRadius = TicketSideNotchRadius, | |
| notchedEdge = NotchedEdge.BOTTOM | |
| ) | |
| private val BottomHalfShape = PerforatedHalfShape( | |
| cornerRadius = TicketCornerRadius, | |
| sideNotchRadius = TicketSideNotchRadius, | |
| notchedEdge = NotchedEdge.TOP | |
| ) | |
| private val TicketColorBackground = Color(color = 0xFFF4F4F6) | |
| private val TicketColorText = Color(color = 0xFF1D1D1F) | |
| private val TicketColorSubtitle = Color(color = 0xFF86868B) | |
| private val TicketAccent = Color(color = 0xFFFFD166) | |
| private val TicketPaperBrush = Brush.verticalGradient( | |
| colors = listOf(Color(color = 0xFFFFFFFF), TicketColorBackground) | |
| ) | |
| private val QrGoldBrush = Brush.linearGradient( | |
| colors = listOf( | |
| Color(color = 0xFFFFD166), | |
| Color(color = 0xFFF4C430), | |
| Color(color = 0xFFD4AF37) | |
| ), | |
| start = Offset.Zero, | |
| end = Offset(x = 500f, y = 500f) | |
| ) | |
| private const val QrGridSize = 25 | |
| private const val QrFinderSize = 8 | |
| private const val QrSeed = 12345L | |
| @Composable | |
| fun GenerativeGradientQrCode(modifier: Modifier = Modifier) { | |
| Spacer( | |
| modifier = modifier | |
| .drawWithCache { | |
| val dimension = minOf(a = size.width, b = size.height) | |
| val cellSize = dimension / QrGridSize | |
| val offsetX = (size.width - dimension) / 2f | |
| val offsetY = (size.height - dimension) / 2f | |
| val r = Random(seed = QrSeed) | |
| val gridPoints = mutableListOf<Offset>() | |
| for (row in 0 until QrGridSize) { | |
| for (col in 0 until QrGridSize) { | |
| val inTopLeft = row < QrFinderSize && col < QrFinderSize | |
| val inTopRight = row < QrFinderSize && col >= QrGridSize - QrFinderSize | |
| val inBottomLeft = row >= QrGridSize - QrFinderSize && col < QrFinderSize | |
| if (!inTopLeft && !inTopRight && !inBottomLeft) { | |
| if (r.nextBoolean()) { | |
| gridPoints.add( | |
| Offset( | |
| x = offsetX + col * cellSize + 1.5f, | |
| y = offsetY + row * cellSize + 1.5f | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| val cellGlyphSize = Size(width = cellSize - 3f, height = cellSize - 3f) | |
| val cellCorner = CornerRadius(x = 4f, y = 4f) | |
| onDrawBehind { | |
| fun drawEye(row: Int, col: Int) { | |
| val origin = | |
| Offset(x = offsetX + col * cellSize, y = offsetY + row * cellSize) | |
| val eyeSize = 7 * cellSize | |
| drawRoundRect( | |
| brush = QrGoldBrush, | |
| topLeft = origin, | |
| size = Size(width = eyeSize, height = eyeSize), | |
| cornerRadius = CornerRadius(x = 12f, y = 12f) | |
| ) | |
| drawRect( | |
| color = Color.White, | |
| topLeft = origin + Offset(x = cellSize, y = cellSize), | |
| size = Size( | |
| width = eyeSize - 2 * cellSize, | |
| height = eyeSize - 2 * cellSize | |
| ) | |
| ) | |
| drawRoundRect( | |
| brush = QrGoldBrush, | |
| topLeft = origin + Offset(x = 2 * cellSize, y = 2 * cellSize), | |
| size = Size( | |
| width = eyeSize - 4 * cellSize, | |
| height = eyeSize - 4 * cellSize | |
| ), | |
| cornerRadius = CornerRadius(x = 6f, y = 6f) | |
| ) | |
| } | |
| drawEye(row = 0, col = 0) | |
| drawEye(row = 0, col = QrGridSize - 7) | |
| drawEye(row = QrGridSize - 7, col = 0) | |
| gridPoints.fastForEach { point -> | |
| drawRoundRect( | |
| brush = QrGoldBrush, | |
| topLeft = point, | |
| size = cellGlyphSize, | |
| cornerRadius = cellCorner | |
| ) | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| @Composable | |
| fun TicketTopHalf(seat: TheaterSeat, modifier: Modifier = Modifier) { | |
| Box( | |
| modifier = modifier | |
| .shadow( | |
| elevation = 24.dp, | |
| shape = TopHalfShape, | |
| spotColor = Color.Black.copy(alpha = 0.6f) | |
| ) | |
| .border(width = 1.dp, color = Color.White.copy(alpha = 0.6f), shape = TopHalfShape) | |
| .clip(shape = TopHalfShape) | |
| .background(brush = TicketPaperBrush) | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(horizontal = 28.dp, vertical = 26.dp) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .weight(weight = 1f), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| GenerativeGradientQrCode(modifier = Modifier.size(size = 130.dp)) | |
| } | |
| Column( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Text( | |
| text = "FOLLOW FOR MORE", | |
| color = TicketColorText, | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.Black, | |
| letterSpacing = (-0.5).sp, | |
| textAlign = TextAlign.Center | |
| ) | |
| Spacer(modifier = Modifier.height(height = 4.dp)) | |
| Text( | |
| text = "70mm IMAX · 2h 21min", | |
| color = TicketColorSubtitle, | |
| fontFamily = FontFamily.Monospace, | |
| fontSize = 11.sp, | |
| letterSpacing = 1.sp, | |
| textAlign = TextAlign.Center | |
| ) | |
| Spacer(modifier = Modifier.height(height = 8.dp)) | |
| } | |
| } | |
| RealisticPerforationEffect( | |
| modifier = Modifier.align(alignment = Alignment.BottomCenter), | |
| isTopHalf = true | |
| ) | |
| @Suppress("UNUSED_EXPRESSION") seat | |
| } | |
| } | |
| @Composable | |
| fun TicketBottomHalf( | |
| seat: TheaterSeat, | |
| sharedSeatSlot: @Composable BoxScope.() -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Box( | |
| modifier = modifier | |
| .shadow( | |
| elevation = 24.dp, | |
| shape = BottomHalfShape, | |
| spotColor = Color.Black.copy(alpha = 0.6f) | |
| ) | |
| .border(width = 1.dp, color = Color.White.copy(alpha = 0.6f), shape = BottomHalfShape) | |
| .clip(shape = BottomHalfShape) | |
| .background(brush = TicketPaperBrush) | |
| ) { | |
| RealisticPerforationEffect( | |
| modifier = Modifier.align(alignment = Alignment.TopCenter), | |
| isTopHalf = false | |
| ) | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(horizontal = 28.dp, vertical = 26.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Text( | |
| text = "ADMIT ONE", | |
| color = TicketAccent, | |
| style = PremiumGoldTextStyle, | |
| fontSize = 10.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 4.sp, | |
| modifier = Modifier.align(alignment = Alignment.Start) | |
| ) | |
| Spacer(modifier = Modifier.height(height = 16.dp)) | |
| Column(horizontalAlignment = Alignment.CenterHorizontally) { | |
| Text( | |
| text = "SEAT", | |
| color = TicketColorSubtitle, | |
| fontSize = 11.sp, | |
| letterSpacing = 3.sp, | |
| fontFamily = FontFamily.Monospace, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| Spacer(modifier = Modifier.height(height = 8.dp)) | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Box( | |
| modifier = Modifier.size(width = 38.dp, height = 34.dp), | |
| contentAlignment = Alignment.Center, | |
| content = sharedSeatSlot | |
| ) | |
| Spacer(modifier = Modifier.width(width = 12.dp)) | |
| Text( | |
| text = "${seat.row}${seat.number}", | |
| color = TicketColorText, | |
| fontSize = 52.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| lineHeight = 56.sp | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.weight(weight = 1f)) | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.Bottom | |
| ) { | |
| Column { | |
| Text( | |
| text = "DATE", | |
| color = TicketColorSubtitle, | |
| fontSize = 9.sp, | |
| letterSpacing = 2.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| Spacer(modifier = Modifier.height(height = 4.dp)) | |
| Text( | |
| text = "SAT 25 APR 2026", | |
| color = TicketColorText, | |
| fontSize = 13.sp, | |
| fontFamily = FontFamily.Monospace, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| Column(horizontalAlignment = Alignment.End) { | |
| Text( | |
| text = "HALL · TIME", | |
| color = TicketColorSubtitle, | |
| fontSize = 9.sp, | |
| letterSpacing = 2.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| Spacer(modifier = Modifier.height(height = 4.dp)) | |
| Text( | |
| text = "07 · 20:30", | |
| color = TicketColorText, | |
| fontSize = 13.sp, | |
| fontFamily = FontFamily.Monospace, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(height = 16.dp)) | |
| Text( | |
| text = "ZG·2026·${seat.row}${ | |
| seat.number.toString().padStart(length = 3, padChar = '0') | |
| }", | |
| color = TicketColorSubtitle, | |
| fontFamily = FontFamily.Monospace, | |
| fontSize = 9.sp, | |
| letterSpacing = 1.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| } | |
| } | |
| private val PerforationDashEffect: PathEffect = | |
| PathEffect.dashPathEffect(intervals = floatArrayOf(16f, 16f), phase = 0f) | |
| private val PerforationShadowColor = Color(color = 0xFF0A0D14) | |
| @Composable | |
| fun RealisticPerforationEffect(modifier: Modifier = Modifier, isTopHalf: Boolean) { | |
| val density = LocalDensity.current.density | |
| val snR = TicketSideNotchRadius.value * density | |
| Spacer( | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .height(height = 4.dp) | |
| .drawWithCache { | |
| val yOffset = if (isTopHalf) size.height else 0f | |
| val startX = snR | |
| val endX = size.width - snR | |
| onDrawBehind { | |
| drawLine( | |
| color = Color.White, | |
| start = Offset(x = startX, y = yOffset + 2f), | |
| end = Offset(x = endX, y = yOffset + 2f), | |
| strokeWidth = 8f, | |
| cap = StrokeCap.Round, | |
| pathEffect = PerforationDashEffect | |
| ) | |
| drawLine( | |
| color = PerforationShadowColor, | |
| start = Offset(x = startX, y = yOffset), | |
| end = Offset(x = endX, y = yOffset), | |
| strokeWidth = 8f, | |
| cap = StrokeCap.Round, | |
| pathEffect = PerforationDashEffect | |
| ) | |
| } | |
| } | |
| ) | |
| } | |
| @Composable | |
| private fun SeatChip(modifier: Modifier = Modifier) { | |
| val uPath = remember { Path() } | |
| Canvas(modifier = modifier) { | |
| val w = size.width | |
| val h = size.height | |
| if (w <= 0f || h <= 0f) return@Canvas | |
| val cornerX = (6f / 38f) * w | |
| val cornerY = (6f / 34f) * h | |
| val strokeW = (4f / 38f) * w | |
| uPath.reset() | |
| uPath.moveTo(x = 0f, y = 0f) | |
| uPath.lineTo(x = 0f, y = h - cornerY) | |
| uPath.quadraticTo(x1 = 0f, y1 = h, x2 = cornerX, y2 = h) | |
| uPath.lineTo(x = w - cornerX, y = h) | |
| uPath.quadraticTo(x1 = w, y1 = h, x2 = w, y2 = h - cornerY) | |
| uPath.lineTo(x = w, y = 0f) | |
| drawPath( | |
| path = uPath, | |
| brush = PremiumGoldGradient, | |
| style = Stroke(width = strokeW, cap = StrokeCap.Round) | |
| ) | |
| val cushionH = (10f / 34f) * h | |
| val cushionTop = h - (14f / 34f) * h | |
| val cushionInset = (6f / 38f) * w | |
| drawRoundRect( | |
| brush = PremiumGoldGradient, | |
| topLeft = Offset(x = cushionInset, y = cushionTop), | |
| size = Size(width = w - 2f * cushionInset, height = cushionH), | |
| cornerRadius = CornerRadius(x = (2f / 38f) * w, y = (2f / 34f) * h) | |
| ) | |
| } | |
| } | |
| private enum class NotchedEdge { TOP, BOTTOM } | |
| private class PerforatedHalfShape( | |
| private val cornerRadius: Dp, | |
| private val sideNotchRadius: Dp, | |
| private val notchedEdge: NotchedEdge | |
| ) : Shape { | |
| override fun createOutline( | |
| size: Size, | |
| layoutDirection: LayoutDirection, | |
| density: Density | |
| ): Outline { | |
| val cR = with(density) { cornerRadius.toPx() } | |
| val snR = with(density) { sideNotchRadius.toPx() } | |
| val w = size.width | |
| val h = size.height | |
| val path = Path().apply { | |
| when (notchedEdge) { | |
| NotchedEdge.TOP -> { | |
| moveTo(x = 0f, y = snR) | |
| arcTo( | |
| rect = Rect(left = -snR, top = -snR, right = snR, bottom = snR), | |
| startAngleDegrees = 90f, | |
| sweepAngleDegrees = -90f, | |
| forceMoveTo = false | |
| ) | |
| lineTo(x = w - snR, y = 0f) | |
| arcTo( | |
| rect = Rect(left = w - snR, top = -snR, right = w + snR, bottom = snR), | |
| startAngleDegrees = 180f, | |
| sweepAngleDegrees = -90f, | |
| forceMoveTo = false | |
| ) | |
| lineTo(x = w, y = h - cR) | |
| arcTo( | |
| rect = Rect(left = w - 2f * cR, top = h - 2f * cR, right = w, bottom = h), | |
| startAngleDegrees = 0f, | |
| sweepAngleDegrees = 90f, | |
| forceMoveTo = false | |
| ) | |
| lineTo(x = cR, y = h) | |
| arcTo( | |
| rect = Rect(left = 0f, top = h - 2f * cR, right = 2f * cR, bottom = h), | |
| startAngleDegrees = 90f, | |
| sweepAngleDegrees = 90f, | |
| forceMoveTo = false | |
| ) | |
| close() | |
| } | |
| NotchedEdge.BOTTOM -> { | |
| moveTo(x = 0f, y = cR) | |
| arcTo( | |
| rect = Rect(left = 0f, top = 0f, right = 2f * cR, bottom = 2f * cR), | |
| startAngleDegrees = 180f, | |
| sweepAngleDegrees = 90f, | |
| forceMoveTo = false | |
| ) | |
| lineTo(x = w - cR, y = 0f) | |
| arcTo( | |
| rect = Rect(left = w - 2f * cR, top = 0f, right = w, bottom = 2f * cR), | |
| startAngleDegrees = 270f, | |
| sweepAngleDegrees = 90f, | |
| forceMoveTo = false | |
| ) | |
| lineTo(x = w, y = h - snR) | |
| arcTo( | |
| rect = Rect( | |
| left = w - snR, | |
| top = h - snR, | |
| right = w + snR, | |
| bottom = h + snR | |
| ), startAngleDegrees = 270f, sweepAngleDegrees = -90f, forceMoveTo = false | |
| ) | |
| lineTo(x = snR, y = h) | |
| arcTo( | |
| rect = Rect(left = -snR, top = h - snR, right = snR, bottom = h + snR), | |
| startAngleDegrees = 0f, | |
| sweepAngleDegrees = -90f, | |
| forceMoveTo = false | |
| ) | |
| close() | |
| } | |
| } | |
| } | |
| return Outline.Generic(path) | |
| } | |
| } | |
| @Composable | |
| fun CinemaSeatBottomSheet(seat: TheaterSeat, onConfirm: () -> Unit) { | |
| var triggerStagger by remember { mutableStateOf(value = false) } | |
| LaunchedEffect(key1 = Unit) { | |
| delay(timeMillis = 50) | |
| triggerStagger = true | |
| } | |
| Card( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(all = 16.dp) | |
| .padding(bottom = 24.dp) | |
| .shadow( | |
| elevation = 30.dp, | |
| shape = RoundedCornerShape(size = 24.dp), | |
| ambientColor = Color.Black | |
| ), | |
| colors = CardDefaults.cardColors(containerColor = Color(color = 0xFF1E293B)), | |
| shape = RoundedCornerShape(size = 24.dp) | |
| ) { | |
| Column(modifier = Modifier.padding(all = 24.dp)) { | |
| // --- Stagger 1: Seat & Price Row --- | |
| AnimatedStaggerItem(visible = triggerStagger, index = 0) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| AnimatedContent( | |
| targetState = seat, | |
| transitionSpec = { | |
| fadeIn(animationSpec = tween(durationMillis = 300)) togetherWith fadeOut( | |
| animationSpec = tween(durationMillis = 300) | |
| ) | |
| }, | |
| label = "SeatTextTransition" | |
| ) { targetSeat -> | |
| Column { | |
| InitialFadingLetterText( | |
| text = "Row ${targetSeat.row} - Seat ${targetSeat.number}" | |
| ) | |
| Spacer(modifier = Modifier.height(height = 2.dp)) | |
| Text( | |
| text = "70MM IMAX PREMIER SEATING", | |
| color = Color(color = 0xFF94A3B8), | |
| fontSize = 11.sp, | |
| letterSpacing = 1.sp, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| } | |
| } | |
| Text( | |
| text = "$15.00", | |
| style = TextStyle(brush = PremiumGoldGradient), | |
| fontSize = 26.sp, | |
| fontWeight = FontWeight.Black | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(height = 24.dp)) | |
| // --- Stagger 2: Gradient Checkout Button --- | |
| AnimatedStaggerItem(visible = triggerStagger, index = 1) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(height = 60.dp) | |
| .shadow( | |
| elevation = 8.dp, | |
| shape = RoundedCornerShape(size = 18.dp), | |
| spotColor = Color(color = 0xFFFFC107).copy(alpha = 0.5f) | |
| ) | |
| .clip(shape = RoundedCornerShape(size = 18.dp)) | |
| .background(brush = PremiumGoldGradient) | |
| .clickable { onConfirm() }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = "Checkout & Print Ticket", | |
| fontSize = 16.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| color = Color.Black, | |
| letterSpacing = 0.5.sp | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun InitialFadingLetterText(text: String) { | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| text.forEachIndexed { index, char -> | |
| if (char == ' ') { | |
| Spacer(modifier = Modifier.width(width = 6.dp)) | |
| } else { | |
| val alphaAnim = remember(key1 = text) { Animatable(initialValue = 0f) } | |
| LaunchedEffect(key1 = text) { | |
| delay(timeMillis = index * 35L) | |
| alphaAnim.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(durationMillis = 200) | |
| ) | |
| alphaAnim.animateTo( | |
| targetValue = 0.4f, | |
| animationSpec = tween(durationMillis = 200) | |
| ) | |
| alphaAnim.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(durationMillis = 250) | |
| ) | |
| } | |
| Text( | |
| text = char.toString(), | |
| fontSize = 22.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White, | |
| modifier = Modifier.graphicsLayer { alpha = alphaAnim.value } | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun PlayfulButtonText(text: String) { | |
| val infiniteTransition = rememberInfiniteTransition(label = "PlayfulText") | |
| val phase by infiniteTransition.animateFloat( | |
| initialValue = 0f, | |
| targetValue = (2 * PI).toFloat(), | |
| animationSpec = infiniteRepeatable( | |
| animation = tween(durationMillis = 2500, easing = LinearEasing), | |
| repeatMode = RepeatMode.Restart | |
| ), | |
| label = "WavePhase" | |
| ) | |
| Row( | |
| horizontalArrangement = Arrangement.Center, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| text.forEachIndexed { index, char -> | |
| if (char == ' ') { | |
| Spacer(modifier = Modifier.width(width = 6.dp)) | |
| } else { | |
| Text( | |
| text = char.toString(), | |
| fontSize = 16.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| color = Color.Black, | |
| modifier = Modifier | |
| .offset { | |
| val sine = sin(x = phase + (index * 0.35f)).toFloat() | |
| IntOffset(x = 0, y = (sine * -2f).dp.roundToPx()) | |
| } | |
| .graphicsLayer { | |
| val sine = sin(x = phase + (index * 0.35f)).toFloat() | |
| alpha = 0.3f + ((sine + 1f) / 2f) * 0.7f | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun AnimatedStaggerItem( | |
| visible: Boolean, | |
| index: Int, | |
| content: @Composable () -> Unit | |
| ) { | |
| val enterDelay = 100 + (index * 80) | |
| AnimatedVisibility( | |
| visible = visible, | |
| enter = fadeIn( | |
| animationSpec = tween(durationMillis = 400, delayMillis = enterDelay) | |
| ) + slideInVertically( | |
| initialOffsetY = { it / 2 }, | |
| animationSpec = tween( | |
| durationMillis = 500, | |
| delayMillis = enterDelay, | |
| easing = FastOutSlowInEasing | |
| ) | |
| ) | |
| ) { | |
| content() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment