Bump version to 1.3 (versionCode 4)

- Add CityListSheet for managing locations via page indicator tap
- Add drag-to-reorder support in city list
- Fix Polish translation: "Daylight w roku" → "Długość dnia w roku"
- Fix PageIndicator position to avoid overlapping yearly chart
- Remove "About" section from UI (simplified)
- Add CHANGELOG.md
This commit is contained in:
Paweł Orzech 2026-01-31 12:51:37 +01:00
parent 0512ee674d
commit aede45cec2
No known key found for this signature in database
9 changed files with 336 additions and 82 deletions

42
CHANGELOG.md Normal file
View file

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

View file

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

View file

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

View file

@ -37,6 +37,7 @@ 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.getTimeGradient
import com.sunzones.ui.main.animation.lerpGradient 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
@ -50,6 +51,7 @@ 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 // Entrance animation — runs once on screen load, shared by all cards
val entranceProgress = remember { Animatable(0f) } val entranceProgress = remember { Animatable(0f) }
@ -132,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)
) )
} }
@ -162,4 +165,16 @@ fun MainScreen(
) )
} }
if (showCityListSheet) {
CityListSheet(
locations = uiState.locations,
onDismiss = { showCityListSheet = false },
onDelete = { viewModel.deleteLocation(it) },
onReorder = { from, to -> viewModel.reorderLocations(from, to) },
onAddClick = {
showCityListSheet = false
showAddSheet = true
}
)
}
} }

View file

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

View file

@ -5,7 +5,6 @@ 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.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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
@ -14,10 +13,8 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -36,11 +33,7 @@ fun PageIndicator(
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.clickable( .clickable(onClick = onClick)
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = Color.White.copy(alpha = 0.3f)),
onClick = onClick
)
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
) { ) {
Row( Row(

View file

@ -1,14 +1,11 @@
package com.sunzones.ui.main.components package com.sunzones.ui.main.components
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
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
@ -40,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
@ -54,7 +49,6 @@ 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.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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
@ -87,7 +81,6 @@ fun SunCard(
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 scrollState = rememberScrollState() val scrollState = rememberScrollState()
@ -393,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
@ -450,51 +426,6 @@ 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
)
}
}
}
}
}
} }
/** /**

View file

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

View file

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