Compare commits

...

15 commits
v1.1 ... main

Author SHA1 Message Date
Paweł Orzech
9477b32397
Merge pull request #4 from pawelorzech/codex/agent
Add AGENTS instructions for SunZones Kotlin app
2026-02-04 13:50:35 +01:00
Paweł Orzech
5e4bf7e21b
Add agents doc for Android repo 2026-02-04 13:49:10 +01:00
Paweł Orzech
0a6ef134ee
Merge pull request #3 from pawelorzech/add-claude-github-actions-1770208577090
Add Claude Code GitHub Workflow
2026-02-04 13:36:32 +01:00
Paweł Orzech
cfe6c91e0f "Claude Code Review workflow" 2026-02-04 13:36:19 +01:00
Paweł Orzech
d83790f218 "Claude PR Assistant workflow" 2026-02-04 13:36:18 +01:00
Paweł Orzech
aede45cec2
Bump version to 1.3 (versionCode 4)
- Add CityListSheet for managing locations via page indicator tap
- Add drag-to-reorder support in city list
- Fix Polish translation: "Daylight w roku" → "Długość dnia w roku"
- Fix PageIndicator position to avoid overlapping yearly chart
- Remove "About" section from UI (simplified)
- Add CHANGELOG.md
2026-01-31 12:51:37 +01:00
Paweł Orzech
0512ee674d
Add location reordering support and improve page transitions
- Add DAO and repository methods for reordering locations by display order
- Make PageIndicator clickable with ripple effect for future reorder UI
- Replace SunCard alpha fade with dark scrim overlay for smoother transitions
- Add Claude Code custom commands
2026-01-31 12:15:09 +01:00
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
22 changed files with 1011 additions and 175 deletions

View file

@ -0,0 +1,15 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create a git commit
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Based on the above changes, create a single git commit. Then push it.

View file

@ -0,0 +1,15 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Do migration tests first. Then: update changelog, update readme, update version in gradle files, create a git commit and push it
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Do migration tests first. Then: update changelog, update readme, update version in gradle files, create a git commit and push it

View file

@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

18
AGENTS.md Normal file
View file

@ -0,0 +1,18 @@
# AGENTS
## Overview
- Android app built with Kotlin + Jetpack Compose.
## Build
Requires Java 17 and Android SDK.
```bash
export JAVA_HOME="/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home"
./gradlew assembleDebug
```
## Run
TODO: Add local run/install instructions (e.g., `installDebug`, emulator/device setup) once confirmed.
## Test
TODO: Add test/lint commands once confirmed.

42
CHANGELOG.md Normal file
View file

@ -0,0 +1,42 @@
# Changelog
## [1.3] - 2026-01-31
### Added
- City management sheet - tap page indicator to manage, reorder and delete locations
- Drag-to-reorder support in city list
### Changed
- Page indicator moved higher to avoid overlapping yearly daylight chart
- Simplified page indicator styling
### Fixed
- Polish translation: "Daylight w roku" → "Długość dnia w roku"
### Removed
- "About" section from cards and city list (simplified UI)
## [1.2] - 2025-01-XX
### Added
- Location reordering support
- Improved page transitions between locations
## [1.1] - 2025-01-XX
### Added
- Sun data refresh on app resume
- Periodic 60-second auto-refresh
### Fixed
- Location name text alignment for multi-line names
## [1.0] - 2025-01-XX
### Added
- Initial release
- Multi-location sunrise/sunset tracking
- Animated sun/moon arc
- Time-of-day gradient backgrounds
- Yearly daylight chart
- 20 language translations

View file

@ -12,7 +12,7 @@ Track sunrise, sunset and daylight across multiple locations around the world. A
## Features
- **Multi-location tracking** -- swipe between saved locations to see real-time sun and moon data
- **Multi-location tracking** -- swipe between saved locations to see real-time sun and moon data; tap page indicator to manage cities
- **Sunrise & sunset times** with day/night length and countdown to next sunrise
- **Animated sun/moon arc** -- a visual progress indicator showing where the sun (or moon) is in its path across the sky
- **Moon phase & illumination** -- current lunar phase emoji and illumination percentage

View file

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

View file

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
@ -23,4 +24,10 @@ interface LocationDao {
@Query("DELETE FROM locations WHERE isCurrentLocation = 1")
suspend fun deleteCurrentLocation()
@Update
suspend fun updateLocations(locations: List<LocationEntity>)
@Query("SELECT * FROM locations ORDER BY displayOrder ASC, id ASC")
suspend fun getAllLocationsOnce(): List<LocationEntity>
}

View file

@ -23,4 +23,16 @@ class LocationRepository @Inject constructor(
locationDao.deleteCurrentLocation()
locationDao.insertLocation(location)
}
suspend fun reorderLocations(fromIndex: Int, toIndex: Int) {
val locations = locationDao.getAllLocationsOnce().toMutableList()
if (fromIndex !in locations.indices || toIndex !in locations.indices) return
val item = locations.removeAt(fromIndex)
locations.add(toIndex, item)
val updated = locations.mapIndexed { i, e -> e.copy(displayOrder = i) }
locationDao.updateLocations(updated)
}
suspend fun getAllLocationsOnce(): List<LocationEntity> =
locationDao.getAllLocationsOnce()
}

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,15 @@ 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.CityListSheet
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(
@ -42,6 +51,20 @@ fun MainScreen(
) {
val uiState by viewModel.uiState.collectAsState()
var showAddSheet by remember { mutableStateOf(false) }
var showCityListSheet 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) {
@ -70,10 +93,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
)
}
@ -81,10 +134,11 @@ fun MainScreen(
PageIndicator(
pageCount = uiState.locations.size,
currentPage = pagerState.currentPage,
onClick = { showCityListSheet = true },
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 80.dp)
.padding(bottom = 120.dp)
)
}
@ -111,4 +165,16 @@ fun MainScreen(
)
}
if (showCityListSheet) {
CityListSheet(
locations = uiState.locations,
onDismiss = { showCityListSheet = false },
onDelete = { viewModel.deleteLocation(it) },
onReorder = { from, to -> viewModel.reorderLocations(from, to) },
onAddClick = {
showCityListSheet = false
showAddSheet = true
}
)
}
}

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,16 +76,31 @@ 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 {
repository.deleteLocation(id)
}
}
fun reorderLocations(fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
repository.reorderLocations(fromIndex, toIndex)
}
}
}

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

@ -0,0 +1,269 @@
package com.sunzones.ui.main.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.DragHandle
import androidx.compose.material.icons.rounded.MyLocation
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.sunzones.R
import com.sunzones.domain.model.SunLocation
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CityListSheet(
locations: List<SunLocation>,
onDismiss: () -> Unit,
onDelete: (Long) -> Unit,
onReorder: (fromIndex: Int, toIndex: Int) -> Unit,
onAddClick: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val haptic = LocalHapticFeedback.current
val density = LocalDensity.current
val localList = remember { mutableStateListOf<SunLocation>() }
LaunchedEffect(locations) {
localList.clear()
localList.addAll(locations)
}
var draggedItemIndex by remember { mutableIntStateOf(-1) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
val itemHeight = with(density) { 56.dp.toPx() }
var locationToDelete by remember { mutableStateOf<SunLocation?>(null) }
if (locationToDelete != null) {
AlertDialog(
onDismissRequest = { locationToDelete = null },
title = { Text(stringResource(R.string.delete_location_title)) },
text = { Text(stringResource(R.string.delete_location_message, locationToDelete!!.name)) },
confirmButton = {
TextButton(
onClick = {
locationToDelete?.let { location ->
val index = localList.indexOfFirst { it.id == location.id }
if (index >= 0) {
localList.removeAt(index)
}
onDelete(location.id)
}
locationToDelete = null
}
) {
Text(
text = stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error
)
}
},
dismissButton = {
TextButton(onClick = { locationToDelete = null }) {
Text(stringResource(R.string.cancel))
}
}
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.manage_cities),
style = MaterialTheme.typography.titleLarge
)
IconButton(onClick = onAddClick) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.add_location),
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (localList.isEmpty()) {
Text(
text = stringResource(R.string.empty_state),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
} else {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth()
) {
itemsIndexed(
items = localList,
key = { _, item -> item.id }
) { index, location ->
val isDragging = draggedItemIndex == index
val elevation by animateDpAsState(
targetValue = if (isDragging) 8.dp else 0.dp,
label = "elevation"
)
val backgroundColor by animateColorAsState(
targetValue = if (isDragging) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surface
},
label = "backgroundColor"
)
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (isDragging) 1f else 0f)
.offset {
IntOffset(
x = 0,
y = if (isDragging) dragOffsetY.roundToInt() else 0
)
}
.shadow(elevation, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.height(56.dp)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Rounded.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(24.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggedItemIndex = index
dragOffsetY = 0f
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
val targetIndex = (index + (dragOffsetY / itemHeight).roundToInt())
.coerceIn(0, localList.size - 1)
if (targetIndex != index && targetIndex != draggedItemIndex) {
val item = localList.removeAt(draggedItemIndex)
localList.add(targetIndex, item)
dragOffsetY -= (targetIndex - draggedItemIndex) * itemHeight
draggedItemIndex = targetIndex
}
},
onDragEnd = {
if (draggedItemIndex != index) {
onReorder(index, draggedItemIndex)
}
draggedItemIndex = -1
dragOffsetY = 0f
},
onDragCancel = {
draggedItemIndex = -1
dragOffsetY = 0f
}
)
}
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = location.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
if (location.isCurrentLocation) {
Icon(
imageVector = Icons.Rounded.MyLocation,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
IconButton(
onClick = { locationToDelete = location }
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringResource(R.string.delete),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
}

View file

@ -4,12 +4,15 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -22,12 +25,18 @@ import androidx.compose.ui.unit.dp
fun PageIndicator(
pageCount: Int,
currentPage: Int,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) {
if (pageCount <= 1) return
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@ -54,4 +63,5 @@ fun PageIndicator(
)
}
}
}
}

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

@ -1,13 +1,11 @@
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
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@ -24,6 +22,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
@ -38,8 +37,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.derivedStateOf
import androidx.compose.runtime.getValue
@ -51,7 +48,8 @@ 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.platform.LocalContext
import androidx.compose.ui.graphics.graphicsLayer
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 +57,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 +75,13 @@ 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 +90,53 @@ 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 + dark scrim (no alpha fade to avoid "lightening" effect)
val scale = lerp(0.92f, 1f, 1f - pageOffset.absoluteValue.coerceAtMost(1f))
val scrimAlpha = lerp(0f, 0.2f, pageOffset.absoluteValue.coerceAtMost(1f))
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.background(brush = Brush.verticalGradient(gradientColors))
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.background(brush = Brush.verticalGradient(displayGradient))
) {
val pageHeight = maxHeight
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
Column(
modifier = Modifier
@ -113,10 +160,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 +186,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 +227,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 +292,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
@ -292,23 +386,6 @@ fun SunCard(
modifier = Modifier.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Subtle about button
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.about_title),
style = MaterialTheme.typography.bodySmall,
color = TextOnDarkSecondary.copy(alpha = 0.35f),
modifier = Modifier
.clickable { showAboutDialog = true }
.padding(vertical = 12.dp, horizontal = 24.dp)
)
}
// Space for navigation bar + page indicator + FAB
Spacer(
modifier = Modifier
@ -317,6 +394,15 @@ fun SunCard(
)
}
}
// Dark scrim overlay for page transition depth effect
if (scrimAlpha > 0.01f) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))
)
}
}
if (showDeleteDialog) {
@ -340,78 +426,37 @@ fun SunCard(
)
}
if (showAboutDialog) {
val context = LocalContext.current
val email = stringResource(R.string.about_email)
Dialog(
onDismissRequest = { showAboutDialog = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.padding(horizontal = 48.dp)
.background(
color = Color.Black.copy(alpha = 0.7f),
shape = RoundedCornerShape(28.dp)
)
.padding(vertical = 32.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "\u2600\uFE0F",
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.about_made_in),
style = MaterialTheme.typography.bodyLarge,
color = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email"))
)
}) {
Text(
text = stringResource(R.string.about_contact),
color = SunGold
)
}
}
}
}
}
}
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)

View file

@ -12,10 +12,12 @@
<string name="delete_location_message">Usunąć %1$s z Twoich lokalizacji?</string>
<string name="delete">Usuń</string>
<string name="cancel">Anuluj</string>
<string name="yearly_daylight">Daylight w roku</string>
<string name="yearly_daylight">Długość dnia w roku</string>
<string name="about_title">O aplikacji</string>
<string name="about_contact">Napisz do mnie</string>
<string name="ok">OK</string>
<string name="night_length">Długość nocy</string>
<string name="sunrise_in">Wschód za %1$s</string>
<string name="manage_cities">Zarządzaj miastami</string>
<string name="drag_to_reorder">Przeciągnij by zmienić kolejność</string>
</resources>

View file

@ -20,4 +20,6 @@
<string name="ok">OK</string>
<string name="night_length">Night length</string>
<string name="sunrise_in">Sunrise in %1$s</string>
<string name="manage_cities">Manage Cities</string>
<string name="drag_to_reorder">Drag to reorder</string>
</resources>