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 ## 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 - **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 - **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 - **Moon phase & illumination** -- current lunar phase emoji and illumination percentage

View file

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

View file

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@ -23,4 +24,10 @@ interface LocationDao {
@Query("DELETE FROM locations WHERE isCurrentLocation = 1") @Query("DELETE FROM locations WHERE isCurrentLocation = 1")
suspend fun deleteCurrentLocation() 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.deleteCurrentLocation()
locationDao.insertLocation(location) 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.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -47,6 +48,7 @@ fun AddLocationSheet(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val focusManager = LocalFocusManager.current
val locationPermissionLauncher = rememberLauncherForActivityResult( val locationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions() contract = ActivityResultContracts.RequestMultiplePermissions()
@ -60,11 +62,16 @@ fun AddLocationSheet(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.resetState() viewModel.resetState()
viewModel.uiState.collect { state ->
if (state.isSaved) {
onDismiss()
}
}
} }
LaunchedEffect(uiState.isSaved) { LaunchedEffect(uiState.searchResults) {
if (uiState.isSaved) { if (uiState.searchResults.isNotEmpty()) {
onDismiss() focusManager.clearFocus()
} }
} }
@ -88,6 +95,7 @@ fun AddLocationSheet(
// Use my location button // Use my location button
OutlinedButton( OutlinedButton(
onClick = { onClick = {
focusManager.clearFocus()
locationPermissionLauncher.launch( locationPermissionLauncher.launch(
arrayOf( arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION,
@ -145,7 +153,10 @@ fun AddLocationSheet(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { viewModel.selectSearchResult(result) } .clickable {
focusManager.clearFocus()
viewModel.selectSearchResult(result)
}
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {

View file

@ -1,5 +1,8 @@
package com.sunzones.ui.main 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -15,11 +18,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -29,12 +35,15 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.sunzones.R import com.sunzones.R
import com.sunzones.ui.addlocation.AddLocationSheet import com.sunzones.ui.addlocation.AddLocationSheet
import com.sunzones.ui.main.animation.getTimeGradient
import com.sunzones.ui.main.animation.lerpGradient
import com.sunzones.ui.main.components.CityListSheet
import com.sunzones.ui.main.components.PageIndicator import com.sunzones.ui.main.components.PageIndicator
import com.sunzones.ui.main.components.SunCard import com.sunzones.ui.main.components.SunCard
import com.sunzones.ui.theme.NightBottom import com.sunzones.ui.theme.NightBottom
import com.sunzones.ui.theme.NightTop import com.sunzones.ui.theme.NightTop
import com.sunzones.ui.theme.TextOnDark
import com.sunzones.ui.theme.TextOnDarkSecondary import com.sunzones.ui.theme.TextOnDarkSecondary
import kotlin.math.absoluteValue
@Composable @Composable
fun MainScreen( fun MainScreen(
@ -42,6 +51,20 @@ fun MainScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var showAddSheet by remember { mutableStateOf(false) } 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()) { Box(modifier = Modifier.fillMaxSize()) {
if (uiState.locations.isEmpty() && !uiState.isLoading) { if (uiState.locations.isEmpty() && !uiState.isLoading) {
@ -70,10 +93,40 @@ fun MainScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { page -> ) { page ->
val location = uiState.locations[page] val location = uiState.locations[page]
// Compute page offset for parallax
val pageOffset = ((pagerState.currentPage - page) +
pagerState.currentPageOffsetFraction)
// Compute blended gradient for crossfade
val currentGradient = getTimeGradient(location.sunProgress, location.isDaytime)
val blendedGradient = if (pageOffset.absoluteValue > 0.001f) {
val adjacentPage = if (pageOffset > 0) {
(page - 1).coerceAtLeast(0)
} else {
(page + 1).coerceAtMost(uiState.locations.size - 1)
}
val adjacentLocation = uiState.locations[adjacentPage]
val adjacentGradient = getTimeGradient(
adjacentLocation.sunProgress,
adjacentLocation.isDaytime
)
lerpGradient(
currentGradient,
adjacentGradient,
pageOffset.absoluteValue.coerceAtMost(1f)
)
} else {
currentGradient
}
SunCard( SunCard(
location = location, location = location,
onDelete = { viewModel.deleteLocation(location.id) }, onDelete = { viewModel.deleteLocation(location.id) },
monthlyDaylight = uiState.yearlyDaylight[location.id] monthlyDaylight = uiState.yearlyDaylight[location.id],
gradientColors = blendedGradient,
pageOffset = pageOffset,
entranceProgress = entranceProgress.value
) )
} }
@ -81,10 +134,11 @@ fun MainScreen(
PageIndicator( PageIndicator(
pageCount = uiState.locations.size, pageCount = uiState.locations.size,
currentPage = pagerState.currentPage, currentPage = pagerState.currentPage,
onClick = { showCityListSheet = true },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .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.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -75,16 +76,31 @@ class MainViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
while (isActive) { while (isActive) {
delay(60_000L) // refresh every minute for sun progress updates 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) _uiState.value = _uiState.value.copy(locations = locations)
} }
}
}
}
fun deleteLocation(id: Long) { fun deleteLocation(id: Long) {
viewModelScope.launch { viewModelScope.launch {
repository.deleteLocation(id) 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.animateDpAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -22,12 +25,18 @@ import androidx.compose.ui.unit.dp
fun PageIndicator( fun PageIndicator(
pageCount: Int, pageCount: Int,
currentPage: Int, currentPage: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) { ) {
if (pageCount <= 1) return if (pageCount <= 1) return
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row( Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@ -55,3 +64,4 @@ fun PageIndicator(
} }
} }
} }
}

View file

@ -1,8 +1,14 @@
package com.sunzones.ui.main.components package com.sunzones.ui.main.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -13,7 +19,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
@ -33,10 +38,11 @@ fun SunArc(
progress: Float, progress: Float,
isDaytime: Boolean, isDaytime: Boolean,
nightProgress: Float = 0f, nightProgress: Float = 0f,
entranceProgress: Float = 1f,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = progress, targetValue = progress * entranceProgress,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@ -45,7 +51,7 @@ fun SunArc(
) )
val animatedNightProgress by animateFloatAsState( val animatedNightProgress by animateFloatAsState(
targetValue = nightProgress, targetValue = nightProgress * entranceProgress,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@ -53,6 +59,27 @@ fun SunArc(
label = "night_progress" label = "night_progress"
) )
// Glow pulse animations
val glowTransition = rememberInfiniteTransition(label = "glow_pulse")
val sunPulseScale by glowTransition.animateFloat(
initialValue = 1.0f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "sun_pulse"
)
val moonPulseScale by glowTransition.animateFloat(
initialValue = 1.0f,
targetValue = 1.10f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 3500, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "moon_pulse"
)
Canvas( Canvas(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -112,7 +139,7 @@ fun SunArc(
val ry = arcHeight val ry = arcHeight
val sunX = centerX + (rx * cos(angle)).toFloat() val sunX = centerX + (rx * cos(angle)).toFloat()
val sunY = baseY - (ry * sin(angle)).toFloat() val sunY = baseY - (ry * sin(angle)).toFloat()
drawSunGlow(sunX, sunY) drawSunGlow(sunX, sunY, sunPulseScale)
} else if (!isDaytime && animatedNightProgress > 0f) { } else if (!isDaytime && animatedNightProgress > 0f) {
// Progress arc (silver) — nighttime // Progress arc (silver) — nighttime
drawArc( drawArc(
@ -135,12 +162,12 @@ fun SunArc(
val ry = arcHeight val ry = arcHeight
val moonX = centerX + (rx * cos(angle)).toFloat() val moonX = centerX + (rx * cos(angle)).toFloat()
val moonY = baseY - (ry * sin(angle)).toFloat() val moonY = baseY - (ry * sin(angle)).toFloat()
drawMoonGlow(moonX, moonY) drawMoonGlow(moonX, moonY, moonPulseScale)
} }
} }
} }
private fun DrawScope.drawSunGlow(x: Float, y: Float) { private fun DrawScope.drawSunGlow(x: Float, y: Float, pulseScale: Float) {
val glowLayers = listOf( val glowLayers = listOf(
24.dp.toPx() to 0.05f, 24.dp.toPx() to 0.05f,
18.dp.toPx() to 0.08f, 18.dp.toPx() to 0.08f,
@ -150,8 +177,8 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float) {
for ((radius, alpha) in glowLayers) { for ((radius, alpha) in glowLayers) {
drawCircle( drawCircle(
color = SunGold.copy(alpha = alpha), color = SunGold.copy(alpha = alpha / pulseScale),
radius = radius, radius = radius * pulseScale,
center = Offset(x, y) center = Offset(x, y)
) )
} }
@ -167,7 +194,7 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float) {
) )
} }
private fun DrawScope.drawMoonGlow(x: Float, y: Float) { private fun DrawScope.drawMoonGlow(x: Float, y: Float, pulseScale: Float) {
val glowLayers = listOf( val glowLayers = listOf(
18.dp.toPx() to 0.04f, 18.dp.toPx() to 0.04f,
12.dp.toPx() to 0.08f, 12.dp.toPx() to 0.08f,
@ -176,8 +203,8 @@ private fun DrawScope.drawMoonGlow(x: Float, y: Float) {
for ((radius, alpha) in glowLayers) { for ((radius, alpha) in glowLayers) {
drawCircle( drawCircle(
color = MoonSilver.copy(alpha = alpha), color = MoonSilver.copy(alpha = alpha / pulseScale),
radius = radius, radius = radius * pulseScale,
center = Offset(x, y) center = Offset(x, y)
) )
} }

View file

@ -1,13 +1,11 @@
package com.sunzones.ui.main.components package com.sunzones.ui.main.components
import android.content.Intent import androidx.compose.animation.core.LinearEasing
import android.net.Uri
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -38,8 +37,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -51,7 +48,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
@ -59,8 +57,15 @@ import androidx.compose.ui.unit.dp
import com.sunzones.R import com.sunzones.R
import com.sunzones.domain.model.MonthDaylight import com.sunzones.domain.model.MonthDaylight
import com.sunzones.domain.model.SunLocation import com.sunzones.domain.model.SunLocation
import com.sunzones.ui.main.animation.lerpGradient
import com.sunzones.ui.main.animation.shiftColorSubtle
import com.sunzones.ui.theme.* import com.sunzones.ui.theme.*
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.sin
import androidx.compose.ui.util.lerp
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@ -70,12 +75,13 @@ fun SunCard(
location: SunLocation, location: SunLocation,
onDelete: () -> Unit, onDelete: () -> Unit,
monthlyDaylight: List<MonthDaylight>?, monthlyDaylight: List<MonthDaylight>?,
gradientColors: List<Color> = listOf(NightTop, NightBottom),
pageOffset: Float = 0f,
entranceProgress: Float = 1f,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var showAboutDialog by remember { mutableStateOf(false) }
val gradientColors = getTimeGradient(location.sunProgress, location.isDaytime)
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val chevronAlpha by remember { val chevronAlpha by remember {
@ -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( BoxWithConstraints(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(brush = Brush.verticalGradient(gradientColors)) .graphicsLayer {
scaleX = scale
scaleY = scale
}
.background(brush = Brush.verticalGradient(displayGradient))
) { ) {
val pageHeight = maxHeight val pageHeight = maxHeight
val maxWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
Column( Column(
modifier = Modifier modifier = Modifier
@ -113,10 +160,17 @@ fun SunCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// Location name // Location name — entrance index 0
val nameAlpha = entranceAlpha(entrance, 0)
val nameDy = entranceTranslationY(entrance, 0)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center,
modifier = Modifier
.graphicsLayer {
alpha = nameAlpha
translationY = nameDy
}
) { ) {
if (location.isCurrentLocation) { if (location.isCurrentLocation) {
Icon( Icon(
@ -132,20 +186,27 @@ fun SunCard(
text = location.name, text = location.name,
style = MaterialTheme.typography.displayMedium.copy(shadow = TextShadowStrong), style = MaterialTheme.typography.displayMedium.copy(shadow = TextShadowStrong),
color = TextOnDark, color = TextOnDark,
maxLines = 2 maxLines = 2,
textAlign = TextAlign.Center
) )
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Sun/Moon arc with optional moon phase overlay // Sun/Moon arc — entrance index 1, parallax background layer
Box( Box(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center,
modifier = Modifier
.graphicsLayer {
alpha = entranceAlpha(entrance, 1)
translationY = entranceTranslationY(entrance, 1)
}
) { ) {
SunArc( SunArc(
progress = location.sunProgress, progress = location.sunProgress,
isDaytime = location.isDaytime, isDaytime = location.isDaytime,
nightProgress = location.nightProgress, nightProgress = location.nightProgress,
entranceProgress = entrance,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -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)) Spacer(modifier = Modifier.height(32.dp))
// Hero element — day length or night length // Hero element — entrance index 2
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.graphicsLayer {
alpha = entranceAlpha(entrance, 2)
translationY = entranceTranslationY(entrance, 2)
}
) {
if (location.isDaytime) { if (location.isDaytime) {
Text( Text(
text = location.dayLengthFormatted, text = location.dayLengthFormatted,
@ -204,12 +292,18 @@ fun SunCard(
) )
} }
} }
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Sunrise / Sunset times // Sunrise / Sunset times — entrance index 3
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = entranceAlpha(entrance, 3)
translationY = entranceTranslationY(entrance, 3)
},
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
// Sunrise // Sunrise
@ -292,23 +386,6 @@ fun SunCard(
modifier = Modifier.padding(top = 8.dp) 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 // Space for navigation bar + page indicator + FAB
Spacer( Spacer(
modifier = Modifier 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) { 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.
*/
return when { private fun entranceAlpha(progress: Float, index: Int): Float {
sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom) val stagger = index * 0.08f
sunProgress < 0.3f -> listOf(MorningTop, MorningBottom) return ((progress - stagger) / (1f - stagger)).coerceIn(0f, 1f)
sunProgress < 0.7f -> listOf(MiddayTop, MiddayBottom)
sunProgress < 0.85f -> listOf(AfternoonTop, AfternoonBottom)
sunProgress < 0.95f -> listOf(SunsetTop, SunsetBottom)
else -> listOf(TwilightTop, TwilightBottom)
} }
/**
* Staggered entrance vertical translation for element at given index.
* Returns pixels to offset (20dp equivalent fading upward).
*/
private fun entranceTranslationY(progress: Float, index: Int): Float {
val alpha = entranceAlpha(progress, index)
return (1f - alpha) * 60f // ~20dp in pixels at mdpi, scales naturally
} }
private fun moonPhaseEmoji(degrees: Double): String { private fun moonPhaseEmoji(degrees: Double): String {
// degrees: -180..180 where 0=new moon, 90=first quarter, 180/-180=full, -90=last quarter // degrees: -180..180 where 0=new moon, 90=first quarter, 180/-180=full, -90=last quarter
val normalized = ((degrees % 360) + 360) % 360 // normalize to 0..360 val normalized = ((degrees % 360) + 360) % 360 // normalize to 0..360
return when { return when {
normalized < 22.5 -> "\uD83C\uDF11" // 🌑 New moon normalized < 22.5 -> "\uD83C\uDF11" // New moon
normalized < 67.5 -> "\uD83C\uDF12" // 🌒 Waxing crescent normalized < 67.5 -> "\uD83C\uDF12" // Waxing crescent
normalized < 112.5 -> "\uD83C\uDF13" // 🌓 First quarter normalized < 112.5 -> "\uD83C\uDF13" // First quarter
normalized < 157.5 -> "\uD83C\uDF14" // 🌔 Waxing gibbous normalized < 157.5 -> "\uD83C\uDF14" // Waxing gibbous
normalized < 202.5 -> "\uD83C\uDF15" // 🌕 Full moon normalized < 202.5 -> "\uD83C\uDF15" // Full moon
normalized < 247.5 -> "\uD83C\uDF16" // 🌖 Waning gibbous normalized < 247.5 -> "\uD83C\uDF16" // Waning gibbous
normalized < 292.5 -> "\uD83C\uDF17" // 🌗 Last quarter normalized < 292.5 -> "\uD83C\uDF17" // Last quarter
normalized < 337.5 -> "\uD83C\uDF18" // 🌘 Waning crescent normalized < 337.5 -> "\uD83C\uDF18" // Waning crescent
else -> "\uD83C\uDF11" // 🌑 New moon else -> "\uD83C\uDF11" // New moon
} }
} }

View file

@ -0,0 +1,123 @@
package com.sunzones.ui.main.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import com.sunzones.ui.theme.SunGlow
import com.sunzones.ui.theme.SunGold
import com.sunzones.ui.theme.SunOrange
import com.sunzones.ui.theme.SunriseParticle
import com.sunzones.ui.theme.SunsetParticle1
import com.sunzones.ui.theme.SunsetParticle2
import com.sunzones.ui.theme.SunsetParticle3
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
private data class Particle(
var x: Float,
var y: Float,
var vx: Float,
var vy: Float,
var alpha: Float,
var scale: Float,
var life: Float, // 0..1, decreasing
val color: Color
)
@Composable
fun SunsetParticles(
active: Boolean,
isSunrise: Boolean,
emitX: Float,
emitY: Float,
modifier: Modifier = Modifier
) {
val particles = remember { mutableStateListOf<Particle>() }
val palette = if (isSunrise) {
listOf(SunGold, SunOrange, SunGlow, SunriseParticle)
} else {
listOf(SunOrange, SunsetParticle1, SunsetParticle2, SunsetParticle3)
}
LaunchedEffect(active, emitX, emitY) {
if (!active) {
particles.clear()
return@LaunchedEffect
}
// Emit burst of 24 particles
particles.clear()
repeat(24) {
val angle = Random.nextFloat() * 2f * Math.PI.toFloat()
val speed = 40f + Random.nextFloat() * 80f
particles.add(
Particle(
x = emitX + (Random.nextFloat() - 0.5f) * 8f,
y = emitY + (Random.nextFloat() - 0.5f) * 8f,
vx = cos(angle) * speed,
vy = -sin(angle).coerceAtLeast(0.1f) * speed * 0.8f,
alpha = 0.7f + Random.nextFloat() * 0.3f,
scale = 0.5f + Random.nextFloat() * 0.5f,
life = 1f,
color = palette[Random.nextInt(palette.size)]
)
)
}
var lastTime = System.nanoTime()
while (isActive && particles.isNotEmpty()) {
delay(16L)
val now = System.nanoTime()
val dt = ((now - lastTime) / 1_000_000_000f).coerceAtMost(0.05f)
lastTime = now
val iterator = particles.listIterator()
while (iterator.hasNext()) {
val p = iterator.next()
p.x += p.vx * dt
p.y += p.vy * dt
p.vy += 60f * dt // gravity
p.life -= dt * 0.8f
p.alpha = (p.life * 0.8f).coerceIn(0f, 1f)
p.scale *= (1f - dt * 0.5f)
if (p.life <= 0f) {
iterator.remove()
} else {
iterator.set(p.copy())
}
}
}
}
Canvas(modifier = modifier.fillMaxSize()) {
for (p in particles) {
val radius = 4f * p.scale
if (radius > 0.5f && p.alpha > 0.01f) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
p.color.copy(alpha = p.alpha),
p.color.copy(alpha = p.alpha * 0.3f)
),
center = Offset(p.x, p.y),
radius = radius * 2f
),
radius = radius * 2f,
center = Offset(p.x, p.y)
)
}
}
}
}

View file

@ -52,6 +52,12 @@ val MoonSilver = Color(0xFFE0E0E0)
val MoonGlow = Color(0xFFF5F5F5) val MoonGlow = Color(0xFFF5F5F5)
val MoonBlue = Color(0xFFB0BEC5) val MoonBlue = Color(0xFFB0BEC5)
// Particle colors
val SunriseParticle = Color(0xFFFFE082)
val SunsetParticle1 = Color(0xFFE65100)
val SunsetParticle2 = Color(0xFF7B1FA2)
val SunsetParticle3 = Color(0xFFFF7043)
// Material 3 light colors // Material 3 light colors
val PrimaryLight = Color(0xFF1565C0) val PrimaryLight = Color(0xFF1565C0)
val OnPrimaryLight = Color(0xFFFFFFFF) val OnPrimaryLight = Color(0xFFFFFFFF)

View file

@ -12,10 +12,12 @@
<string name="delete_location_message">Usunąć %1$s z Twoich lokalizacji?</string> <string name="delete_location_message">Usunąć %1$s z Twoich lokalizacji?</string>
<string name="delete">Usuń</string> <string name="delete">Usuń</string>
<string name="cancel">Anuluj</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_title">O aplikacji</string>
<string name="about_contact">Napisz do mnie</string> <string name="about_contact">Napisz do mnie</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="night_length">Długość nocy</string> <string name="night_length">Długość nocy</string>
<string name="sunrise_in">Wschód za %1$s</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> </resources>

View file

@ -20,4 +20,6 @@
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="night_length">Night length</string> <string name="night_length">Night length</string>
<string name="sunrise_in">Sunrise in %1$s</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> </resources>