Compare commits

...

8 commits
v1.1 ... main

Author SHA1 Message Date
Paweł Orzech
ecd734582c
Update SunCard.kt 2026-01-28 00:17:56 +01:00
Paweł Orzech
34c842c2d5
Refactor SunCard entrance animation to be shared
Moved the entrance animation logic from SunCard to MainScreen so that all cards share a single entrance animation instance. SunCard now receives entranceProgress as a parameter, simplifying its internal state and ensuring consistent animation timing across cards.
2026-01-28 00:17:30 +01:00
Paweł Orzech
87ad810392
gitignore update 2026-01-28 00:16:51 +01:00
Paweł Orzech
a22b994b0a
Bump version to 1.2 (versionCode 3) 2026-01-27 23:41:56 +01:00
Paweł Orzech
117a16d571
Merge pull request #2 from pawelorzech/claude/add-app-refresh-startup-wczRE
Fix sun data refresh on app resume and periodic refresh
2026-01-27 22:23:06 +01:00
Paweł Orzech
dda6d664ab
Merge pull request #1 from pawelorzech/claude/fix-name-alignment-mg6dN
Fix location name text alignment for multi-line names
2026-01-27 22:19:07 +01:00
Claude
07808be975
Fix sun data refresh on app resume and periodic refresh
The periodic refresh was broken because collectLatest on a Room Flow
suspends indefinitely, preventing the while loop from advancing.
Replace with Flow.first() for one-shot snapshots. Add refresh() method
triggered via LifecycleEventEffect(ON_RESUME) so sun times recalculate
every time the app comes to foreground.

https://claude.ai/code/session_01QD2KVpt2xSRtRALne48g3n
2026-01-27 21:14:43 +00:00
Claude
36e958d828
Fix location name text alignment for multi-line names
When a long location name like "Jabłonowo Pomorskie" wraps to two
lines, the text was left-aligned instead of centered. Added
textAlign = TextAlign.Center to the location name Text composable.

https://claude.ai/code/session_01FAXwbjLiUKJdmsCyn3b9T8
2026-01-27 17:47:04 +00:00
9 changed files with 470 additions and 80 deletions

View file

@ -16,8 +16,8 @@ android {
applicationId = "com.sunzones"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 3
versionName = "1.2"
}
signingConfigs {

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

@ -1,5 +1,8 @@
package com.sunzones.ui.main
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -15,11 +18,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -29,12 +35,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(
@ -43,6 +51,19 @@ fun MainScreen(
val uiState by viewModel.uiState.collectAsState()
var showAddSheet by remember { mutableStateOf(false) }
// Entrance animation — runs once on screen load, shared by all cards
val entranceProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
entranceProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing)
)
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refresh()
}
Box(modifier = Modifier.fillMaxSize()) {
if (uiState.locations.isEmpty() && !uiState.isLoading) {
// Empty state
@ -70,10 +91,40 @@ 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,
entranceProgress = entranceProgress.value
)
}

View file

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -75,12 +76,21 @@ class MainViewModel @Inject constructor(
viewModelScope.launch {
while (isActive) {
delay(60_000L) // refresh every minute for sun progress updates
getLocationsUseCase().collectLatest { locations ->
refreshNow()
}
}
}
fun refresh() {
viewModelScope.launch {
refreshNow()
}
}
private suspend fun refreshNow() {
val locations = getLocationsUseCase().first()
_uiState.value = _uiState.value.copy(locations = locations)
}
}
}
}
fun deleteLocation(id: Long) {
viewModelScope.launch {

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,7 @@ package com.sunzones.ui.main.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
@ -24,6 +25,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -51,7 +53,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
@ -59,8 +63,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")
@ -70,12 +81,14 @@ fun SunCard(
location: SunLocation,
onDelete: () -> Unit,
monthlyDaylight: List<MonthDaylight>?,
gradientColors: List<Color> = listOf(NightTop, NightBottom),
pageOffset: Float = 0f,
entranceProgress: Float = 1f,
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 {
@ -84,12 +97,54 @@ fun SunCard(
}
}
val entrance = entranceProgress
// 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
@ -113,10 +168,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(
@ -132,20 +194,27 @@ fun SunCard(
text = location.name,
style = MaterialTheme.typography.displayMedium.copy(shadow = TextShadowStrong),
color = TextOnDark,
maxLines = 2
maxLines = 2,
textAlign = TextAlign.Center
)
}
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()
)
@ -166,11 +235,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))
// 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) {
Text(
text = location.dayLengthFormatted,
@ -204,12 +300,18 @@ fun SunCard(
)
}
}
}
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
@ -387,31 +489,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)