Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9477b32397 | ||
|
|
5e4bf7e21b | ||
|
|
0a6ef134ee | ||
|
|
cfe6c91e0f | ||
|
|
d83790f218 | ||
|
|
aede45cec2 | ||
|
|
0512ee674d | ||
|
|
ecd734582c | ||
|
|
34c842c2d5 | ||
|
|
87ad810392 | ||
|
|
a22b994b0a | ||
|
|
117a16d571 | ||
|
|
dda6d664ab | ||
|
|
07808be975 | ||
|
|
36e958d828 |
22 changed files with 1011 additions and 175 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
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue