gitignore update

This commit is contained in:
Paweł Orzech 2026-01-28 00:16:51 +01:00
parent a22b994b0a
commit 87ad810392
No known key found for this signature in database
7 changed files with 442 additions and 74 deletions

View file

@ -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
) {

View file

@ -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
)
}

View file

@ -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<Color>, gradientB: List<Color>, fraction: Float): List<Color> {
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<Color> {
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)
}
}

View file

@ -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)
)
}

View file

@ -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<MonthDaylight>?,
gradientColors: List<Color> = 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<Color> {
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
}
}

View file

@ -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<Particle>() }
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)
)
}
}
}
}

View file

@ -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)