Compare commits
No commits in common. "main" and "claude/fix-name-alignment-mg6dN" have entirely different histories.
main
...
claude/fix
22 changed files with 174 additions and 1008 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
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
44
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,44 +0,0 @@
|
||||||
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
50
.github/workflows/claude.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
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
18
AGENTS.md
|
|
@ -1,18 +0,0 @@
|
||||||
# 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
42
CHANGELOG.md
|
|
@ -1,42 +0,0 @@
|
||||||
# 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; tap page indicator to manage cities
|
- **Multi-location tracking** -- swipe between saved locations to see real-time sun and moon data
|
||||||
- **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 = 4
|
versionCode = 1
|
||||||
versionName = "1.3"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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
|
||||||
|
|
@ -24,10 +23,4 @@ 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,16 +23,4 @@ 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,7 +34,6 @@ 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
|
||||||
|
|
@ -48,7 +47,6 @@ 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()
|
||||||
|
|
@ -62,16 +60,11 @@ fun AddLocationSheet(
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.resetState()
|
viewModel.resetState()
|
||||||
viewModel.uiState.collect { state ->
|
|
||||||
if (state.isSaved) {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.searchResults) {
|
LaunchedEffect(uiState.isSaved) {
|
||||||
if (uiState.searchResults.isNotEmpty()) {
|
if (uiState.isSaved) {
|
||||||
focusManager.clearFocus()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +88,6 @@ 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,
|
||||||
|
|
@ -153,10 +145,7 @@ fun AddLocationSheet(
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable { viewModel.selectSearchResult(result) }
|
||||||
focusManager.clearFocus()
|
|
||||||
viewModel.selectSearchResult(result)
|
|
||||||
}
|
|
||||||
.padding(vertical = 12.dp),
|
.padding(vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -18,14 +15,11 @@ 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
|
||||||
|
|
@ -35,15 +29,12 @@ 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(
|
||||||
|
|
@ -51,20 +42,6 @@ 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) {
|
||||||
|
|
@ -93,40 +70,10 @@ 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,11 +81,10 @@ 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 = 120.dp)
|
.padding(bottom = 80.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,16 +111,4 @@ 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,7 +14,6 @@ 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
|
||||||
|
|
@ -76,31 +75,16 @@ 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
|
||||||
refreshNow()
|
getLocationsUseCase().collectLatest { locations ->
|
||||||
|
_uiState.value = _uiState.value.copy(locations = locations)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
refreshNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshNow() {
|
|
||||||
val locations = getLocationsUseCase().first()
|
|
||||||
_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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
package com.sunzones.ui.main.animation
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import com.sunzones.ui.theme.*
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-color lerp between two gradient color lists.
|
|
||||||
*/
|
|
||||||
fun lerpGradient(gradientA: List<Color>, gradientB: List<Color>, fraction: Float): List<Color> {
|
|
||||||
val clampedFraction = fraction.coerceIn(0f, 1f)
|
|
||||||
return gradientA.indices.map { i ->
|
|
||||||
lerp(gradientA[i], gradientB.getOrElse(i) { gradientA[i] }, clampedFraction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subtle HSV shift for ambient sky animation.
|
|
||||||
* Shifts hue by +/-3 degrees and saturation by +/-3% based on phase (0..1).
|
|
||||||
*/
|
|
||||||
fun shiftColorSubtle(color: Color, phase: Float): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(
|
|
||||||
android.graphics.Color.argb(
|
|
||||||
(color.alpha * 255).toInt(),
|
|
||||||
(color.red * 255).toInt(),
|
|
||||||
(color.green * 255).toInt(),
|
|
||||||
(color.blue * 255).toInt()
|
|
||||||
),
|
|
||||||
hsv
|
|
||||||
)
|
|
||||||
val twoPiPhase = phase * 2f * Math.PI.toFloat()
|
|
||||||
hsv[0] = (hsv[0] + 3f * sin(twoPiPhase)) % 360f
|
|
||||||
if (hsv[0] < 0f) hsv[0] += 360f
|
|
||||||
hsv[1] = (hsv[1] + 0.03f * cos(twoPiPhase)).coerceIn(0f, 1f)
|
|
||||||
val argb = android.graphics.Color.HSVToColor(hsv)
|
|
||||||
return Color(argb).copy(alpha = color.alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns gradient colors for the given sun progress and daytime state.
|
|
||||||
*/
|
|
||||||
fun getTimeGradient(sunProgress: Float, isDaytime: Boolean): List<Color> {
|
|
||||||
if (!isDaytime) return listOf(NightTop, NightBottom)
|
|
||||||
|
|
||||||
return when {
|
|
||||||
sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom)
|
|
||||||
sunProgress < 0.3f -> listOf(MorningTop, MorningBottom)
|
|
||||||
sunProgress < 0.7f -> listOf(MiddayTop, MiddayBottom)
|
|
||||||
sunProgress < 0.85f -> listOf(AfternoonTop, AfternoonBottom)
|
|
||||||
sunProgress < 0.95f -> listOf(SunsetTop, SunsetBottom)
|
|
||||||
else -> listOf(TwilightTop, TwilightBottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
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,15 +4,12 @@ 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
|
||||||
|
|
@ -25,43 +22,36 @@ 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(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.clip(RoundedCornerShape(16.dp))
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
.clickable(onClick = onClick)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
repeat(pageCount) { index ->
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
val isSelected = index == currentPage
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
repeat(pageCount) { index ->
|
|
||||||
val isSelected = index == currentPage
|
|
||||||
|
|
||||||
val width by animateDpAsState(
|
val width by animateDpAsState(
|
||||||
targetValue = if (isSelected) 24.dp else 8.dp,
|
targetValue = if (isSelected) 24.dp else 8.dp,
|
||||||
animationSpec = spring(),
|
animationSpec = spring(),
|
||||||
label = "indicator_width"
|
label = "indicator_width"
|
||||||
)
|
)
|
||||||
|
|
||||||
val color by animateColorAsState(
|
val color by animateColorAsState(
|
||||||
targetValue = if (isSelected) Color.White else Color.White.copy(alpha = 0.4f),
|
targetValue = if (isSelected) Color.White else Color.White.copy(alpha = 0.4f),
|
||||||
label = "indicator_color"
|
label = "indicator_color"
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(width)
|
.width(width)
|
||||||
.height(8.dp)
|
.height(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color)
|
.background(color)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
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
|
||||||
|
|
@ -19,6 +13,7 @@ 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
|
||||||
|
|
@ -38,11 +33,10 @@ 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 * entranceProgress,
|
targetValue = progress,
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
stiffness = Spring.StiffnessLow
|
stiffness = Spring.StiffnessLow
|
||||||
|
|
@ -51,7 +45,7 @@ fun SunArc(
|
||||||
)
|
)
|
||||||
|
|
||||||
val animatedNightProgress by animateFloatAsState(
|
val animatedNightProgress by animateFloatAsState(
|
||||||
targetValue = nightProgress * entranceProgress,
|
targetValue = nightProgress,
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
stiffness = Spring.StiffnessLow
|
stiffness = Spring.StiffnessLow
|
||||||
|
|
@ -59,27 +53,6 @@ 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()
|
||||||
|
|
@ -139,7 +112,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, sunPulseScale)
|
drawSunGlow(sunX, sunY)
|
||||||
} else if (!isDaytime && animatedNightProgress > 0f) {
|
} else if (!isDaytime && animatedNightProgress > 0f) {
|
||||||
// Progress arc (silver) — nighttime
|
// Progress arc (silver) — nighttime
|
||||||
drawArc(
|
drawArc(
|
||||||
|
|
@ -162,12 +135,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, moonPulseScale)
|
drawMoonGlow(moonX, moonY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawSunGlow(x: Float, y: Float, pulseScale: Float) {
|
private fun DrawScope.drawSunGlow(x: Float, y: 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,
|
||||||
|
|
@ -177,8 +150,8 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float, pulseScale: Float) {
|
||||||
|
|
||||||
for ((radius, alpha) in glowLayers) {
|
for ((radius, alpha) in glowLayers) {
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = SunGold.copy(alpha = alpha / pulseScale),
|
color = SunGold.copy(alpha = alpha),
|
||||||
radius = radius * pulseScale,
|
radius = radius,
|
||||||
center = Offset(x, y)
|
center = Offset(x, y)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +167,7 @@ private fun DrawScope.drawSunGlow(x: Float, y: Float, pulseScale: Float) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawMoonGlow(x: Float, y: Float, pulseScale: Float) {
|
private fun DrawScope.drawMoonGlow(x: Float, y: 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,
|
||||||
|
|
@ -203,8 +176,8 @@ private fun DrawScope.drawMoonGlow(x: Float, y: Float, pulseScale: Float) {
|
||||||
|
|
||||||
for ((radius, alpha) in glowLayers) {
|
for ((radius, alpha) in glowLayers) {
|
||||||
drawCircle(
|
drawCircle(
|
||||||
color = MoonSilver.copy(alpha = alpha / pulseScale),
|
color = MoonSilver.copy(alpha = alpha),
|
||||||
radius = radius * pulseScale,
|
radius = radius,
|
||||||
center = Offset(x, y)
|
center = Offset(x, y)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package com.sunzones.ui.main.components
|
package com.sunzones.ui.main.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import android.content.Intent
|
||||||
|
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
|
||||||
|
|
@ -37,6 +39,8 @@ 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
|
||||||
|
|
@ -48,8 +52,7 @@ 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.graphics.graphicsLayer
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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
|
||||||
|
|
@ -57,15 +60,8 @@ 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")
|
||||||
|
|
||||||
|
|
@ -75,13 +71,12 @@ 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 {
|
||||||
|
|
@ -90,53 +85,12 @@ 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()
|
||||||
.graphicsLayer {
|
.background(brush = Brush.verticalGradient(gradientColors))
|
||||||
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
|
||||||
|
|
@ -160,17 +114,10 @@ fun SunCard(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
// Location name — entrance index 0
|
// Location name
|
||||||
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(
|
||||||
|
|
@ -193,20 +140,14 @@ fun SunCard(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Sun/Moon arc — entrance index 1, parallax background layer
|
// Sun/Moon arc with optional moon phase overlay
|
||||||
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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -227,83 +168,50 @@ 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 — entrance index 2
|
// Hero element — day length or night length
|
||||||
Column(
|
if (location.isDaytime) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Text(
|
||||||
modifier = Modifier
|
text = location.dayLengthFormatted,
|
||||||
.graphicsLayer {
|
style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong),
|
||||||
alpha = entranceAlpha(entrance, 2)
|
color = TextOnDark,
|
||||||
translationY = entranceTranslationY(entrance, 2)
|
fontWeight = FontWeight.Black
|
||||||
}
|
)
|
||||||
) {
|
Text(
|
||||||
if (location.isDaytime) {
|
text = stringResource(R.string.day_length),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault),
|
||||||
|
color = TextOnDarkSecondary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = location.nightLengthFormatted,
|
||||||
|
style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong),
|
||||||
|
color = TextOnDark,
|
||||||
|
fontWeight = FontWeight.Black
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.night_length),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault),
|
||||||
|
color = TextOnDarkSecondary
|
||||||
|
)
|
||||||
|
if (location.sunriseCountdownFormatted != null) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = location.dayLengthFormatted,
|
text = stringResource(R.string.sunrise_in, location.sunriseCountdownFormatted),
|
||||||
style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong),
|
style = MaterialTheme.typography.titleSmall.copy(shadow = TextShadowDefault),
|
||||||
color = TextOnDark,
|
color = SunGold.copy(alpha = 0.8f)
|
||||||
fontWeight = FontWeight.Black
|
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.day_length),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault),
|
|
||||||
color = TextOnDarkSecondary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = location.nightLengthFormatted,
|
|
||||||
style = MaterialTheme.typography.displayLarge.copy(shadow = TextShadowStrong),
|
|
||||||
color = TextOnDark,
|
|
||||||
fontWeight = FontWeight.Black
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.night_length),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(shadow = TextShadowDefault),
|
|
||||||
color = TextOnDarkSecondary
|
|
||||||
)
|
|
||||||
if (location.sunriseCountdownFormatted != null) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.sunrise_in, location.sunriseCountdownFormatted),
|
|
||||||
style = MaterialTheme.typography.titleSmall.copy(shadow = TextShadowDefault),
|
|
||||||
color = SunGold.copy(alpha = 0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Sunrise / Sunset times — entrance index 3
|
// Sunrise / Sunset times
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.graphicsLayer {
|
|
||||||
alpha = entranceAlpha(entrance, 3)
|
|
||||||
translationY = entranceTranslationY(entrance, 3)
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
// Sunrise
|
// Sunrise
|
||||||
|
|
@ -386,6 +294,23 @@ 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
|
||||||
|
|
@ -394,15 +319,6 @@ 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) {
|
||||||
|
|
@ -426,37 +342,78 @@ 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> {
|
||||||
* Staggered entrance alpha for element at given index.
|
if (!isDaytime) return listOf(NightTop, NightBottom)
|
||||||
*/
|
|
||||||
private fun entranceAlpha(progress: Float, index: Int): Float {
|
|
||||||
val stagger = index * 0.08f
|
|
||||||
return ((progress - stagger) / (1f - stagger)).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return when {
|
||||||
* Staggered entrance vertical translation for element at given index.
|
sunProgress < 0.1f -> listOf(SunriseTop, SunriseBottom)
|
||||||
* Returns pixels to offset (20dp equivalent fading upward).
|
sunProgress < 0.3f -> listOf(MorningTop, MorningBottom)
|
||||||
*/
|
sunProgress < 0.7f -> listOf(MiddayTop, MiddayBottom)
|
||||||
private fun entranceTranslationY(progress: Float, index: Int): Float {
|
sunProgress < 0.85f -> listOf(AfternoonTop, AfternoonBottom)
|
||||||
val alpha = entranceAlpha(progress, index)
|
sunProgress < 0.95f -> listOf(SunsetTop, SunsetBottom)
|
||||||
return (1f - alpha) * 60f // ~20dp in pixels at mdpi, scales naturally
|
else -> listOf(TwilightTop, TwilightBottom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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,12 +52,6 @@ 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,12 +12,10 @@
|
||||||
<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">Długość dnia w roku</string>
|
<string name="yearly_daylight">Daylight 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,6 +20,4 @@
|
||||||
<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