mirror of
https://github.com/pawelorzech/SunZones.git
synced 2026-01-29 19:54:26 +00:00
Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd734582c | ||
|
|
34c842c2d5 | ||
|
|
87ad810392 | ||
|
|
a22b994b0a | ||
|
|
117a16d571 | ||
|
|
dda6d664ab | ||
|
|
07808be975 | ||
|
|
36e958d828 |
9 changed files with 470 additions and 80 deletions
|
|
@ -16,8 +16,8 @@ android {
|
|||
applicationId = "com.sunzones"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 3
|
||||
versionName = "1.2"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,13 +76,22 @@ class MainViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
while (isActive) {
|
||||
delay(60_000L) // refresh every minute for sun progress updates
|
||||
getLocationsUseCase().collectLatest { locations ->
|
||||
_uiState.value = _uiState.value.copy(locations = 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 {
|
||||
repository.deleteLocation(id)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,50 +235,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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue