Created
December 31, 2025 14:38
-
-
Save Kyriakos-Georgiopoulos/6f084ce2fdde17f4785a2bb7fc9f46e3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2025 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.CubicBezierEasing | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.LinearEasing | |
| import androidx.compose.animation.core.animateDp | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.animation.core.updateTransition | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.Image | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.BoxWithConstraints | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.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.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.outlined.AccessTime | |
| import androidx.compose.material.icons.outlined.ChevronRight | |
| import androidx.compose.material.icons.outlined.DirectionsCar | |
| import androidx.compose.material.icons.outlined.EnergySavingsLeaf | |
| import androidx.compose.material.icons.outlined.ExpandMore | |
| import androidx.compose.material.icons.outlined.FlightTakeoff | |
| import androidx.compose.material.icons.outlined.Home | |
| import androidx.compose.material.icons.outlined.Key | |
| import androidx.compose.material.icons.outlined.LocalShipping | |
| import androidx.compose.material.icons.outlined.PersonOutline | |
| import androidx.compose.material.icons.outlined.Place | |
| import androidx.compose.material.icons.outlined.ReceiptLong | |
| import androidx.compose.material.icons.outlined.Restaurant | |
| import androidx.compose.material.icons.outlined.Schedule | |
| import androidx.compose.material.icons.outlined.Search | |
| import androidx.compose.material.icons.outlined.StarBorder | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.NavigationBar | |
| import androidx.compose.material3.NavigationBarItem | |
| import androidx.compose.material3.NavigationBarItemDefaults | |
| import androidx.compose.material3.Surface | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.runtime.withFrameNanos | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.alpha | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.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.graphicsLayer | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.layout.ContentScale | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.res.painterResource | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.unit.Dp | |
| import androidx.compose.ui.unit.dp | |
| import com.zengrip.R | |
| import kotlinx.coroutines.coroutineScope | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.launch | |
| private enum class SplashStage { | |
| Logo, | |
| LogoToLines, | |
| LinesGrow, | |
| Centering, | |
| RevealSlit, | |
| RevealFull, | |
| Done | |
| } | |
| private object TwinLinesTweens { | |
| val Grow = tween<Float>(780, easing = CubicBezierEasing(0.18f, 0f, 0f, 1f)) | |
| val Center = tween<Float>(700, easing = CubicBezierEasing(0.18f, 0f, 0f, 1f)) | |
| val Slit = tween<Float>(980, easing = CubicBezierEasing(0.18f, 0f, 0f, 1f)) | |
| val FullOpen = tween<Float>(2600, easing = CubicBezierEasing(0.10f, 0.92f, 0.16f, 1f)) | |
| val LogoInAlpha = tween<Float>(920, easing = CubicBezierEasing(0.12f, 1f, 0.22f, 1f)) | |
| val LogoInScale = tween<Float>(1100, easing = CubicBezierEasing(0.12f, 1f, 0.22f, 1f)) | |
| val LogoOut = tween<Float>(260, easing = LinearEasing) | |
| } | |
| /** | |
| * Splash-to-home reveal animation using two vertical "twin lines": | |
| * - Logo fades in/out | |
| * - Two lines appear near the center, grow to full height | |
| * - Lines slide to the true center | |
| * - A black curtain opens by separating the lines until the screen is fully revealed | |
| * | |
| * The home content is always rendered behind the curtain (full size). | |
| * | |
| * Tap anywhere after the reveal is complete to run the reverse-close and replay the animation. | |
| */ | |
| @Composable | |
| fun TwinLinesRevealDemo( | |
| modifier: Modifier = Modifier, | |
| lineWidth: Dp = 3.dp, | |
| lineMinGap: Dp = 10.dp, | |
| lineMaxGap: Dp = 18.dp, | |
| ) { | |
| var stage by remember { mutableStateOf(SplashStage.Logo) } | |
| val gapPxAnim = remember { Animatable(0f) } | |
| val linesHeightFrac = remember { Animatable(0f) } | |
| val linesAlpha = remember { Animatable(0f) } | |
| val centerOffsetFracAnim = remember { Animatable(1f) } | |
| var screenWidthPxState by remember { mutableStateOf(0f) } | |
| val logoAlphaAnim = remember { Animatable(0f) } | |
| val logoScaleAnim = remember { Animatable(0.92f) } | |
| var replayKey by remember { mutableStateOf(0) } | |
| var isAnimating by remember { mutableStateOf(false) } | |
| val uiScope = rememberCoroutineScope() | |
| val density = LocalDensity.current | |
| val transition = updateTransition(targetState = stage, label = "splash") | |
| val lineGapDp by transition.animateDp( | |
| label = "lineGapDp", | |
| transitionSpec = { tween(260, easing = FastOutSlowInEasing) } | |
| ) { s -> | |
| when (s) { | |
| SplashStage.Logo, SplashStage.LogoToLines -> lineMinGap | |
| else -> lineMaxGap | |
| } | |
| } | |
| val lineWidthPx = with(density) { lineWidth.toPx() } | |
| val lineGapPx = with(density) { lineGapDp.toPx() } | |
| fun smootherStep(t: Float): Float { | |
| val x = t.coerceIn(0f, 1f) | |
| return x * x * x * (x * (x * 6f - 15f) + 10f) | |
| } | |
| suspend fun resetToStartState() { | |
| linesHeightFrac.snapTo(0f) | |
| linesAlpha.snapTo(0f) | |
| gapPxAnim.snapTo(0f) | |
| centerOffsetFracAnim.snapTo(1f) | |
| logoAlphaAnim.snapTo(0f) | |
| logoScaleAnim.snapTo(0.86f) | |
| stage = SplashStage.Logo | |
| } | |
| suspend fun playForward() = coroutineScope { | |
| resetToStartState() | |
| // Give the reverse-close a moment to "land" in pure black before re-introducing motion | |
| withFrameNanos { } | |
| delay(180) | |
| withFrameNanos { } | |
| // Logo in: slower + more "premium" | |
| launch { logoAlphaAnim.animateTo(1f, TwinLinesTweens.LogoInAlpha) } | |
| launch { logoScaleAnim.animateTo(1f, TwinLinesTweens.LogoInScale) } | |
| // Keep logo readable a bit longer before transitioning to lines | |
| delay(820) | |
| stage = SplashStage.LogoToLines | |
| // Logo out | |
| launch { logoAlphaAnim.animateTo(0f, TwinLinesTweens.LogoOut) } | |
| launch { logoScaleAnim.animateTo(0.93f, TwinLinesTweens.LogoOut) } | |
| // Lines in | |
| linesAlpha.animateTo(1f, tween(300, easing = LinearEasing)) | |
| delay(220) | |
| stage = SplashStage.LinesGrow | |
| linesHeightFrac.animateTo(1f, TwinLinesTweens.Grow) | |
| stage = SplashStage.Centering | |
| val slitOverlapMs = 220L | |
| val centeringJob = launch { centerOffsetFracAnim.animateTo(0f, TwinLinesTweens.Center) } | |
| delay((TwinLinesTweens.Center.durationMillis - slitOverlapMs).coerceAtLeast(0).toLong()) | |
| stage = SplashStage.RevealSlit | |
| gapPxAnim.snapTo(0f) | |
| gapPxAnim.animateTo(lineGapPx, TwinLinesTweens.Slit) | |
| centeringJob.join() | |
| stage = SplashStage.RevealFull | |
| while (screenWidthPxState <= 0f) delay(16) | |
| gapPxAnim.animateTo(screenWidthPxState, TwinLinesTweens.FullOpen) | |
| delay(160) | |
| linesAlpha.animateTo(0f, tween(380, easing = LinearEasing)) | |
| stage = SplashStage.Done | |
| } | |
| suspend fun closeAndReplay() = coroutineScope { | |
| if (isAnimating) return@coroutineScope | |
| isAnimating = true | |
| while (screenWidthPxState <= 0f) delay(16) | |
| // Ensure we're in a clean "fully revealed" state before reversing | |
| stage = SplashStage.RevealFull | |
| // Keep logo hidden during reverse so it can't flash | |
| logoAlphaAnim.snapTo(0f) | |
| logoScaleAnim.snapTo(0.86f) | |
| // Lines should be visible, full height, centered | |
| linesAlpha.snapTo(1f) | |
| linesHeightFrac.snapTo(1f) | |
| centerOffsetFracAnim.snapTo(0f) | |
| // Start from fully open curtain | |
| gapPxAnim.snapTo(screenWidthPxState) | |
| val closeCurtainTween = tween<Float>( | |
| durationMillis = 900, | |
| easing = CubicBezierEasing(0.20f, 0f, 0.0f, 1f) | |
| ) | |
| val moveOffCenterTween = tween<Float>( | |
| durationMillis = 650, | |
| easing = CubicBezierEasing(0.18f, 0f, 0.0f, 1f) | |
| ) | |
| val shrinkHeightTween = tween<Float>( | |
| durationMillis = 780, | |
| easing = CubicBezierEasing(0.18f, 0f, 0.0f, 1f) | |
| ) | |
| // 1) Close curtains first (lines stay centered and full height) | |
| gapPxAnim.animateTo(0f, closeCurtainTween) | |
| // Make sure we are truly "closed" | |
| stage = SplashStage.Centering | |
| // 2) Move off-center (back to the initial offset) | |
| centerOffsetFracAnim.animateTo(1f, moveOffCenterTween) | |
| // 3) Reduce height smoothly to 0 | |
| linesHeightFrac.animateTo(0f, shrinkHeightTween) | |
| // Now hide lines (subtle, not a pop) | |
| linesAlpha.animateTo(0f, tween(180, easing = LinearEasing)) | |
| // Reset stage so the next forward run starts logically from Logo. | |
| stage = SplashStage.Logo | |
| // Give a little "restart breath" (black stays black) | |
| withFrameNanos { } | |
| delay(220) | |
| isAnimating = false | |
| replayKey++ | |
| } | |
| LaunchedEffect(replayKey) { | |
| isAnimating = true | |
| playForward() | |
| isAnimating = false | |
| } | |
| BoxWithConstraints(modifier = modifier.fillMaxSize()) { | |
| val screenWidthPx = with(density) { maxWidth.toPx() } | |
| LaunchedEffect(screenWidthPx) { screenWidthPxState = screenWidthPx } | |
| val centerOffsetFrac = centerOffsetFracAnim.value | |
| val rawT = (gapPxAnim.value / screenWidthPx).coerceIn(0f, 1f) | |
| val t = smootherStep(rawT) | |
| val openingPxEased = t * screenWidthPx | |
| val minHalfSeparation = (lineWidthPx * 1.3f).coerceAtLeast(2.5f) | |
| val halfSeparation = maxOf(openingPxEased / 2f, minHalfSeparation) | |
| MockHomeScreen() | |
| RevealCurtainOverlay( | |
| openingPx = openingPxEased, | |
| centerOffsetFrac = centerOffsetFrac, | |
| isRevealing = stage >= SplashStage.RevealSlit, | |
| edgeFeatherPx = with(density) { 34.dp.toPx() } | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.Center) | |
| .graphicsLayer { | |
| scaleX = logoScaleAnim.value | |
| scaleY = logoScaleAnim.value | |
| } | |
| .alpha(logoAlphaAnim.value) | |
| ) { | |
| KyriakosLogo() | |
| } | |
| Canvas( | |
| Modifier | |
| .fillMaxSize() | |
| .alpha(linesAlpha.value) | |
| ) { | |
| val heightFrac = linesHeightFrac.value.coerceIn(0f, 1f) | |
| if (heightFrac <= 0f) return@Canvas | |
| val h = size.height | |
| val w = size.width | |
| val cx = (w / 2f) + (w * 0.14f * centerOffsetFrac) | |
| val halfHeight = (h * heightFrac) / 2f | |
| val top = (h / 2f) - halfHeight | |
| val bottom = (h / 2f) + halfHeight | |
| val x1 = cx - halfSeparation | |
| val x2 = cx + halfSeparation | |
| drawLine(Color.White, Offset(x1, top), Offset(x1, bottom), strokeWidth = lineWidthPx) | |
| drawLine(Color.White, Offset(x2, top), Offset(x2, bottom), strokeWidth = lineWidthPx) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(stage, isAnimating) { | |
| detectTapGestures { | |
| if (stage == SplashStage.Done && !isAnimating) { | |
| uiScope.launch { closeAndReplay() } | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| /** Logo shown during the splash stage. */ | |
| @Composable | |
| private fun KyriakosLogo(modifier: Modifier = Modifier) { | |
| Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { | |
| Text( | |
| text = "K", | |
| color = Color.White, | |
| style = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Black) | |
| ) | |
| Spacer(Modifier.height(6.dp)) | |
| Text( | |
| text = "Kyriakos", | |
| color = Color.White, | |
| style = MaterialTheme.typography.headlineMedium.copy( | |
| fontWeight = FontWeight.SemiBold, | |
| letterSpacing = MaterialTheme.typography.headlineMedium.letterSpacing | |
| ) | |
| ) | |
| } | |
| } | |
| /** Mock home screen content rendered behind the reveal curtain. */ | |
| @Composable | |
| private fun MockHomeScreen() { | |
| Surface(color = Color(0xFFF4F5F7)) { | |
| Column( | |
| Modifier | |
| .fillMaxSize() | |
| .windowInsetsPadding(WindowInsets.statusBars) | |
| ) { | |
| HeaderPromo() | |
| Spacer(Modifier.height(6.dp)) | |
| QuickActions() | |
| Spacer(Modifier.height(6.dp)) | |
| SearchRow() | |
| SectionTitle("Around you") | |
| FeedList() | |
| Spacer(Modifier.weight(1f)) | |
| BottomNav() | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun HeaderPromo() { | |
| Surface( | |
| modifier = Modifier | |
| .padding(horizontal = 16.dp, vertical = 12.dp) | |
| .fillMaxWidth(), | |
| shape = RoundedCornerShape(16.dp), | |
| color = Color(0xFF0F5A3C) | |
| ) { | |
| Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { | |
| Column(Modifier.weight(1f)) { | |
| Text( | |
| text = "Try Eco Mode", | |
| color = Color.White, | |
| style = MaterialTheme.typography.titleMedium, | |
| fontWeight = FontWeight.SemiBold | |
| ) | |
| Spacer(Modifier.height(4.dp)) | |
| Text( | |
| text = "Go greener →", | |
| color = Color.White.copy(alpha = 0.9f), | |
| style = MaterialTheme.typography.bodyMedium | |
| ) | |
| } | |
| Box( | |
| Modifier | |
| .size(44.dp) | |
| .clip(CircleShape) | |
| .background(Color.White.copy(alpha = 0.16f)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| Icons.Outlined.EnergySavingsLeaf, | |
| contentDescription = null, | |
| tint = Color.White | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun QuickActions() { | |
| val items = listOf( | |
| "Ride" to Icons.Outlined.DirectionsCar, | |
| "Food" to Icons.Outlined.Restaurant, | |
| "Rent" to Icons.Outlined.Key, | |
| "Reserve" to Icons.Outlined.Schedule, | |
| "Courier" to Icons.Outlined.LocalShipping, | |
| "Travel" to Icons.Outlined.FlightTakeoff | |
| ) | |
| Column(Modifier.padding(horizontal = 16.dp)) { | |
| repeat(2) { row -> | |
| Row( | |
| Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(10.dp) | |
| ) { | |
| repeat(3) { col -> | |
| val idx = row * 3 + col | |
| QuickActionCard( | |
| title = items[idx].first, | |
| icon = items[idx].second, | |
| modifier = Modifier.weight(1f) | |
| ) | |
| } | |
| } | |
| Spacer(Modifier.height(10.dp)) | |
| } | |
| } | |
| } | |
| /** Small quick-action card used in the home grid. */ | |
| @Composable | |
| private fun QuickActionCard( | |
| title: String, | |
| icon: androidx.compose.ui.graphics.vector.ImageVector, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Surface( | |
| modifier = modifier.height(86.dp), | |
| shape = RoundedCornerShape(14.dp), | |
| color = Color.White | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(12.dp), | |
| verticalArrangement = Arrangement.Center, | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Box( | |
| Modifier | |
| .size(34.dp) | |
| .clip(RoundedCornerShape(10.dp)) | |
| .background(Color(0xFFF1F2F4)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon(icon, contentDescription = null, tint = Color(0xFF111111)) | |
| } | |
| Spacer(Modifier.height(10.dp)) | |
| Text( | |
| text = title, | |
| style = MaterialTheme.typography.bodyMedium, | |
| fontWeight = FontWeight.Medium, | |
| color = Color(0xFF111111), | |
| textAlign = TextAlign.Center | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun SearchRow() { | |
| Row( | |
| Modifier | |
| .padding(horizontal = 16.dp, vertical = 12.dp) | |
| .fillMaxWidth(), | |
| horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Surface( | |
| modifier = Modifier.weight(1f), | |
| shape = RoundedCornerShape(14.dp), | |
| color = Color.White | |
| ) { | |
| Row( | |
| Modifier.padding(horizontal = 12.dp, vertical = 12.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Icon(Icons.Outlined.Search, contentDescription = null, tint = Color(0xFF6B7280)) | |
| Spacer(Modifier.width(8.dp)) | |
| Text( | |
| "Where to?", | |
| color = Color(0xFF6B7280), | |
| style = MaterialTheme.typography.bodyMedium | |
| ) | |
| } | |
| } | |
| Surface(shape = RoundedCornerShape(14.dp), color = Color.White) { | |
| Row( | |
| Modifier.padding(horizontal = 12.dp, vertical = 12.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Icon(Icons.Outlined.AccessTime, contentDescription = null, tint = Color(0xFF111111)) | |
| Spacer(Modifier.width(6.dp)) | |
| Text( | |
| "Now", | |
| color = Color(0xFF111111), | |
| style = MaterialTheme.typography.bodyMedium, | |
| fontWeight = FontWeight.Medium | |
| ) | |
| Spacer(Modifier.width(4.dp)) | |
| Icon(Icons.Outlined.ExpandMore, contentDescription = null, tint = Color(0xFF111111)) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun SectionTitle(text: String) { | |
| Text( | |
| text = text, | |
| modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), | |
| style = MaterialTheme.typography.titleMedium, | |
| fontWeight = FontWeight.SemiBold, | |
| color = Color(0xFF111111) | |
| ) | |
| } | |
| @Composable | |
| private fun FeedList() { | |
| Column(Modifier.padding(horizontal = 16.dp)) { | |
| FeedRow(Icons.Outlined.StarBorder, "Choose a saved place", "Home, Work and more") | |
| Spacer(Modifier.height(10.dp)) | |
| FeedRow(Icons.Outlined.Place, "Set destination on map", "Pick a spot precisely") | |
| Spacer(Modifier.height(12.dp)) | |
| GoogleMapPreview() | |
| } | |
| } | |
| @Composable | |
| private fun FeedRow( | |
| icon: androidx.compose.ui.graphics.vector.ImageVector, | |
| title: String, | |
| subtitle: String | |
| ) { | |
| Surface( | |
| modifier = Modifier.fillMaxWidth(), | |
| shape = RoundedCornerShape(16.dp), | |
| color = Color.White | |
| ) { | |
| Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { | |
| Box( | |
| Modifier | |
| .size(40.dp) | |
| .clip(RoundedCornerShape(12.dp)) | |
| .background(Color(0xFFF1F2F4)), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon(icon, contentDescription = null, tint = Color(0xFF111111)) | |
| } | |
| Spacer(Modifier.width(12.dp)) | |
| Column(Modifier.weight(1f)) { | |
| Text(title, color = Color(0xFF111111), fontWeight = FontWeight.Medium) | |
| Spacer(Modifier.height(2.dp)) | |
| Text( | |
| subtitle, | |
| color = Color(0xFF6B7280), | |
| style = MaterialTheme.typography.bodyMedium | |
| ) | |
| } | |
| Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = Color(0xFF9AA0AA)) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun BottomNav() { | |
| NavigationBar( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .navigationBarsPadding(), | |
| containerColor = Color.White, | |
| tonalElevation = 4.dp | |
| ) { | |
| val colors = NavigationBarItemDefaults.colors( | |
| selectedIconColor = Color(0xFF111111), | |
| selectedTextColor = Color(0xFF111111), | |
| unselectedIconColor = Color(0xFF8A8F98), | |
| unselectedTextColor = Color(0xFF8A8F98), | |
| indicatorColor = Color.Transparent | |
| ) | |
| NavigationBarItem( | |
| selected = true, | |
| onClick = {}, | |
| icon = { Icon(Icons.Outlined.Home, contentDescription = null) }, | |
| label = { Text("Home") }, | |
| alwaysShowLabel = true, | |
| colors = colors | |
| ) | |
| NavigationBarItem( | |
| selected = false, | |
| onClick = {}, | |
| icon = { Icon(Icons.Outlined.ReceiptLong, contentDescription = null) }, | |
| label = { Text("Activity") }, | |
| alwaysShowLabel = true, | |
| colors = colors | |
| ) | |
| NavigationBarItem( | |
| selected = false, | |
| onClick = {}, | |
| icon = { Icon(Icons.Outlined.PersonOutline, contentDescription = null) }, | |
| label = { Text("Account") }, | |
| alwaysShowLabel = true, | |
| colors = colors | |
| ) | |
| } | |
| } | |
| /** | |
| * Black curtain overlay with a centered "window" whose width is controlled by [openingPx]. | |
| * | |
| * When [openingPx] reaches (almost) the screen width, the overlay stops drawing to avoid edge artifacts. | |
| */ | |
| @Composable | |
| private fun RevealCurtainOverlay( | |
| openingPx: Float, | |
| centerOffsetFrac: Float, | |
| isRevealing: Boolean, | |
| modifier: Modifier = Modifier, | |
| startOffsetFractionOfWidth: Float = 0.14f, | |
| edgeFeatherPx: Float = 0f | |
| ) { | |
| Canvas(modifier.fillMaxSize()) { | |
| val w = size.width | |
| val h = size.height | |
| if (!isRevealing) { | |
| drawRect(Color.Black) | |
| return@Canvas | |
| } | |
| val windowW = openingPx.coerceIn(0f, w) | |
| if (windowW >= w - 1f) return@Canvas | |
| if (windowW <= 0f) { | |
| drawRect(Color.Black) | |
| return@Canvas | |
| } | |
| val cx = (w / 2f) + (w * startOffsetFractionOfWidth * centerOffsetFrac) | |
| val leftEdge = (cx - windowW / 2f).coerceIn(0f, w) | |
| val rightEdge = (cx + windowW / 2f).coerceIn(0f, w) | |
| if (leftEdge > 0f) { | |
| drawRect( | |
| color = Color.Black, | |
| topLeft = Offset.Zero, | |
| size = Size(leftEdge, h) | |
| ) | |
| } | |
| if (rightEdge < w) { | |
| drawRect( | |
| color = Color.Black, | |
| topLeft = Offset(rightEdge, 0f), | |
| size = Size(w - rightEdge, h) | |
| ) | |
| } | |
| val feather = edgeFeatherPx | |
| .coerceIn(0f, 120f) | |
| .coerceAtMost(windowW / 2f) | |
| .coerceAtMost(leftEdge) | |
| .coerceAtMost(w - rightEdge) | |
| if (feather > 0f) { | |
| drawRect( | |
| brush = Brush.horizontalGradient( | |
| 0f to Color.Black, | |
| 1f to Color.Transparent | |
| ), | |
| topLeft = Offset(leftEdge - feather, 0f), | |
| size = Size(feather, h) | |
| ) | |
| drawRect( | |
| brush = Brush.horizontalGradient( | |
| 0f to Color.Transparent, | |
| 1f to Color.Black | |
| ), | |
| topLeft = Offset(rightEdge, 0f), | |
| size = Size(feather, h) | |
| ) | |
| } | |
| } | |
| } | |
| /** Static Google-style map preview card (uses a local drawable). */ | |
| @Composable | |
| private fun GoogleMapPreview(modifier: Modifier = Modifier) { | |
| Surface( | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .height(240.dp) | |
| .padding(horizontal = 4.dp, vertical = 8.dp), | |
| shape = RoundedCornerShape(20.dp), | |
| tonalElevation = 0.dp | |
| ) { | |
| Image( | |
| painter = painterResource(id = R.drawable.map_preview), | |
| contentDescription = null, | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .clip(RoundedCornerShape(20.dp)), | |
| contentScale = ContentScale.Crop | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment