From 87ad81039270a18c8d0681fca57ed9381c2b861c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Wed, 28 Jan 2026 00:16:51 +0100 Subject: [PATCH] gitignore update --- .../ui/addlocation/AddLocationSheet.kt | 19 +- .../java/com/sunzones/ui/main/MainScreen.kt | 35 ++- .../ui/main/animation/AnimationUtils.kt | 56 +++++ .../com/sunzones/ui/main/components/SunArc.kt | 49 +++- .../sunzones/ui/main/components/SunCard.kt | 228 +++++++++++++----- .../ui/main/components/SunsetParticles.kt | 123 ++++++++++ .../main/java/com/sunzones/ui/theme/Color.kt | 6 + 7 files changed, 442 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/sunzones/ui/main/animation/AnimationUtils.kt create mode 100644 app/src/main/java/com/sunzones/ui/main/components/SunsetParticles.kt diff --git a/app/src/main/java/com/sunzones/ui/addlocation/AddLocationSheet.kt b/app/src/main/java/com/sunzones/ui/addlocation/AddLocationSheet.kt index 965ac35..dee59ef 100644 --- a/app/src/main/java/com/sunzones/ui/addlocation/AddLocationSheet.kt +++ b/app/src/main/java/com/sunzones/ui/addlocation/AddLocationSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -47,6 +48,7 @@ fun AddLocationSheet( ) { val uiState by viewModel.uiState.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val focusManager = LocalFocusManager.current val locationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() @@ -60,11 +62,16 @@ fun AddLocationSheet( LaunchedEffect(Unit) { viewModel.resetState() + viewModel.uiState.collect { state -> + if (state.isSaved) { + onDismiss() + } + } } - LaunchedEffect(uiState.isSaved) { - if (uiState.isSaved) { - onDismiss() + LaunchedEffect(uiState.searchResults) { + if (uiState.searchResults.isNotEmpty()) { + focusManager.clearFocus() } } @@ -88,6 +95,7 @@ fun AddLocationSheet( // Use my location button OutlinedButton( onClick = { + focusManager.clearFocus() locationPermissionLauncher.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, @@ -145,7 +153,10 @@ fun AddLocationSheet( Row( modifier = Modifier .fillMaxWidth() - .clickable { viewModel.selectSearchResult(result) } + .clickable { + focusManager.clearFocus() + viewModel.selectSearchResult(result) + } .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/com/sunzones/ui/main/MainScreen.kt b/app/src/main/java/com/sunzones/ui/main/MainScreen.kt index 5a6a965..9b2b58d 100644 --- a/app/src/main/java/com/sunzones/ui/main/MainScreen.kt +++ b/app/src/main/java/com/sunzones/ui/main/MainScreen.kt @@ -31,12 +31,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.sunzones.R import com.sunzones.ui.addlocation.AddLocationSheet +import com.sunzones.ui.main.animation.getTimeGradient +import com.sunzones.ui.main.animation.lerpGradient import com.sunzones.ui.main.components.PageIndicator import com.sunzones.ui.main.components.SunCard import com.sunzones.ui.theme.NightBottom import com.sunzones.ui.theme.NightTop -import com.sunzones.ui.theme.TextOnDark import com.sunzones.ui.theme.TextOnDarkSecondary +import kotlin.math.absoluteValue @Composable fun MainScreen( @@ -76,10 +78,39 @@ fun MainScreen( modifier = Modifier.fillMaxSize() ) { page -> val location = uiState.locations[page] + + // Compute page offset for parallax + val pageOffset = ((pagerState.currentPage - page) + + pagerState.currentPageOffsetFraction) + + // Compute blended gradient for crossfade + val currentGradient = getTimeGradient(location.sunProgress, location.isDaytime) + val blendedGradient = if (pageOffset.absoluteValue > 0.001f) { + val adjacentPage = if (pageOffset > 0) { + (page - 1).coerceAtLeast(0) + } else { + (page + 1).coerceAtMost(uiState.locations.size - 1) + } + val adjacentLocation = uiState.locations[adjacentPage] + val adjacentGradient = getTimeGradient( + adjacentLocation.sunProgress, + adjacentLocation.isDaytime + ) + lerpGradient( + currentGradient, + adjacentGradient, + pageOffset.absoluteValue.coerceAtMost(1f) + ) + } else { + currentGradient + } + SunCard( location = location, onDelete = { viewModel.deleteLocation(location.id) }, - monthlyDaylight = uiState.yearlyDaylight[location.id] + monthlyDaylight = uiState.yearlyDaylight[location.id], + gradientColors = blendedGradient, + pageOffset = pageOffset ) } diff --git a/app/src/main/java/com/sunzones/ui/main/animation/AnimationUtils.kt b/app/src/main/java/com/sunzones/ui/main/animation/AnimationUtils.kt new file mode 100644 index 0000000..6ac84d6 --- /dev/null +++ b/app/src/main/java/com/sunzones/ui/main/animation/AnimationUtils.kt @@ -0,0 +1,56 @@ +package com.sunzones.ui.main.animation + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import com.sunzones.ui.theme.* +import kotlin.math.cos +import kotlin.math.sin + +/** + * Per-color lerp between two gradient color lists. + */ +fun lerpGradient(gradientA: List, gradientB: List, fraction: Float): List { + val clampedFraction = fraction.coerceIn(0f, 1f) + return gradientA.indices.map { i -> + lerp(gradientA[i], gradientB.getOrElse(i) { gradientA[i] }, clampedFraction) + } +} + +/** + * Subtle HSV shift for ambient sky animation. + * Shifts hue by +/-3 degrees and saturation by +/-3% based on phase (0..1). + */ +fun shiftColorSubtle(color: Color, phase: Float): Color { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV( + android.graphics.Color.argb( + (color.alpha * 255).toInt(), + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt() + ), + hsv + ) + val twoPiPhase = phase * 2f * Math.PI.toFloat() + hsv[0] = (hsv[0] + 3f * sin(twoPiPhase)) % 360f + if (hsv[0] < 0f) hsv[0] += 360f + hsv[1] = (hsv[1] + 0.03f * cos(twoPiPhase)).coerceIn(0f, 1f) + val argb = android.graphics.Color.HSVToColor(hsv) + return Color(argb).copy(alpha = color.alpha) +} + +/** + * Returns gradient colors for the given sun progress and daytime state. + */ +fun getTimeGradient(sunProgress: Float, isDaytime: Boolean): List { + if (!isDaytime) return listOf(NightTop, NightBottom) + + return when { + sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom) + sunProgress < 0.3f -> listOf(MorningTop, MorningBottom) + sunProgress < 0.7f -> listOf(MiddayTop, MiddayBottom) + sunProgress < 0.85f -> listOf(AfternoonTop, AfternoonBottom) + sunProgress < 0.95f -> listOf(SunsetTop, SunsetBottom) + else -> listOf(TwilightTop, TwilightBottom) + } +} diff --git a/app/src/main/java/com/sunzones/ui/main/components/SunArc.kt b/app/src/main/java/com/sunzones/ui/main/components/SunArc.kt index 66dc9c9..4d58675 100644 --- a/app/src/main/java/com/sunzones/ui/main/components/SunArc.kt +++ b/app/src/main/java/com/sunzones/ui/main/components/SunArc.kt @@ -1,8 +1,14 @@ package com.sunzones.ui.main.components +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,7 +19,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke @@ -33,10 +38,11 @@ fun SunArc( progress: Float, isDaytime: Boolean, nightProgress: Float = 0f, + entranceProgress: Float = 1f, modifier: Modifier = Modifier ) { val animatedProgress by animateFloatAsState( - targetValue = progress, + targetValue = progress * entranceProgress, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow @@ -45,7 +51,7 @@ fun SunArc( ) val animatedNightProgress by animateFloatAsState( - targetValue = nightProgress, + targetValue = nightProgress * entranceProgress, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow @@ -53,6 +59,27 @@ fun SunArc( label = "night_progress" ) + // Glow pulse animations + val glowTransition = rememberInfiniteTransition(label = "glow_pulse") + val sunPulseScale by glowTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 2500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "sun_pulse" + ) + val moonPulseScale by glowTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.10f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 3500, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "moon_pulse" + ) + Canvas( modifier = modifier .fillMaxWidth() @@ -112,7 +139,7 @@ fun SunArc( val ry = arcHeight val sunX = centerX + (rx * cos(angle)).toFloat() val sunY = baseY - (ry * sin(angle)).toFloat() - drawSunGlow(sunX, sunY) + drawSunGlow(sunX, sunY, sunPulseScale) } else if (!isDaytime && animatedNightProgress > 0f) { // Progress arc (silver) — nighttime drawArc( @@ -135,12 +162,12 @@ fun SunArc( val ry = arcHeight val moonX = centerX + (rx * cos(angle)).toFloat() val moonY = baseY - (ry * sin(angle)).toFloat() - drawMoonGlow(moonX, moonY) + drawMoonGlow(moonX, moonY, moonPulseScale) } } } -private fun DrawScope.drawSunGlow(x: Float, y: Float) { +private fun DrawScope.drawSunGlow(x: Float, y: Float, pulseScale: Float) { val glowLayers = listOf( 24.dp.toPx() to 0.05f, 18.dp.toPx() to 0.08f, @@ -150,8 +177,8 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float) { for ((radius, alpha) in glowLayers) { drawCircle( - color = SunGold.copy(alpha = alpha), - radius = radius, + color = SunGold.copy(alpha = alpha / pulseScale), + radius = radius * pulseScale, center = Offset(x, y) ) } @@ -167,7 +194,7 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float) { ) } -private fun DrawScope.drawMoonGlow(x: Float, y: Float) { +private fun DrawScope.drawMoonGlow(x: Float, y: Float, pulseScale: Float) { val glowLayers = listOf( 18.dp.toPx() to 0.04f, 12.dp.toPx() to 0.08f, @@ -176,8 +203,8 @@ private fun DrawScope.drawMoonGlow(x: Float, y: Float) { for ((radius, alpha) in glowLayers) { drawCircle( - color = MoonSilver.copy(alpha = alpha), - radius = radius, + color = MoonSilver.copy(alpha = alpha / pulseScale), + radius = radius * pulseScale, center = Offset(x, y) ) } diff --git a/app/src/main/java/com/sunzones/ui/main/components/SunCard.kt b/app/src/main/java/com/sunzones/ui/main/components/SunCard.kt index 2541251..8d1274a 100644 --- a/app/src/main/java/com/sunzones/ui/main/components/SunCard.kt +++ b/app/src/main/java/com/sunzones/ui/main/components/SunCard.kt @@ -2,6 +2,9 @@ package com.sunzones.ui.main.components import android.content.Intent import android.net.Uri +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -42,6 +45,7 @@ import androidx.compose.material3.TextButton import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,7 +56,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset @@ -60,8 +66,15 @@ import androidx.compose.ui.unit.dp import com.sunzones.R import com.sunzones.domain.model.MonthDaylight import com.sunzones.domain.model.SunLocation +import com.sunzones.ui.main.animation.lerpGradient +import com.sunzones.ui.main.animation.shiftColorSubtle import com.sunzones.ui.theme.* import java.time.format.DateTimeFormatter +import kotlin.math.PI +import kotlin.math.absoluteValue +import kotlin.math.cos +import kotlin.math.sin +import androidx.compose.ui.util.lerp private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") @@ -71,12 +84,13 @@ fun SunCard( location: SunLocation, onDelete: () -> Unit, monthlyDaylight: List?, + gradientColors: List = listOf(NightTop, NightBottom), + pageOffset: Float = 0f, modifier: Modifier = Modifier ) { var showDeleteDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) } - val gradientColors = getTimeGradient(location.sunProgress, location.isDaytime) val scrollState = rememberScrollState() val chevronAlpha by remember { @@ -85,12 +99,62 @@ fun SunCard( } } + // Step 5: Entrance animation + val entranceProgress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + entranceProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing) + ) + } + val entrance = entranceProgress.value + + // Step 4: Living sky ambient + val ambientTransition = rememberInfiniteTransition(label = "ambient") + val ambientPhase by ambientTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 12000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "ambient_phase" + ) + + // Apply ambient shift to gradient colors + val ambientGradient = remember(gradientColors, ambientPhase) { + gradientColors.map { shiftColorSubtle(it, ambientPhase) } + } + + // Entrance: blend from night to actual gradient + val nightGradient = listOf(NightTop, NightBottom) + val displayGradient = if (entrance < 1f) { + lerpGradient(nightGradient, ambientGradient, entrance) + } else { + ambientGradient + } + + // Step 6: Particle state + val isSunriseWindow = location.isDaytime && location.sunProgress < 0.08f + val isSunsetWindow = location.isDaytime && location.sunProgress > 0.92f + val particleActive = isSunriseWindow || isSunsetWindow + + // Page transition: scale + fade (replaces parallax translationX) + val scale = lerp(0.92f, 1f, 1f - pageOffset.absoluteValue.coerceAtMost(1f)) + val pageFade = lerp(0.6f, 1f, 1f - pageOffset.absoluteValue.coerceAtMost(1f)) + BoxWithConstraints( modifier = modifier .fillMaxSize() - .background(brush = Brush.verticalGradient(gradientColors)) + .graphicsLayer { + scaleX = scale + scaleY = scale + alpha = pageFade + } + .background(brush = Brush.verticalGradient(displayGradient)) ) { val pageHeight = maxHeight + val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() } Column( modifier = Modifier @@ -114,10 +178,17 @@ fun SunCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - // Location name + // Location name — entrance index 0 + val nameAlpha = entranceAlpha(entrance, 0) + val nameDy = entranceTranslationY(entrance, 0) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .graphicsLayer { + alpha = nameAlpha + translationY = nameDy + } ) { if (location.isCurrentLocation) { Icon( @@ -140,14 +211,20 @@ fun SunCard( Spacer(modifier = Modifier.height(32.dp)) - // Sun/Moon arc with optional moon phase overlay + // Sun/Moon arc — entrance index 1, parallax background layer Box( - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, + modifier = Modifier + .graphicsLayer { + alpha = entranceAlpha(entrance, 1) + translationY = entranceTranslationY(entrance, 1) + } ) { SunArc( progress = location.sunProgress, isDaytime = location.isDaytime, nightProgress = location.nightProgress, + entranceProgress = entrance, modifier = Modifier.fillMaxWidth() ) @@ -168,50 +245,83 @@ fun SunCard( ) } } + + // Particle overlay + if (particleActive && entrance >= 1f) { + val arcPadding = with(LocalDensity.current) { 32.dp.toPx() } + val arcWidth = maxWidthPx - with(LocalDensity.current) { 48.dp.toPx() } - arcPadding * 2 + val arcHeight = arcWidth / 2 + val centerX = (maxWidthPx - with(LocalDensity.current) { 48.dp.toPx() }) / 2 + val arcBaseY = with(LocalDensity.current) { 180.dp.toPx() } - with(LocalDensity.current) { 20.dp.toPx() } + val angle = PI - (location.sunProgress * PI) + val sunX = centerX + (arcWidth / 2 * cos(angle)).toFloat() + val sunY = arcBaseY - (arcHeight * sin(angle)).toFloat() + + SunsetParticles( + active = true, + isSunrise = isSunriseWindow, + emitX = sunX, + emitY = sunY + ) + } } Spacer(modifier = Modifier.height(32.dp)) - // Hero element — day length or night length - if (location.isDaytime) { - Text( - text = location.dayLengthFormatted, - style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong), - color = TextOnDark, - fontWeight = FontWeight.Black - ) - Text( - text = stringResource(R.string.day_length), - style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault), - color = TextOnDarkSecondary - ) - } else { - Text( - text = location.nightLengthFormatted, - style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong), - color = TextOnDark, - fontWeight = FontWeight.Black - ) - Text( - text = stringResource(R.string.night_length), - style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault), - color = TextOnDarkSecondary - ) - if (location.sunriseCountdownFormatted != null) { - Spacer(modifier = Modifier.height(4.dp)) + // Hero element — entrance index 2 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .graphicsLayer { + alpha = entranceAlpha(entrance, 2) + translationY = entranceTranslationY(entrance, 2) + } + ) { + if (location.isDaytime) { Text( - text = stringResource(R.string.sunrise_in, location.sunriseCountdownFormatted), - style = MaterialTheme.typography.titleSmall.copy(shadow = TextShadowDefault), - color = SunGold.copy(alpha = 0.8f) + text = location.dayLengthFormatted, + style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong), + color = TextOnDark, + fontWeight = FontWeight.Black ) + Text( + text = stringResource(R.string.day_length), + style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault), + color = TextOnDarkSecondary + ) + } else { + Text( + text = location.nightLengthFormatted, + style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong), + color = TextOnDark, + fontWeight = FontWeight.Black + ) + Text( + text = stringResource(R.string.night_length), + style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault), + color = TextOnDarkSecondary + ) + if (location.sunriseCountdownFormatted != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.sunrise_in, location.sunriseCountdownFormatted), + style = MaterialTheme.typography.titleSmall.copy(shadow = TextShadowDefault), + color = SunGold.copy(alpha = 0.8f) + ) + } } } Spacer(modifier = Modifier.height(24.dp)) - // Sunrise / Sunset times + // Sunrise / Sunset times — entrance index 3 Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = entranceAlpha(entrance, 3) + translationY = entranceTranslationY(entrance, 3) + }, horizontalArrangement = Arrangement.SpaceEvenly ) { // Sunrise @@ -389,31 +499,35 @@ fun SunCard( } } -private fun getTimeGradient(sunProgress: Float, isDaytime: Boolean): List { - if (!isDaytime) return listOf(NightTop, NightBottom) +/** + * Staggered entrance alpha for element at given index. + */ +private fun entranceAlpha(progress: Float, index: Int): Float { + val stagger = index * 0.08f + return ((progress - stagger) / (1f - stagger)).coerceIn(0f, 1f) +} - return when { - sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom) - sunProgress < 0.3f -> listOf(MorningTop, MorningBottom) - sunProgress < 0.7f -> listOf(MiddayTop, MiddayBottom) - sunProgress < 0.85f -> listOf(AfternoonTop, AfternoonBottom) - sunProgress < 0.95f -> listOf(SunsetTop, SunsetBottom) - else -> listOf(TwilightTop, TwilightBottom) - } +/** + * Staggered entrance vertical translation for element at given index. + * Returns pixels to offset (20dp equivalent fading upward). + */ +private fun entranceTranslationY(progress: Float, index: Int): Float { + val alpha = entranceAlpha(progress, index) + return (1f - alpha) * 60f // ~20dp in pixels at mdpi, scales naturally } private fun moonPhaseEmoji(degrees: Double): String { // degrees: -180..180 where 0=new moon, 90=first quarter, 180/-180=full, -90=last quarter val normalized = ((degrees % 360) + 360) % 360 // normalize to 0..360 return when { - normalized < 22.5 -> "\uD83C\uDF11" // 🌑 New moon - normalized < 67.5 -> "\uD83C\uDF12" // 🌒 Waxing crescent - normalized < 112.5 -> "\uD83C\uDF13" // 🌓 First quarter - normalized < 157.5 -> "\uD83C\uDF14" // 🌔 Waxing gibbous - normalized < 202.5 -> "\uD83C\uDF15" // 🌕 Full moon - normalized < 247.5 -> "\uD83C\uDF16" // 🌖 Waning gibbous - normalized < 292.5 -> "\uD83C\uDF17" // 🌗 Last quarter - normalized < 337.5 -> "\uD83C\uDF18" // 🌘 Waning crescent - else -> "\uD83C\uDF11" // 🌑 New moon + normalized < 22.5 -> "\uD83C\uDF11" // New moon + normalized < 67.5 -> "\uD83C\uDF12" // Waxing crescent + normalized < 112.5 -> "\uD83C\uDF13" // First quarter + normalized < 157.5 -> "\uD83C\uDF14" // Waxing gibbous + normalized < 202.5 -> "\uD83C\uDF15" // Full moon + normalized < 247.5 -> "\uD83C\uDF16" // Waning gibbous + normalized < 292.5 -> "\uD83C\uDF17" // Last quarter + normalized < 337.5 -> "\uD83C\uDF18" // Waning crescent + else -> "\uD83C\uDF11" // New moon } } diff --git a/app/src/main/java/com/sunzones/ui/main/components/SunsetParticles.kt b/app/src/main/java/com/sunzones/ui/main/components/SunsetParticles.kt new file mode 100644 index 0000000..a4e46d5 --- /dev/null +++ b/app/src/main/java/com/sunzones/ui/main/components/SunsetParticles.kt @@ -0,0 +1,123 @@ +package com.sunzones.ui.main.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.sunzones.ui.theme.SunGlow +import com.sunzones.ui.theme.SunGold +import com.sunzones.ui.theme.SunOrange +import com.sunzones.ui.theme.SunriseParticle +import com.sunzones.ui.theme.SunsetParticle1 +import com.sunzones.ui.theme.SunsetParticle2 +import com.sunzones.ui.theme.SunsetParticle3 +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +private data class Particle( + var x: Float, + var y: Float, + var vx: Float, + var vy: Float, + var alpha: Float, + var scale: Float, + var life: Float, // 0..1, decreasing + val color: Color +) + +@Composable +fun SunsetParticles( + active: Boolean, + isSunrise: Boolean, + emitX: Float, + emitY: Float, + modifier: Modifier = Modifier +) { + val particles = remember { mutableStateListOf() } + + val palette = if (isSunrise) { + listOf(SunGold, SunOrange, SunGlow, SunriseParticle) + } else { + listOf(SunOrange, SunsetParticle1, SunsetParticle2, SunsetParticle3) + } + + LaunchedEffect(active, emitX, emitY) { + if (!active) { + particles.clear() + return@LaunchedEffect + } + + // Emit burst of 24 particles + particles.clear() + repeat(24) { + val angle = Random.nextFloat() * 2f * Math.PI.toFloat() + val speed = 40f + Random.nextFloat() * 80f + particles.add( + Particle( + x = emitX + (Random.nextFloat() - 0.5f) * 8f, + y = emitY + (Random.nextFloat() - 0.5f) * 8f, + vx = cos(angle) * speed, + vy = -sin(angle).coerceAtLeast(0.1f) * speed * 0.8f, + alpha = 0.7f + Random.nextFloat() * 0.3f, + scale = 0.5f + Random.nextFloat() * 0.5f, + life = 1f, + color = palette[Random.nextInt(palette.size)] + ) + ) + } + + var lastTime = System.nanoTime() + while (isActive && particles.isNotEmpty()) { + delay(16L) + val now = System.nanoTime() + val dt = ((now - lastTime) / 1_000_000_000f).coerceAtMost(0.05f) + lastTime = now + + val iterator = particles.listIterator() + while (iterator.hasNext()) { + val p = iterator.next() + p.x += p.vx * dt + p.y += p.vy * dt + p.vy += 60f * dt // gravity + p.life -= dt * 0.8f + p.alpha = (p.life * 0.8f).coerceIn(0f, 1f) + p.scale *= (1f - dt * 0.5f) + if (p.life <= 0f) { + iterator.remove() + } else { + iterator.set(p.copy()) + } + } + } + } + + Canvas(modifier = modifier.fillMaxSize()) { + for (p in particles) { + val radius = 4f * p.scale + if (radius > 0.5f && p.alpha > 0.01f) { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + p.color.copy(alpha = p.alpha), + p.color.copy(alpha = p.alpha * 0.3f) + ), + center = Offset(p.x, p.y), + radius = radius * 2f + ), + radius = radius * 2f, + center = Offset(p.x, p.y) + ) + } + } + } +} diff --git a/app/src/main/java/com/sunzones/ui/theme/Color.kt b/app/src/main/java/com/sunzones/ui/theme/Color.kt index 0cf88af..a631f94 100644 --- a/app/src/main/java/com/sunzones/ui/theme/Color.kt +++ b/app/src/main/java/com/sunzones/ui/theme/Color.kt @@ -52,6 +52,12 @@ val MoonSilver = Color(0xFFE0E0E0) val MoonGlow = Color(0xFFF5F5F5) val MoonBlue = Color(0xFFB0BEC5) +// Particle colors +val SunriseParticle = Color(0xFFFFE082) +val SunsetParticle1 = Color(0xFFE65100) +val SunsetParticle2 = Color(0xFF7B1FA2) +val SunsetParticle3 = Color(0xFFFF7043) + // Material 3 light colors val PrimaryLight = Color(0xFF1565C0) val OnPrimaryLight = Color(0xFFFFFFFF)