Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9477b32397 | ||
|
|
5e4bf7e21b | ||
|
|
0a6ef134ee | ||
|
|
cfe6c91e0f | ||
|
|
d83790f218 | ||
|
|
aede45cec2 | ||
|
|
0512ee674d | ||
|
|
ecd734582c | ||
|
|
34c842c2d5 | ||
|
|
87ad810392 |
22 changed files with 989 additions and 171 deletions
15
.claude/commands/commit-push-pr.md
Normal file
15
.claude/commands/commit-push-pr.md
Normal 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.
|
||||
15
.claude/commands/new-version.md
Normal file
15
.claude/commands/new-version.md
Normal 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
|
||||
44
.github/workflows/claude-code-review.yml
vendored
Normal file
44
.github/workflows/claude-code-review.yml
vendored
Normal 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
50
.github/workflows/claude.yml
vendored
Normal 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
18
AGENTS.md
Normal 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
42
CHANGELOG.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ android {
|
|||
applicationId = "com.sunzones"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.2"
|
||||
versionCode = 4
|
||||
versionName = "1.3"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.compose.runtime.collectAsState
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
@ -47,6 +48,7 @@ fun AddLocationSheet(
|
|||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||
|
|
@ -60,11 +62,16 @@ fun AddLocationSheet(
|
|||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.resetState()
|
||||
viewModel.uiState.collect { state ->
|
||||
if (state.isSaved) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.isSaved) {
|
||||
if (uiState.isSaved) {
|
||||
onDismiss()
|
||||
LaunchedEffect(uiState.searchResults) {
|
||||
if (uiState.searchResults.isNotEmpty()) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +95,7 @@ fun AddLocationSheet(
|
|||
// Use my location button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
|
|
@ -145,7 +153,10 @@ fun AddLocationSheet(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.selectSearchResult(result) }
|
||||
.clickable {
|
||||
focusManager.clearFocus()
|
||||
viewModel.selectSearchResult(result)
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.sunzones.ui.main
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
|
@ -15,6 +18,7 @@ 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
|
||||
|
|
@ -31,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(
|
||||
|
|
@ -44,6 +51,16 @@ 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()
|
||||
|
|
@ -76,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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -87,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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -117,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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,4 +97,10 @@ class MainViewModel @Inject constructor(
|
|||
repository.deleteLocation(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun reorderLocations(fromIndex: Int, toIndex: Int) {
|
||||
viewModelScope.launch {
|
||||
repository.reorderLocations(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -39,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
|
||||
|
|
@ -52,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
|
||||
|
|
@ -60,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")
|
||||
|
||||
|
|
@ -71,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 {
|
||||
|
|
@ -85,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
|
||||
|
|
@ -114,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(
|
||||
|
|
@ -140,14 +193,20 @@ fun SunCard(
|
|||
|
||||
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()
|
||||
)
|
||||
|
||||
|
|
@ -168,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,
|
||||
|
|
@ -206,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
|
||||
|
|
@ -294,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
|
||||
|
|
@ -319,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) {
|
||||
|
|
@ -342,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
package com.sunzones.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.sunzones.ui.theme.SunGlow
|
||||
import com.sunzones.ui.theme.SunGold
|
||||
import com.sunzones.ui.theme.SunOrange
|
||||
import com.sunzones.ui.theme.SunriseParticle
|
||||
import com.sunzones.ui.theme.SunsetParticle1
|
||||
import com.sunzones.ui.theme.SunsetParticle2
|
||||
import com.sunzones.ui.theme.SunsetParticle3
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
private data class Particle(
|
||||
var x: Float,
|
||||
var y: Float,
|
||||
var vx: Float,
|
||||
var vy: Float,
|
||||
var alpha: Float,
|
||||
var scale: Float,
|
||||
var life: Float, // 0..1, decreasing
|
||||
val color: Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SunsetParticles(
|
||||
active: Boolean,
|
||||
isSunrise: Boolean,
|
||||
emitX: Float,
|
||||
emitY: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val particles = remember { mutableStateListOf<Particle>() }
|
||||
|
||||
val palette = if (isSunrise) {
|
||||
listOf(SunGold, SunOrange, SunGlow, SunriseParticle)
|
||||
} else {
|
||||
listOf(SunOrange, SunsetParticle1, SunsetParticle2, SunsetParticle3)
|
||||
}
|
||||
|
||||
LaunchedEffect(active, emitX, emitY) {
|
||||
if (!active) {
|
||||
particles.clear()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
// Emit burst of 24 particles
|
||||
particles.clear()
|
||||
repeat(24) {
|
||||
val angle = Random.nextFloat() * 2f * Math.PI.toFloat()
|
||||
val speed = 40f + Random.nextFloat() * 80f
|
||||
particles.add(
|
||||
Particle(
|
||||
x = emitX + (Random.nextFloat() - 0.5f) * 8f,
|
||||
y = emitY + (Random.nextFloat() - 0.5f) * 8f,
|
||||
vx = cos(angle) * speed,
|
||||
vy = -sin(angle).coerceAtLeast(0.1f) * speed * 0.8f,
|
||||
alpha = 0.7f + Random.nextFloat() * 0.3f,
|
||||
scale = 0.5f + Random.nextFloat() * 0.5f,
|
||||
life = 1f,
|
||||
color = palette[Random.nextInt(palette.size)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var lastTime = System.nanoTime()
|
||||
while (isActive && particles.isNotEmpty()) {
|
||||
delay(16L)
|
||||
val now = System.nanoTime()
|
||||
val dt = ((now - lastTime) / 1_000_000_000f).coerceAtMost(0.05f)
|
||||
lastTime = now
|
||||
|
||||
val iterator = particles.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val p = iterator.next()
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
p.vy += 60f * dt // gravity
|
||||
p.life -= dt * 0.8f
|
||||
p.alpha = (p.life * 0.8f).coerceIn(0f, 1f)
|
||||
p.scale *= (1f - dt * 0.5f)
|
||||
if (p.life <= 0f) {
|
||||
iterator.remove()
|
||||
} else {
|
||||
iterator.set(p.copy())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
for (p in particles) {
|
||||
val radius = 4f * p.scale
|
||||
if (radius > 0.5f && p.alpha > 0.01f) {
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
p.color.copy(alpha = p.alpha),
|
||||
p.color.copy(alpha = p.alpha * 0.3f)
|
||||
),
|
||||
center = Offset(p.x, p.y),
|
||||
radius = radius * 2f
|
||||
),
|
||||
radius = radius * 2f,
|
||||
center = Offset(p.x, p.y)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,12 @@ val MoonSilver = Color(0xFFE0E0E0)
|
|||
val MoonGlow = Color(0xFFF5F5F5)
|
||||
val MoonBlue = Color(0xFFB0BEC5)
|
||||
|
||||
// Particle colors
|
||||
val SunriseParticle = Color(0xFFFFE082)
|
||||
val SunsetParticle1 = Color(0xFFE65100)
|
||||
val SunsetParticle2 = Color(0xFF7B1FA2)
|
||||
val SunsetParticle3 = Color(0xFFFF7043)
|
||||
|
||||
// Material 3 light colors
|
||||
val PrimaryLight = Color(0xFF1565C0)
|
||||
val OnPrimaryLight = Color(0xFFFFFFFF)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue