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.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -47,6 +48,7 @@ fun AddLocationSheet(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val locationPermissionLauncher = rememberLauncherForActivityResult( val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions() contract = ActivityResultContracts.RequestMultiplePermissions()
@ -60,11 +62,16 @@ fun AddLocationSheet(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.resetState() viewModel.resetState()
viewModel.uiState.collect { state ->
if (state.isSaved) {
onDismiss()
}
}
} }
LaunchedEffect(uiState.isSaved) { LaunchedEffect(uiState.searchResults) {
if (uiState.isSaved) { if (uiState.searchResults.isNotEmpty()) {
onDismiss() focusManager.clearFocus()
} }
} }
@ -88,6 +95,7 @@ fun AddLocationSheet(
// Use my location button // Use my location button
OutlinedButton( OutlinedButton(
onClick = { onClick = {
focusManager.clearFocus()
locationPermissionLauncher.launch( locationPermissionLauncher.launch(
arrayOf( arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
@ -145,7 +153,10 @@ fun AddLocationSheet(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { viewModel.selectSearchResult(result) } .clickable {
focusManager.clearFocus()
viewModel.selectSearchResult(result)
}
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {

View file

@ -31,12 +31,14 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.sunzones.R import com.sunzones.R
import com.sunzones.ui.addlocation.AddLocationSheet 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.PageIndicator
import com.sunzones.ui.main.components.SunCard import com.sunzones.ui.main.components.SunCard
import com.sunzones.ui.theme.NightBottom import com.sunzones.ui.theme.NightBottom
import com.sunzones.ui.theme.NightTop import com.sunzones.ui.theme.NightTop
import com.sunzones.ui.theme.TextOnDark
import com.sunzones.ui.theme.TextOnDarkSecondary import com.sunzones.ui.theme.TextOnDarkSecondary
import kotlin.math.absoluteValue
@Composable @Composable
fun MainScreen( fun MainScreen(
@ -76,10 +78,39 @@ fun MainScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { page -> ) { page ->
val location = uiState.locations[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( SunCard(
location = location, location = location,
onDelete = { viewModel.deleteLocation(location.id) }, 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 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.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState 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.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.geometry.Rect
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
@ -33,10 +38,11 @@ fun SunArc(
progress: Float, progress: Float,
isDaytime: Boolean, isDaytime: Boolean,
nightProgress: Float = 0f, nightProgress: Float = 0f,
entranceProgress: Float = 1f,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = progress, targetValue = progress * entranceProgress,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@ -45,7 +51,7 @@ fun SunArc(
) )
val animatedNightProgress by animateFloatAsState( val animatedNightProgress by animateFloatAsState(
targetValue = nightProgress, targetValue = nightProgress * entranceProgress,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@ -53,6 +59,27 @@ fun SunArc(
label = "night_progress" 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( Canvas(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -112,7 +139,7 @@ fun SunArc(
val ry = arcHeight val ry = arcHeight
val sunX = centerX + (rx * cos(angle)).toFloat() val sunX = centerX + (rx * cos(angle)).toFloat()
val sunY = baseY - (ry * sin(angle)).toFloat() val sunY = baseY - (ry * sin(angle)).toFloat()
drawSunGlow(sunX, sunY) drawSunGlow(sunX, sunY, sunPulseScale)
} else if (!isDaytime && animatedNightProgress > 0f) { } else if (!isDaytime && animatedNightProgress > 0f) {
// Progress arc (silver) — nighttime // Progress arc (silver) — nighttime
drawArc( drawArc(
@ -135,12 +162,12 @@ fun SunArc(
val ry = arcHeight val ry = arcHeight
val moonX = centerX + (rx * cos(angle)).toFloat() val moonX = centerX + (rx * cos(angle)).toFloat()
val moonY = baseY - (ry * sin(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( val glowLayers = listOf(
24.dp.toPx() to 0.05f, 24.dp.toPx() to 0.05f,
18.dp.toPx() to 0.08f, 18.dp.toPx() to 0.08f,
@ -150,8 +177,8 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float) {
for ((radius, alpha) in glowLayers) { for ((radius, alpha) in glowLayers) {
drawCircle( drawCircle(
color = SunGold.copy(alpha = alpha), color = SunGold.copy(alpha = alpha / pulseScale),
radius = radius, radius = radius * pulseScale,
center = Offset(x, y) 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( val glowLayers = listOf(
18.dp.toPx() to 0.04f, 18.dp.toPx() to 0.04f,
12.dp.toPx() to 0.08f, 12.dp.toPx() to 0.08f,
@ -176,8 +203,8 @@ private fun DrawScope.drawMoonGlow(x: Float, y: Float) {
for ((radius, alpha) in glowLayers) { for ((radius, alpha) in glowLayers) {
drawCircle( drawCircle(
color = MoonSilver.copy(alpha = alpha), color = MoonSilver.copy(alpha = alpha / pulseScale),
radius = radius, radius = radius * pulseScale,
center = Offset(x, y) center = Offset(x, y)
) )
} }

View file

@ -2,6 +2,9 @@ package com.sunzones.ui.main.components
import android.content.Intent import android.content.Intent
import android.net.Uri 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.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable 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.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -52,7 +56,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@ -60,8 +66,15 @@ import androidx.compose.ui.unit.dp
import com.sunzones.R import com.sunzones.R
import com.sunzones.domain.model.MonthDaylight import com.sunzones.domain.model.MonthDaylight
import com.sunzones.domain.model.SunLocation 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 com.sunzones.ui.theme.*
import java.time.format.DateTimeFormatter 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") private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@ -71,12 +84,13 @@ fun SunCard(
location: SunLocation, location: SunLocation,
onDelete: () -> Unit, onDelete: () -> Unit,
monthlyDaylight: List<MonthDaylight>?, monthlyDaylight: List<MonthDaylight>?,
gradientColors: List<Color> = listOf(NightTop, NightBottom),
pageOffset: Float = 0f,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) } var showAboutDialog by remember { mutableStateOf(false) }
val gradientColors = getTimeGradient(location.sunProgress, location.isDaytime)
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val chevronAlpha by remember { 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( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(brush = Brush.verticalGradient(gradientColors)) .graphicsLayer {
scaleX = scale
scaleY = scale
alpha = pageFade
}
.background(brush = Brush.verticalGradient(displayGradient))
) { ) {
val pageHeight = maxHeight val pageHeight = maxHeight
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
Column( Column(
modifier = Modifier modifier = Modifier
@ -114,10 +178,17 @@ fun SunCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// Location name // Location name — entrance index 0
val nameAlpha = entranceAlpha(entrance, 0)
val nameDy = entranceTranslationY(entrance, 0)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center,
modifier = Modifier
.graphicsLayer {
alpha = nameAlpha
translationY = nameDy
}
) { ) {
if (location.isCurrentLocation) { if (location.isCurrentLocation) {
Icon( Icon(
@ -140,14 +211,20 @@ fun SunCard(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Sun/Moon arc with optional moon phase overlay // Sun/Moon arc — entrance index 1, parallax background layer
Box( Box(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
modifier = Modifier
.graphicsLayer {
alpha = entranceAlpha(entrance, 1)
translationY = entranceTranslationY(entrance, 1)
}
) { ) {
SunArc( SunArc(
progress = location.sunProgress, progress = location.sunProgress,
isDaytime = location.isDaytime, isDaytime = location.isDaytime,
nightProgress = location.nightProgress, nightProgress = location.nightProgress,
entranceProgress = entrance,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -168,11 +245,38 @@ 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)) Spacer(modifier = Modifier.height(32.dp))
// Hero element — day length or night length // Hero element — entrance index 2
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer {
alpha = entranceAlpha(entrance, 2)
translationY = entranceTranslationY(entrance, 2)
}
) {
if (location.isDaytime) { if (location.isDaytime) {
Text( Text(
text = location.dayLengthFormatted, text = location.dayLengthFormatted,
@ -206,12 +310,18 @@ fun SunCard(
) )
} }
} }
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Sunrise / Sunset times // Sunrise / Sunset times — entrance index 3
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = entranceAlpha(entrance, 3)
translationY = entranceTranslationY(entrance, 3)
},
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
// Sunrise // 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.
*/
return when { private fun entranceAlpha(progress: Float, index: Int): Float {
sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom) val stagger = index * 0.08f
sunProgress < 0.3f -> listOf(MorningTop, MorningBottom) return ((progress - stagger) / (1f - stagger)).coerceIn(0f, 1f)
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 { private fun moonPhaseEmoji(degrees: Double): String {
// degrees: -180..180 where 0=new moon, 90=first quarter, 180/-180=full, -90=last quarter // 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 val normalized = ((degrees % 360) + 360) % 360 // normalize to 0..360
return when { return when {
normalized < 22.5 -> "\uD83C\uDF11" // 🌑 New moon normalized < 22.5 -> "\uD83C\uDF11" // New moon
normalized < 67.5 -> "\uD83C\uDF12" // 🌒 Waxing crescent normalized < 67.5 -> "\uD83C\uDF12" // Waxing crescent
normalized < 112.5 -> "\uD83C\uDF13" // 🌓 First quarter normalized < 112.5 -> "\uD83C\uDF13" // First quarter
normalized < 157.5 -> "\uD83C\uDF14" // 🌔 Waxing gibbous normalized < 157.5 -> "\uD83C\uDF14" // Waxing gibbous
normalized < 202.5 -> "\uD83C\uDF15" // 🌕 Full moon normalized < 202.5 -> "\uD83C\uDF15" // Full moon
normalized < 247.5 -> "\uD83C\uDF16" // 🌖 Waning gibbous normalized < 247.5 -> "\uD83C\uDF16" // Waning gibbous
normalized < 292.5 -> "\uD83C\uDF17" // 🌗 Last quarter normalized < 292.5 -> "\uD83C\uDF17" // Last quarter
normalized < 337.5 -> "\uD83C\uDF18" // 🌘 Waning crescent normalized < 337.5 -> "\uD83C\uDF18" // Waning crescent
else -> "\uD83C\uDF11" // 🌑 New moon 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 MoonGlow = Color(0xFFF5F5F5)
val MoonBlue = Color(0xFFB0BEC5) 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 // Material 3 light colors
val PrimaryLight = Color(0xFF1565C0) val PrimaryLight = Color(0xFF1565C0)
val OnPrimaryLight = Color(0xFFFFFFFF) val OnPrimaryLight = Color(0xFFFFFFFF)