diff --git a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt
index b7cda27b..e6e49b7c 100644
--- a/app/src/main/java/com/sadellie/unitto/UnittoApp.kt
+++ b/app/src/main/java/com/sadellie/unitto/UnittoApp.kt
@@ -70,17 +70,6 @@ internal fun UnittoApp(prefs: AppPreferences?) {
val shortcutsScope = rememberCoroutineScope()
- val tabs by remember {
- mutableStateOf(
- listOf(
- DrawerItems.Calculator,
- DrawerItems.Converter,
- DrawerItems.DateDifference,
- DrawerItems.TimeZones
- )
- )
- }
-
val navBackStackEntry by navController.currentBackStackEntryAsState()
val gesturesEnabled: Boolean by remember(navBackStackEntry?.destination) {
derivedStateOf {
@@ -115,7 +104,7 @@ internal fun UnittoApp(prefs: AppPreferences?) {
drawer = {
UnittoDrawerSheet(
modifier = Modifier,
- tabs = tabs,
+ tabs = DrawerItems.ALL,
currentDestination = navBackStackEntry?.destination?.route
) { destination ->
drawerScope.launch { drawerState.close() }
diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
index e8fa2424..a7b581f6 100644
--- a/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
+++ b/core/base/src/main/java/com/sadellie/unitto/core/base/TopLevelDestinations.kt
@@ -18,6 +18,7 @@
package com.sadellie.unitto.core.base
+import android.os.Build
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -102,12 +103,20 @@ sealed class TopLevelDestinations(
// Shown in settings
val TOP_LEVEL_DESTINATIONS by lazy {
- listOf(
- TopLevelDestinations.Calculator,
- TopLevelDestinations.Converter,
- TopLevelDestinations.DateCalculator,
- TopLevelDestinations.TimeZone,
- )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ listOf(
+ TopLevelDestinations.Calculator,
+ TopLevelDestinations.Converter,
+ TopLevelDestinations.DateCalculator,
+ TopLevelDestinations.TimeZone,
+ )
+ } else {
+ listOf(
+ TopLevelDestinations.Calculator,
+ TopLevelDestinations.Converter,
+ TopLevelDestinations.DateCalculator,
+ )
+ }
}
// Only routes, not graphs!
diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml
index dc5e755e..1144d3c6 100644
--- a/core/base/src/main/res/values/strings.xml
+++ b/core/base/src/main/res/values/strings.xml
@@ -1,11 +1,12 @@
+
+
+ Add
Unitto
All expressions from history will be deleted forever. This action can\'t be undone!
Can\'t divide by 0
No history
-
-
Calculator
Cancel
Checked filter
@@ -14,8 +15,6 @@
Clear
Click to try again
Add or remove unit from favorites
-
-
Convert from
Make sure there are no typos, try different filters or check for disabled unit groups.
Convert to
@@ -43,16 +42,15 @@
Years
+ Delete
Disabled
Open or close drop down menu
Enabled
-
-
- Epoch converter
Error
Hello!
-
+
+ Label
Loading…
Deutsch
English
@@ -65,8 +63,6 @@
Dutch
Русский
Türkçe
-
-
Navigate up
No results found
OK
Open menu
- Open settings
Open settings
Search button
Search…
@@ -102,15 +97,11 @@ Used in this dialog window. Should be short -->
Dark
Disable unit group
Display
-
-
App look and feel
Dynamic colors
Use colors from your wallpaper
Enable unit group
Exponential notation
-
-
Replace part of the number with E
Format time
Example: Show 130 minutes as 2h 10m
@@ -134,8 +125,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Precision
Converted values may have a precision higher than the preferred one.
%1$s (Max)
-
-
Number of decimal places
Privacy Policy
Rate this app
@@ -143,14 +132,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Selected color
Selected style
Separator
-
-
Group separator symbol
Alphabetical
Scale (Asc.)
Scale (Desc.)
-
-
Usage
Space
Starting screen
@@ -162,9 +147,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
System font
Use system font for texts in app
Terms and Conditions
-
-
- Themes
Third party licenses
Settings
Translate this app
@@ -184,57 +166,35 @@ Maybe this can be labeled better? Let me know. It should be something that can d
ac
Minute
\'
-
-
Second
\"
Apostilb
asb
Dalton
u
-
-
Attofarad
aF
Attojoule
aJ
-
-
Attoliter
aL
-
-
Attometer
-
-
Attometer/square second
am/s^2
am
Attonewton
aN
-
-
Attopascal
aPa
-
-
Attosecond
as
-
-
Attowatt
aW
Bar
bar
-
-
Binary
base2
-
-
Bit
-
-
Bit/second
b/s
b
@@ -254,14 +214,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
cd/ft^2
Candela/square inch
cd/in^2
-
-
Candela/square meter
cd/m^2
Carat
ct
-
-
Celsius
°C
Cent
@@ -278,8 +234,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
cm
Centipascal
cPa
-
-
Unit converter
First Cosmic Velocity
v1
@@ -313,8 +267,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Cubic Millimeter/second
mm3/s
mm^3
-
-
1inch Network
NCH
Cardano
@@ -765,16 +717,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Earth g
Earth\'s orbital speed
ve
-
-
Electron cross section
ecs
-
-
Electron mass
me
-
-
Electron volt
eV
Horse power
@@ -881,8 +827,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Flux
Force
Fuel
-
-
Length
Luminance
Mass
@@ -1018,8 +962,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Light year
ly
Liter
-
-
Liter/hour
L/h
Liter/minute
@@ -1043,8 +985,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Mars M
Mars surface gravity
Mars g
-
-
Maxwell
Mx
Mebibit
@@ -1144,8 +1084,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Millimeter
Millimeter of mercury
mm Hg
-
-
Millimeter/hour
mm/h
Millimeter/minute
@@ -1186,13 +1124,9 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Neptune M
Neptune surface gravity
Neptune g
-
-
Newton
Newton centimeter
N*cm
-
-
Newton meter
N*m
Newton millimeter
@@ -1280,8 +1214,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
p
Quecto
q
-
-
Quetta
Q
Ronna
@@ -1422,8 +1354,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
Yard/second
yd/s
yd
-
-
y
Yesterday
\ No newline at end of file
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt
index 2b30f920..c8679b6c 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt
@@ -68,7 +68,7 @@ fun UnittoSearchBar(
title: String,
searchActions: @Composable (RowScope.() -> Unit) = {},
noSearchActions: @Composable (RowScope.() -> Unit) = {},
- placeholder: String,
+ placeholder: String = stringResource(R.string.search_text_field_placeholder),
scrollBehavior: TopAppBarScrollBehavior? = null,
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors()
) {
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/UnittoDateTimeFormatter.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/UnittoDateTimeFormatter.kt
index d95e770a..3f727bee 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/UnittoDateTimeFormatter.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/UnittoDateTimeFormatter.kt
@@ -36,6 +36,26 @@ data object UnittoDateTimeFormatter {
*/
val time12Formatter1: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("hh:mm") }
+ /**
+ * 23
+ */
+ val time24OnlyHoursFormatter: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("HH") }
+
+ /**
+ * 23
+ */
+ val time12OnlyHoursFormatter: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("hh") }
+
+ /**
+ * 59
+ */
+ val timeOnlyMinutesFormatter: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("mm") }
+
+ /**
+ * 59
+ */
+ val timeOnlySecondsFormatter: DateTimeFormatter by lazy { DateTimeFormatter.ofPattern("ss") }
+
/**
* AM
*/
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/ZonedDateTimeUtils.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/ZonedDateTimeUtils.kt
index 1938c5d8..86d0dba3 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/ZonedDateTimeUtils.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/datetime/ZonedDateTimeUtils.kt
@@ -27,12 +27,42 @@ import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.absoluteValue
+/**
+ * Formats date time into something like:
+ *
+ * 23:59 or 11:59 AM
+ *
+ * Depends on system preferences
+ *
+ * @return Formatted string
+ */
@Composable
fun ZonedDateTime.formatLocal(): String {
return if (DateFormat.is24HourFormat(LocalContext.current)) format(UnittoDateTimeFormatter.time24Formatter)
else format(UnittoDateTimeFormatter.time12FormatterFull)
}
+@Composable
+fun ZonedDateTime.formatOnlyHours(): String {
+ return if (DateFormat.is24HourFormat(LocalContext.current)) format(UnittoDateTimeFormatter.time24OnlyHoursFormatter)
+ else format(UnittoDateTimeFormatter.time12OnlyHoursFormatter)
+}
+
+@Composable
+fun ZonedDateTime.formatOnlyMinutes(): String {
+ return format(UnittoDateTimeFormatter.timeOnlyMinutesFormatter)
+}
+
+@Composable
+fun ZonedDateTime.formatOnlySeconds(): String {
+ return format(UnittoDateTimeFormatter.timeOnlySecondsFormatter)
+}
+
+@Composable
+fun ZonedDateTime.formatOnlyAmPm(): String {
+ return format(UnittoDateTimeFormatter.time12Formatter2)
+}
+
/**
* Format offset string. Examples:
*
@@ -74,7 +104,7 @@ fun ZonedDateTime.formatOffset(
resultBuffer += "${hour}${stringResource(R.string.unit_hour_short)}"
}
- // TODO Very ugly
+ // TODO Very ugly. Replace with formatTime option from unit converter
if (minute != 0L) {
if (hour != 0L) resultBuffer += " "
resultBuffer += "${minute}${stringResource(R.string.unit_minute_short)}"
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
index b22bad75..d876efec 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/model/DrawerItems.kt
@@ -18,6 +18,7 @@
package com.sadellie.unitto.core.ui.model
+import android.os.Build
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Calculate
import androidx.compose.material.icons.filled.Event
@@ -58,4 +59,23 @@ sealed class DrawerItems(
selectedIcon = Icons.Filled.Schedule,
defaultIcon = Icons.Outlined.Schedule
)
+
+ companion object {
+ val ALL by lazy {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ listOf(
+ Calculator,
+ Converter,
+ DateDifference,
+ TimeZones
+ )
+ } else {
+ listOf(
+ Calculator,
+ Converter,
+ DateDifference,
+ )
+ }
+ }
+ }
}
diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt
index a021bd49..94a2edd0 100644
--- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt
+++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt
@@ -56,6 +56,46 @@ val Typography.numbersDisplayMedium by lazy {
)
}
+val Typography.numberBodyLarge: TextStyle by lazy {
+ TextStyle(
+ fontFamily = latoFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 1.5.em,
+ letterSpacing = 0.5.sp,
+ )
+}
+
+val Typography.numberDisplayLarge: TextStyle by lazy {
+ TextStyle(
+ fontFamily = latoFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 57.sp,
+ lineHeight = 1.25.em,
+ letterSpacing = (-0.25).sp,
+ )
+}
+
+val Typography.numberHeadlineSmall: TextStyle by lazy {
+ TextStyle(
+ fontFamily = latoFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 1.25.em,
+ letterSpacing = 0.sp,
+ )
+}
+
+val Typography.numberHeadlineMedium: TextStyle by lazy {
+ TextStyle(
+ fontFamily = latoFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 1.25.em,
+ letterSpacing = 0.sp,
+ )
+}
+
val TypographyUnitto by lazy {
Typography(
displayLarge = TextStyle(
diff --git a/data/common/src/main/java/com/sadellie/unitto/data/common/TimeZoneUtils.kt b/data/common/src/main/java/com/sadellie/unitto/data/common/TimeZoneUtils.kt
new file mode 100644
index 00000000..4ef865fd
--- /dev/null
+++ b/data/common/src/main/java/com/sadellie/unitto/data/common/TimeZoneUtils.kt
@@ -0,0 +1,40 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.data.common
+
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
+import java.time.ZonedDateTime
+
+@RequiresApi(Build.VERSION_CODES.N)
+fun TimeZone.offset(currentTime: ZonedDateTime): ZonedDateTime {
+ val offsetSeconds = currentTime.offset.totalSeconds.toLong()
+ val currentTimeWithoutOffset = currentTime.minusSeconds(offsetSeconds)
+
+ return currentTimeWithoutOffset.plusSeconds(this.rawOffset / 1000L)
+}
+
+val TimeZone.region: String
+ @RequiresApi(Build.VERSION_CODES.N)
+ get() = id
+ .replace("_", " ")
+ .split("/")
+ .reversed()
+ .joinToString()
diff --git a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt
index 3de4f9d4..1984def3 100644
--- a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt
+++ b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneDao.kt
@@ -19,23 +19,59 @@
package com.sadellie.unitto.data.database
import androidx.room.Dao
-import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface TimeZoneDao {
- @Query("SELECT * FROM time_zones ORDER BY position ASC")
- fun getAll(): Flow>
+
+ @Query("SELECT * FROM time_zones WHERE position > 0 ORDER BY position ASC")
+ fun getFavorites(): Flow>
+
+ @Query("SELECT MAX(position) FROM time_zones")
+ fun getMaxPosition(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg timeZoneEntity: TimeZoneEntity)
- @Query("UPDATE time_zones SET position = ( SELECT SUM(position) FROM time_zones WHERE id IN (:fromId, :toId) ) - position WHERE id IN (:fromId, :toId)")
- suspend fun swap(fromId: String, toId: String)
+ @Transaction
+ suspend fun addToFavorites(id: String) {
+ insert(
+ TimeZoneEntity(
+ id = id,
+ position = getMaxPosition() + 1,
+ )
+ )
+ }
- @Delete
- suspend fun remove(timeZoneEntity: TimeZoneEntity)
+ @Query("UPDATE time_zones SET position = -1 WHERE id = :id")
+ suspend fun removeFromFavorites(id: String)
+
+ @Query("UPDATE time_zones SET label = :label WHERE id = :id")
+ suspend fun updateLabel(id: String, label: String)
+
+ @Query("UPDATE time_zones SET position = :newPosition WHERE position = :oldPosition AND id = :id")
+ suspend fun updateDragged(id: String, oldPosition: Int, newPosition: Int)
+
+ @Query("UPDATE time_zones SET position = (position - 1) WHERE position > :currentPosition and position <= :targetPosition")
+ suspend fun moveDown(currentPosition: Int, targetPosition: Int)
+
+ @Query("UPDATE time_zones SET position = (position + 1) WHERE position >= :targetPosition AND position < :currentPosition")
+ suspend fun moveUp(currentPosition: Int, targetPosition: Int)
+
+ @Transaction
+ suspend fun moveMove(id: String, currentPosition: Int, targetPosition: Int) {
+ // Very good explanation
+ // https://www.c-sharpcorner.com/article/updating-display-order-in-database-with-drag-drop5/
+ updateDragged(id, currentPosition, 0)
+ if (targetPosition > currentPosition) {
+ moveDown(currentPosition, targetPosition)
+ } else {
+ moveUp(currentPosition, targetPosition)
+ }
+ updateDragged(id, 0, targetPosition)
+ }
}
diff --git a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneEntity.kt b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneEntity.kt
index 91383b85..d5bd296a 100644
--- a/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneEntity.kt
+++ b/data/database/src/main/java/com/sadellie/unitto/data/database/TimeZoneEntity.kt
@@ -25,6 +25,6 @@ import androidx.room.PrimaryKey
@Entity(tableName = "time_zones")
class TimeZoneEntity(
@PrimaryKey val id: String,
- @ColumnInfo(name = "position") val position: Int,
+ @ColumnInfo(name = "position") val position: Int,
@ColumnInfo(name = "label") val label: String = "",
)
diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/FavoriteZone.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/FavoriteZone.kt
new file mode 100644
index 00000000..7c7e7f1b
--- /dev/null
+++ b/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/FavoriteZone.kt
@@ -0,0 +1,30 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.data.model.timezone
+
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
+
+@RequiresApi(Build.VERSION_CODES.N)
+data class FavoriteZone(
+ val timeZone: TimeZone,
+ val position: Int,
+ val label: String
+)
diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/SearchResultZone.kt b/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/SearchResultZone.kt
new file mode 100644
index 00000000..3c4f3a32
--- /dev/null
+++ b/data/model/src/main/java/com/sadellie/unitto/data/model/timezone/SearchResultZone.kt
@@ -0,0 +1,29 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.data.model.timezone
+
+import android.icu.util.TimeZone
+
+/**
+ * Don't get 'region' from [timeZone]. Use [formattedLabel] (same but cached)
+ */
+data class SearchResultZone(
+ val timeZone: TimeZone,
+ val formattedLabel: String
+)
diff --git a/data/timezone/src/main/java/com/sadellie/unitto/data/timezone/TimeZonesRepository.kt b/data/timezone/src/main/java/com/sadellie/unitto/data/timezone/TimeZonesRepository.kt
index 91a453e5..2ed5b920 100644
--- a/data/timezone/src/main/java/com/sadellie/unitto/data/timezone/TimeZonesRepository.kt
+++ b/data/timezone/src/main/java/com/sadellie/unitto/data/timezone/TimeZonesRepository.kt
@@ -18,97 +18,143 @@
package com.sadellie.unitto.data.timezone
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
import com.sadellie.unitto.data.common.lev
+import com.sadellie.unitto.data.common.region
import com.sadellie.unitto.data.database.TimeZoneDao
-import com.sadellie.unitto.data.database.TimeZoneEntity
-import com.sadellie.unitto.data.model.UnittoTimeZone
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
+import com.sadellie.unitto.data.model.timezone.SearchResultZone
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
+@RequiresApi(Build.VERSION_CODES.N)
@Singleton
class TimeZonesRepository @Inject constructor(
private val dao: TimeZoneDao
) {
- private val allTimeZones: HashMap = hashMapOf(
- "zulu_time_zone" to UnittoTimeZone(id = "zulu_time_zone", nameRes = "Zulu Time Zone", offsetSeconds = 0)
- )
+// Not implemented because it will take me too much time to map 600+ TimeZones and codes
+// private val codeToTimeZoneId: HashMap by lazy {
+// hashMapOf()
+// }
- val favoriteTimeZones: Flow> = dao
- .getAll()
+ val favoriteTimeZones: Flow> = dao
+ .getFavorites()
.map { list ->
- val favorites = mutableListOf()
+ val favorites = mutableListOf()
list.forEach { entity ->
- val foundTimeZone = allTimeZones[entity.id] ?: return@forEach
- val mapped = foundTimeZone.copy(
- position = entity.position
+ favorites.add(
+ FavoriteZone(
+ timeZone = TimeZone.getTimeZone(entity.id),
+ position = entity.position,
+ label = entity.label
+ )
)
- favorites.add(mapped)
}
favorites
}
- suspend fun swapTimeZones(from: String, to: String) = withContext(Dispatchers.IO) {
- dao.swap(from, to)
-
- return@withContext
+ suspend fun moveTimeZone(
+ timeZone: FavoriteZone,
+ targetPosition: Int
+ ) = withContext(Dispatchers.IO) {
+ dao.moveMove(timeZone.timeZone.id, timeZone.position, targetPosition)
}
- suspend fun delete(timeZone: UnittoTimeZone) = withContext(Dispatchers.IO) {
- // Only PrimaryKey is needed
- dao.remove(TimeZoneEntity(id = timeZone.id, position = 0))
- }
-
- suspend fun filterAllTimeZones(searchQuery: String): List =
+ suspend fun addToFavorites(
+ timeZone: TimeZone
+ ) {
withContext(Dispatchers.IO) {
+ dao.addToFavorites(timeZone.id)
+ }
+ }
+
+ suspend fun removeFromFavorites(
+ timeZone: FavoriteZone
+ ) = withContext(Dispatchers.IO) {
+ dao.removeFromFavorites(timeZone.timeZone.id)
+ }
+
+ suspend fun updateLabel(
+ timeZone: FavoriteZone,
+ label: String
+ ) = withContext(Dispatchers.IO) {
+ dao.updateLabel(timeZone.timeZone.id, label)
+ }
+
+ suspend fun filterAllTimeZones(
+ searchQuery: String
+ ): List =
+ withContext(Dispatchers.IO) {
+ val favorites = dao.getFavorites().first().map { it.id }
+
val query = searchQuery.trim().lowercase()
val threshold: Int = query.length / 2
- val timeZonesWithDist = mutableListOf>()
+ val timeZonesWithDist = mutableListOf>()
- allTimeZones.values.forEach { timeZone ->
- val timeZoneName = timeZone.nameRes
+ TimeZone.getAvailableIDs().forEach { timeZoneId ->
+ if (timeZoneId in favorites) return@forEach
- if (timeZone.code.lowercase() == query) {
- timeZonesWithDist.add(timeZone to 1)
+ val timeZone = TimeZone.getTimeZone(timeZoneId)
+ val name = timeZone.displayName
+ val id = timeZone.region
+
+// // CODE Match
+// if (codeToTimeZoneId[timeZone.id]?.lowercase() == query) {
+// timeZonesWithDist.add(SearchResultZone(timeZone, id) to 1)
+// return@forEach
+// }
+
+ // Display name match
+ when {
+ // not zero, so that lev can have that
+ name.startsWith(query) -> {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to 1)
+ return@forEach
+ }
+
+ name.contains(query) -> {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to 2)
+ return@forEach
+ }
+ }
+ val nameLevDist = name
+ .substring(0, minOf(query.length, name.length))
+ .lev(query)
+ if (nameLevDist < threshold) {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to nameLevDist)
return@forEach
}
+ // ID Match
when {
// not zero, so that lev can have that
- timeZoneName.startsWith(query) -> {
- timeZonesWithDist.add(timeZone to 1)
+ id.startsWith(query) -> {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to 1)
return@forEach
}
- timeZoneName.contains(query) -> {
- timeZonesWithDist.add(timeZone to 2)
+ id.contains(query) -> {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to 2)
return@forEach
}
}
-
- val levDist = timeZoneName
- .substring(0, minOf(query.length, timeZoneName.length))
+ val idLevDist = id
+ .substring(0, minOf(query.length, id.length))
.lev(query)
-
- if (levDist < threshold) {
- timeZonesWithDist.add(timeZone to levDist)
+ if (idLevDist < threshold) {
+ timeZonesWithDist.add(SearchResultZone(timeZone, id) to idLevDist)
+ return@forEach
}
}
return@withContext timeZonesWithDist.sortedBy { it.second }.map { it.first }
}
-
- suspend fun addToFavorites(timeZone: UnittoTimeZone) {
-// UNCOMMENT FOR RELEASE
- dao.insert(
- TimeZoneEntity(
- id = timeZone.id,
- position = System.currentTimeMillis().toInt()
- )
- )
- }
}
diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt
index 0ab23c81..8739150d 100644
--- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt
+++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt
@@ -106,7 +106,7 @@ internal fun ChipsRow(
AssistChip(
onClick = navigateToSettingsAction,
imageVector = Icons.Default.Settings,
- contentDescription = stringResource(R.string.open_settings_description)
+ contentDescription = stringResource(R.string.open_settings_label)
)
}
}
@@ -159,7 +159,7 @@ fun ChipsFlexRow(
AssistChip(
onClick = navigateToSettingsAction,
imageVector = Icons.Default.Settings,
- contentDescription = stringResource(R.string.open_settings_description)
+ contentDescription = stringResource(R.string.open_settings_label)
)
}
diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/SearchPlaceholder.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/SearchPlaceholder.kt
index 777d1df9..cfa1f11a 100644
--- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/SearchPlaceholder.kt
+++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/SearchPlaceholder.kt
@@ -66,7 +66,7 @@ internal fun SearchPlaceholder(navigateToSettingsAction: () -> Unit) {
)
// Open settings button
ElevatedButton(onClick = navigateToSettingsAction) {
- Text(text = stringResource(R.string.open_settings_description))
+ Text(text = stringResource(R.string.open_settings_label))
}
}
}
diff --git a/feature/timezone/build.gradle.kts b/feature/timezone/build.gradle.kts
index c8c3b176..a98b3ba6 100644
--- a/feature/timezone/build.gradle.kts
+++ b/feature/timezone/build.gradle.kts
@@ -30,6 +30,9 @@ android {
dependencies {
testImplementation(libs.junit.junit)
+ testImplementation(libs.org.robolectric.robolectric)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.com.github.sadellie.themmo)
implementation(libs.org.burnoutcrew.composereorderable.reorderable)
diff --git a/feature/timezone/src/androidTest/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZonesTest.kt b/feature/timezone/src/androidTest/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZonesTest.kt
new file mode 100644
index 00000000..0aa014a1
--- /dev/null
+++ b/feature/timezone/src/androidTest/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZonesTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.feature.timezone.components
+
+import android.icu.util.TimeZone
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.background
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
+import org.junit.Rule
+import org.junit.Test
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+class FavoriteTimeZonesTest {
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @Test
+ fun convertTime(): Unit = with(composeTestRule) {
+ setContent {
+ FavoriteTimeZoneItem(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ item = FavoriteZone(
+ timeZone = TimeZone.getTimeZone("Africa/Addis_Ababa"),
+ position = -1,
+ label = "label text"
+ ),
+ fromTime = ZonedDateTime.parse(
+ "2023-05-01T14:00+03:00[Africa/Addis_Ababa]",
+ DateTimeFormatter.ISO_ZONED_DATE_TIME
+ ),
+ expanded = true,
+ onClick = {},
+ onDelete = {},
+ onPrimaryClick = {},
+ onLabelClick = {},
+ isDragging = false
+ )
+ }
+
+ onNodeWithText("11:00").assertExists()
+ }
+
+}
\ No newline at end of file
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt
index b03dbc7d..46fd3007 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt
@@ -18,138 +18,130 @@
package com.sadellie.unitto.feature.timezone
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.LinearOutSlowInEasing
-import androidx.compose.animation.core.tween
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
-import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sadellie.unitto.core.base.R
+import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen
+import com.sadellie.unitto.core.ui.common.UnittoListItem
import com.sadellie.unitto.core.ui.common.UnittoSearchBar
-import com.sadellie.unitto.data.model.UnittoTimeZone
-import com.sadellie.unitto.feature.timezone.components.SelectableTimeZone
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
+import com.sadellie.unitto.core.ui.datetime.formatLocal
+import com.sadellie.unitto.core.ui.theme.numberHeadlineSmall
+import com.sadellie.unitto.data.common.offset
+import com.sadellie.unitto.data.common.region
+import com.sadellie.unitto.data.model.timezone.SearchResultZone
import java.time.ZonedDateTime
+@RequiresApi(Build.VERSION_CODES.N)
@Composable
internal fun AddTimeZoneRoute(
viewModel: AddTimeZoneViewModel = hiltViewModel(),
navigateUp: () -> Unit,
- userTime: ZonedDateTime? = null
+ userTime: ZonedDateTime,
) {
- val uiState = viewModel.addTimeZoneUIState.collectAsStateWithLifecycle()
-
- LaunchedEffect(Unit) {
- if (userTime == null) {
- while (isActive) {
- viewModel.setTime(ZonedDateTime.now())
- delay(1000)
- }
- } else {
- viewModel.setTime(userTime)
- }
+ when (val uiState = viewModel.uiState.collectAsStateWithLifecycle().value) {
+ AddTimeZoneUIState.Loading -> UnittoEmptyScreen()
+ is AddTimeZoneUIState.Ready -> AddTimeZoneScreen(
+ uiState = uiState,
+ navigateUp = navigateUp,
+ onQueryChange = viewModel::onQueryChange,
+ addToFavorites = viewModel::addToFavorites,
+ userTime = userTime
+ )
}
-
- AddTimeZoneScreen(
- uiState = uiState.value,
- navigateUp = navigateUp,
- onQueryChange = viewModel::onQueryChange,
- addToFavorites = viewModel::addToFavorites,
- )
}
+@RequiresApi(Build.VERSION_CODES.N)
@Composable
fun AddTimeZoneScreen(
- uiState: AddTimeZoneUIState,
+ uiState: AddTimeZoneUIState.Ready,
navigateUp: () -> Unit,
onQueryChange: (TextFieldValue) -> Unit,
- addToFavorites: (UnittoTimeZone) -> Unit,
+ addToFavorites: (TimeZone) -> Unit,
+ userTime: ZonedDateTime,
) {
- val listState = rememberLazyListState()
- val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
- rememberTopAppBarState()
- )
- val elevatedColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
- val needToTint by remember {
- derivedStateOf { scrollBehavior.state.overlappedFraction > 0.01f }
- }
-
- val searchBarBackground = animateColorAsState(
- targetValue = if (needToTint) elevatedColor else MaterialTheme.colorScheme.surface,
- animationSpec = tween(durationMillis = 500, easing = LinearOutSlowInEasing),
- label = "Search bar background"
- )
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
UnittoSearchBar(
- modifier = Modifier,
query = uiState.query,
onQueryChange = onQueryChange,
navigateUp = navigateUp,
title = stringResource(R.string.time_zone_add_title),
- placeholder = stringResource(R.string.search_text_field_placeholder),
- scrollBehavior = scrollBehavior,
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = searchBarBackground.value
- )
+ scrollBehavior = scrollBehavior
)
},
) { paddingValues ->
- LazyColumn(
- modifier = Modifier.padding(paddingValues),
- state = listState
- ) {
- items(uiState.list) {
- SelectableTimeZone(
- timeZone = it,
- modifier = Modifier
- .clickable { addToFavorites(it); navigateUp() }
- .fillMaxWidth(),
- currentTime = uiState.userTime
- )
+ Crossfade(targetState = uiState.list.isEmpty()) { empty ->
+ if (empty) {
+ UnittoEmptyScreen()
+ } else {
+ LazyColumn(contentPadding = paddingValues) {
+ items(uiState.list, { it.timeZone.id }) {
+ UnittoListItem(
+ modifier = Modifier
+ .animateItemPlacement()
+ .clickable {
+ addToFavorites(it.timeZone)
+ navigateUp()
+ },
+ headlineContent = { Text(it.timeZone.displayName) },
+ supportingContent = { Text(it.formattedLabel) },
+ trailingContent = {
+ Text(
+ text = it.timeZone.offset(userTime).formatLocal(),
+ style = MaterialTheme.typography.numberHeadlineSmall
+ )
+ }
+ )
+ }
+ }
}
}
}
}
+@RequiresApi(Build.VERSION_CODES.N)
@Preview
@Composable
fun PreviewAddTimeZoneScreen() {
AddTimeZoneScreen(
- navigateUp = {},
- uiState = AddTimeZoneUIState(
- list = List(50) {
- UnittoTimeZone(
- id = "timezone $it",
- nameRes = "Time zone $it",
+ uiState = AddTimeZoneUIState.Ready(
+ query = TextFieldValue(),
+ list = listOf(
+ "UTC",
+ "Africa/Addis_Ababa",
+ "ACT"
+ ).map {
+ val zone = TimeZone.getTimeZone(it)
+ SearchResultZone(
+ timeZone = zone,
+ formattedLabel = zone.region
)
}
),
+ navigateUp = {},
onQueryChange = {},
addToFavorites = {},
+ userTime = ZonedDateTime.now()
)
}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneUIState.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneUIState.kt
index 977650e6..dc4dd75a 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneUIState.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneUIState.kt
@@ -19,11 +19,13 @@
package com.sadellie.unitto.feature.timezone
import androidx.compose.ui.text.input.TextFieldValue
-import com.sadellie.unitto.data.model.UnittoTimeZone
-import java.time.ZonedDateTime
+import com.sadellie.unitto.data.model.timezone.SearchResultZone
-data class AddTimeZoneUIState(
- val query: TextFieldValue = TextFieldValue(),
- val list: List = emptyList(),
- val userTime: ZonedDateTime? = null,
-)
+sealed class AddTimeZoneUIState {
+ data object Loading: AddTimeZoneUIState()
+
+ data class Ready(
+ val query: TextFieldValue,
+ val list: List,
+ ): AddTimeZoneUIState()
+}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneViewModel.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneViewModel.kt
index 8f266b51..ae445bc9 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneViewModel.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneViewModel.kt
@@ -18,66 +18,52 @@
package com.sadellie.unitto.feature.timezone
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.sadellie.unitto.data.model.UnittoTimeZone
+import com.sadellie.unitto.data.common.stateIn
+import com.sadellie.unitto.data.model.timezone.SearchResultZone
import com.sadellie.unitto.data.timezone.TimeZonesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import java.time.ZonedDateTime
import javax.inject.Inject
+@RequiresApi(Build.VERSION_CODES.N)
@HiltViewModel
class AddTimeZoneViewModel @Inject constructor(
private val timezonesRepository: TimeZonesRepository,
) : ViewModel() {
-
- private val _userTime = MutableStateFlow(ZonedDateTime.now())
private val _query = MutableStateFlow(TextFieldValue())
+ private val _result = MutableStateFlow(emptyList())
- private val _filteredTimeZones = MutableStateFlow(emptyList())
- val addTimeZoneUIState = combine(
+ val uiState = combine(
_query,
- _filteredTimeZones,
- _userTime,
- ) { query, filteredTimeZone, userTime ->
- return@combine AddTimeZoneUIState(
+ _result,
+ timezonesRepository.favoriteTimeZones,
+ ) { query, result, _ ->
+ return@combine AddTimeZoneUIState.Ready(
query = query,
- list = filteredTimeZone,
- userTime = userTime,
+ list = result,
)
- }.stateIn(
- viewModelScope, SharingStarted.WhileSubscribed(5000), AddTimeZoneUIState()
- )
-
- fun onQueryChange(query: TextFieldValue) {
- _query.update { query }
- filterTimeZones(query.text)
}
-
- private fun filterTimeZones(query: String = "") = viewModelScope.launch {
- _filteredTimeZones.update {
- timezonesRepository.filterAllTimeZones(query)
+ .mapLatest { ui ->
+ viewModelScope.launch {
+ _result.update { timezonesRepository.filterAllTimeZones(ui.query.text) }
+ }
+ ui
}
- }
+ .stateIn(viewModelScope, AddTimeZoneUIState.Loading)
- fun addToFavorites(timeZone: UnittoTimeZone) = viewModelScope.launch(Dispatchers.IO) {
+ fun onQueryChange(textFieldValue: TextFieldValue) = _query.update { textFieldValue }
+
+ fun addToFavorites(timeZone: TimeZone) = viewModelScope.launch {
timezonesRepository.addToFavorites(timeZone)
}
-
- fun setTime(time: ZonedDateTime) = viewModelScope.launch(Dispatchers.Default) {
- _userTime.update { time }
- }
-
- init {
- // TODO Maybe only when actually needed?
- filterTimeZones()
- }
}
diff --git a/data/model/src/main/java/com/sadellie/unitto/data/model/UnittoTimeZone.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneExt.kt
similarity index 58%
rename from data/model/src/main/java/com/sadellie/unitto/data/model/UnittoTimeZone.kt
rename to feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneExt.kt
index 7ba757e7..92a7fee1 100644
--- a/data/model/src/main/java/com/sadellie/unitto/data/model/UnittoTimeZone.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneExt.kt
@@ -16,22 +16,18 @@
* along with this program. If not, see .
*/
-package com.sadellie.unitto.data.model
+package com.sadellie.unitto.feature.timezone
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
+import java.time.ZoneId
import java.time.ZonedDateTime
-data class UnittoTimeZone(
- val id: String,
- // For beta only, will change to StringRes later
- val nameRes: String,
- val position: Int = 0,
- val offsetSeconds: Long = 0,
- val code: String = "CODE",
-) {
- fun offsetFrom(currentTime: ZonedDateTime): ZonedDateTime {
- val offsetSeconds = currentTime.offset.totalSeconds.toLong()
- val currentTimeWithoutOffset = currentTime.minusSeconds(offsetSeconds)
+val TimeZone.offsetSeconds
+ @RequiresApi(Build.VERSION_CODES.N)
+ get() = this.rawOffset / 1000L
- return currentTimeWithoutOffset.plusSeconds(this.offsetSeconds)
- }
-}
+
+@RequiresApi(Build.VERSION_CODES.N)
+fun TimeZone.timeNow(): ZonedDateTime = ZonedDateTime.now(ZoneId.of(this.id, ZoneId.SHORT_IDS))
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneScreen.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneScreen.kt
index f0a1b462..6934cd94 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneScreen.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneScreen.kt
@@ -18,61 +18,52 @@
package com.sadellie.unitto.feature.timezone
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.SizeTransform
-import androidx.compose.animation.animateColor
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.compose.animation.core.animateDp
-import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.animateInt
import androidx.compose.animation.core.updateTransition
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.AnchoredDraggableState
-import androidx.compose.foundation.gestures.DraggableAnchors
-import androidx.compose.foundation.gestures.animateTo
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.outlined.History
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -81,102 +72,112 @@ import com.sadellie.unitto.core.base.R
import com.sadellie.unitto.core.ui.common.MenuButton
import com.sadellie.unitto.core.ui.common.SettingsButton
import com.sadellie.unitto.core.ui.common.TimePickerDialog
+import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar
-import com.sadellie.unitto.core.ui.common.squashable
-import com.sadellie.unitto.core.ui.datetime.UnittoDateTimeFormatter
-import com.sadellie.unitto.core.ui.datetime.formatLocal
-import com.sadellie.unitto.core.ui.theme.TypographyUnitto
-import com.sadellie.unitto.core.ui.theme.DarkThemeColors
-import com.sadellie.unitto.core.ui.theme.LightThemeColors
-import com.sadellie.unitto.data.model.UnittoTimeZone
-import com.sadellie.unitto.feature.timezone.components.TimeZoneListItem
-import io.github.sadellie.themmo.Themmo
-import io.github.sadellie.themmo.rememberThemmoController
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
+import com.sadellie.unitto.feature.timezone.components.FavoriteTimeZoneItem
+import com.sadellie.unitto.feature.timezone.components.UserTimeZone
+import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
import java.time.ZonedDateTime
+@RequiresApi(Build.VERSION_CODES.N)
@Composable
internal fun TimeZoneRoute(
viewModel: TimeZoneViewModel = hiltViewModel(),
- navigateToMenu: () -> Unit,
+ openMenu: () -> Unit,
navigateToSettings: () -> Unit,
- navigateToAddTimeZone: (ZonedDateTime?) -> Unit,
+ navigateToAddTimeZone: (ZonedDateTime) -> Unit,
) {
- val uiState = viewModel.timeZoneUIState.collectAsStateWithLifecycle()
-
- TimeZoneScreen(
- uiState = uiState.value,
- navigateToMenu = navigateToMenu,
- navigateToSettings = navigateToSettings,
- navigateToAddTimeZone = {
- navigateToAddTimeZone(
- if (uiState.value.updateTime) null
- else uiState.value.userTime
+ when (val uiState = viewModel.uiState.collectAsStateWithLifecycle().value) {
+ TimeZoneUIState.Loading -> UnittoEmptyScreen()
+ is TimeZoneUIState.Ready -> {
+ TimeZoneScreen(
+ uiState = uiState,
+ openMenu = openMenu,
+ navigateToSettings = navigateToSettings,
+ navigateToAddTimeZone = navigateToAddTimeZone,
+ setCurrentTime = viewModel::setCurrentTime,
+ setSelectedTime = viewModel::setSelectedTime,
+ onDragEnd = viewModel::onDragEnd,
+ delete = viewModel::delete,
+ updateLabel = viewModel::updateLabel,
+ selectTimeZone = viewModel::selectTimeZone,
+ setDialogState = viewModel::setDialogState
)
- },
- onDragEnd = viewModel::onDragEnd,
- onDelete = viewModel::onDelete,
- setSelectedTime = viewModel::setCustomTime,
- setCurrentTime = viewModel::setCurrentTime,
- resetTime = viewModel::resetTime,
- )
+ }
+ }
}
+@RequiresApi(Build.VERSION_CODES.N)
@Composable
private fun TimeZoneScreen(
- uiState: TimeZoneUIState,
- navigateToMenu: () -> Unit,
+ uiState: TimeZoneUIState.Ready,
+ openMenu: () -> Unit,
navigateToSettings: () -> Unit,
- navigateToAddTimeZone: () -> Unit,
- onDragEnd: (String, String) -> Unit,
- onDelete: (UnittoTimeZone) -> Unit,
- setSelectedTime: (ZonedDateTime) -> Unit,
+ navigateToAddTimeZone: (ZonedDateTime) -> Unit,
setCurrentTime: () -> Unit,
- resetTime: () -> Unit,
+ setSelectedTime: (ZonedDateTime) -> Unit,
+ onDragEnd: (id: FavoriteZone, target: Int) -> Unit,
+ delete: (FavoriteZone) -> Unit,
+ updateLabel: (FavoriteZone, String) -> Unit,
+ selectTimeZone: (FavoriteZone?) -> Unit,
+ setDialogState: (TimeZoneDialogState) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+ var currentUserTime by remember(uiState.customUserTime) {
+ mutableStateOf(
+ uiState.customUserTime ?: uiState.userTimeZone.timeNow()
+ )
+ }
+ val focusRequester = remember { FocusRequester() }
- LaunchedEffect(uiState.updateTime) {
- while (uiState.updateTime and isActive) {
- setCurrentTime()
+ LaunchedEffect(uiState.customUserTime) {
+ while ((uiState.customUserTime == null) and isActive) {
+ currentUserTime = uiState.userTimeZone.timeNow()
delay(1000)
}
}
- val copiedList = rememberUpdatedState(newValue = uiState.list) as MutableState
+ val copiedList = rememberUpdatedState(newValue = uiState.favorites) as MutableState
val state = rememberReorderableLazyListState(
- onMove = onMove@{ from, to ->
+ onMove = { from, to ->
// -1 because we use fake item. It fixes animation for the first item in list
copiedList.value = copiedList.value
.toMutableList()
.apply {
add(to.index - 1, removeAt(from.index - 1))
}
- onDragEnd(from.key as String, to.key as String)
},
canDragOver = { draggedOver, _ ->
// Don't allow dragging over fake item
draggedOver.index > 0
+ },
+ onDragEnd = onDragEnd@{ from, to ->
+ if (from == to) return@onDragEnd
+ // There is some logic going on. I have no idea what I did here but it works
+ val tz = copiedList.value.getOrNull(to - 1) ?: return@onDragEnd
+ val targetInOldTz = uiState.favorites.getOrNull(to - 1) ?: return@onDragEnd
+
+ onDragEnd(tz, targetInOldTz.position)
}
)
- // TODO Unswipe on dragging
- var swiped by remember> { mutableStateOf(null) }
- var showTimeSelector by rememberSaveable { mutableStateOf(false) }
- val maxDrag = -with(LocalDensity.current) { 80.dp.toPx() }
UnittoScreenWithTopBar(
- modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
title = { Text(stringResource(R.string.time_zone_title)) },
- navigationIcon = { MenuButton(navigateToMenu) },
+ navigationIcon = { MenuButton(openMenu) },
actions = { SettingsButton(navigateToSettings) },
floatingActionButton = {
- LargeFloatingActionButton(navigateToAddTimeZone) {
+ LargeFloatingActionButton(
+ onClick = {
+ navigateToAddTimeZone(currentUserTime)
+ }
+ ) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = null,
@@ -186,194 +187,183 @@ private fun TimeZoneScreen(
},
floatingActionButtonPosition = FabPosition.Center,
scrollBehavior = scrollBehavior,
-
- ) { paddingValues ->
-
+ ) { padding ->
LazyColumn(
state = state.listState,
modifier = Modifier
- .padding(paddingValues)
- .fillMaxHeight()
- .reorderable(state)
- .detectReorderAfterLongPress(state),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = PaddingValues(bottom = 124.dp)
+ .fillMaxSize()
+ .padding(padding)
+ .reorderable(state),
+ contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 124.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- // This is a fake item. First item in list can not animated, so we do this magic fuckery
- item {
+ item("user time") {
UserTimeZone(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp),
- userTime = uiState.userTime,
- onClick = { showTimeSelector = true },
- onResetClick = resetTime,
- showReset = !uiState.updateTime,
+ .padding(8.dp),
+ userTime = currentUserTime,
+ onClick = { setDialogState(TimeZoneDialogState.UserTimePicker(currentUserTime)) },
+ onResetClick = setCurrentTime,
+ showReset = uiState.customUserTime != null
)
}
- items(copiedList.value, { it.id }) { item ->
- ReorderableItem(state, key = item.id) { isDragging ->
+
+ items(copiedList.value, { it.timeZone.id }) { item ->
+ ReorderableItem(
+ reorderableState = state,
+ key = item.timeZone.id,
+ ) { isDragging ->
+ val isSelected = uiState.selectedTimeZone == item
+
val transition = updateTransition(isDragging, label = "draggedTransition")
- val background by transition.animateColor(label = "background") {
- if (it) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
- }
+
val itemPadding by transition.animateDp(label = "itemPadding") {
- if (it) 32.dp else 16.dp
+ if (it) 8.dp else 0.dp
}
- val scope = rememberCoroutineScope()
- val draggableState = rememberDraggableTimeZone(maxDrag) { swiped = item }
-
- LaunchedEffect(swiped) {
- if (swiped != item) scope.launch {
- draggableState.animateTo(false)
- }
+ val elevation by transition.animateDp(label = "elevation") {
+ if (it) 8.dp else 2.dp
}
- TimeZoneListItem(
+ val cornerRadius by transition.animateInt(label = "cornerRadius") {
+ if (it) 25 else 15
+ }
+
+ FavoriteTimeZoneItem(
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = itemPadding),
- timeZone = item,
- currentTime = uiState.userTime,
- onDelete = onDelete,
- color = background,
- onSwipe = {},
- draggableState = draggableState,
+ .padding(itemPadding)
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(MaterialTheme.colorScheme.surfaceColorAtElevation(elevation))
+ .detectReorderAfterLongPress(state),
+ item = item,
+ fromTime = currentUserTime,
+ expanded = isSelected,
+ onClick = {
+ selectTimeZone(if (isSelected) null else item)
+ },
+ onDelete = { delete(item) },
+ onLabelClick = { setDialogState(TimeZoneDialogState.LabelEditPicker(item)) },
+ onPrimaryClick = { offsetTime ->
+ setDialogState(TimeZoneDialogState.FavoriteTimePicker(item, offsetTime))
+ },
+ isDragging = isDragging
)
}
}
}
}
- if (showTimeSelector) {
- TimePickerDialog(
- hour = uiState.userTime.hour,
- minute = uiState.userTime.minute,
- onConfirm = { hour, minute ->
- setSelectedTime(
- uiState.userTime
- .withHour(hour)
- .withMinute(minute)
+ when (uiState.dialogState) {
+ is TimeZoneDialogState.UserTimePicker -> {
+ TimePickerDialog(
+ hour = currentUserTime.hour,
+ minute = currentUserTime.minute,
+ onConfirm = { hour, minute ->
+ setSelectedTime(
+ currentUserTime
+ .withHour(hour)
+ .withMinute(minute)
+ )
+ setDialogState(TimeZoneDialogState.Nothing)
+ },
+ onDismiss = { setDialogState(TimeZoneDialogState.Nothing) }
+ )
+ }
+
+ is TimeZoneDialogState.FavoriteTimePicker -> {
+ TimePickerDialog(
+ hour = uiState.dialogState.time.hour,
+ minute = uiState.dialogState.time.minute,
+ onConfirm = { hour, minute ->
+ setSelectedTime(
+ uiState.dialogState.time
+ .withHour(hour)
+ .withMinute(minute)
+ .minusSeconds(uiState.dialogState.timeZone.timeZone.offsetSeconds)
+ .plusSeconds(uiState.userTimeZone.offsetSeconds)
+
+ )
+ setDialogState(TimeZoneDialogState.Nothing)
+ },
+ onDismiss = { setDialogState(TimeZoneDialogState.Nothing) }
+ )
+ }
+
+ is TimeZoneDialogState.LabelEditPicker -> {
+ var tfv by rememberSaveable(stateSaver = TextFieldValue.Saver) {
+ mutableStateOf(
+ TextFieldValue(
+ text = uiState.dialogState.timeZone.label,
+ selection = TextRange(uiState.dialogState.timeZone.label.length)
+ )
)
- showTimeSelector = false
- },
- onDismiss = { showTimeSelector = false }
- )
+ }
+ AlertDialog(
+ title = { Text(text = stringResource(R.string.label_label)) },
+ text = {
+ OutlinedTextField(
+ value = tfv,
+ onValueChange = { tfv = it },
+ modifier = Modifier.focusRequester(focusRequester)
+ )
+ LaunchedEffect(Unit) {
+ awaitFrame()
+ focusRequester.requestFocus()
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ setDialogState(TimeZoneDialogState.Nothing)
+ updateLabel(uiState.dialogState.timeZone, tfv.text)
+ },
+ content = { Text(text = stringResource(R.string.ok_label)) }
+ )
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { setDialogState(TimeZoneDialogState.Nothing) },
+ content = { Text(text = stringResource(R.string.cancel_label)) }
+ )
+ },
+ onDismissRequest = { setDialogState(TimeZoneDialogState.Nothing) },
+ )
+ }
+
+ TimeZoneDialogState.Nothing -> Unit
}
}
-@Composable
-private fun UserTimeZone(
- modifier: Modifier,
- userTime: ZonedDateTime,
- onClick: () -> Unit,
- onResetClick: () -> Unit,
- showReset: Boolean,
-) {
-
- Row(
- modifier = modifier
- .squashable(
- onClick = onClick,
- onLongClick = onResetClick,
- cornerRadiusRange = 8.dp..32.dp,
- interactionSource = remember { MutableInteractionSource() }
- )
- .background(MaterialTheme.colorScheme.tertiaryContainer)
- .padding(horizontal = 16.dp, vertical = 12.dp),
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- text = userTime.format(UnittoDateTimeFormatter.zoneFormatPattern),
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onTertiaryContainer
- )
- AnimatedContent(
- targetState = userTime.formatLocal(),
- label = "user time change",
- transitionSpec = {
- slideInVertically { height -> height } + fadeIn() togetherWith
- slideOutVertically { height -> -height } + fadeOut() using
- SizeTransform()
- }
- ) { time ->
- Text(
- text = time,
- style = MaterialTheme.typography.displayLarge,
- color = MaterialTheme.colorScheme.onTertiaryContainer
- )
- }
- Text(
- text = userTime.format(UnittoDateTimeFormatter.dayMonthYear),
- style = MaterialTheme.typography.headlineMedium,
- color = MaterialTheme.colorScheme.onTertiaryContainer
- )
- }
- AnimatedVisibility(
- visible = showReset,
- enter = scaleIn() + fadeIn(),
- exit = scaleOut() + fadeOut(),
- ) {
- IconButton(onResetClick) {
- Icon(
- imageVector = Icons.Outlined.History,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onTertiaryContainer,
- )
- }
- }
- }
-}
-
-@Composable
-private fun rememberDraggableTimeZone(
- maxDrag: Float,
- onSwipe: () -> Unit,
-) = remember {
- AnchoredDraggableState(
- initialValue = false,
- anchors = DraggableAnchors {
- false at 0f
- true at maxDrag
- },
- positionalThreshold = { it * 0.5f },
- velocityThreshold = { maxDrag },
- animationSpec = tween(),
- confirmValueChange = {
- onSwipe()
- true
- }
- )
-}
-
+@RequiresApi(Build.VERSION_CODES.N)
@Preview
@Composable
fun PreviewTimeZoneScreen() {
- Themmo(
- themmoController = rememberThemmoController(
- lightColorScheme = LightThemeColors,
- darkColorScheme = DarkThemeColors,
- ),
- typography = TypographyUnitto,
- ) {
- TimeZoneScreen(
- uiState = TimeZoneUIState(
- list = List(50) {
- UnittoTimeZone(
- id = "timezone $it",
- nameRes = "Time zone $it",
+ TimeZoneScreen(
+ uiState = TimeZoneUIState.Ready(
+ favorites = TimeZone
+ .getAvailableIDs()
+ .mapIndexed { index, tz ->
+ FavoriteZone(
+ timeZone = TimeZone.getTimeZone(tz),
+ position = index,
+ label = if (tz == "ACT") "label text" else ""
)
- }
- ),
- navigateToMenu = {},
- navigateToSettings = {},
- navigateToAddTimeZone = {},
- onDragEnd = { _, _ -> },
- onDelete = {},
- setSelectedTime = {},
- setCurrentTime = {},
- resetTime = {},
- )
- }
+ },
+ customUserTime = null,
+ userTimeZone = TimeZone.getTimeZone("Africa/Addis_Ababa"),
+ selectedTimeZone = null,
+ dialogState = TimeZoneDialogState.Nothing
+ ),
+ openMenu = {},
+ navigateToSettings = {},
+ navigateToAddTimeZone = {},
+ setCurrentTime = {},
+ setSelectedTime = {},
+ onDragEnd = { _, _ -> },
+ delete = {},
+ updateLabel = { _, _ -> },
+ selectTimeZone = {},
+ setDialogState = {}
+ )
}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneUIState.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneUIState.kt
index 5b0ef725..50bc1b05 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneUIState.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneUIState.kt
@@ -18,11 +18,35 @@
package com.sadellie.unitto.feature.timezone
-import com.sadellie.unitto.data.model.UnittoTimeZone
+import android.icu.util.TimeZone
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
import java.time.ZonedDateTime
-data class TimeZoneUIState(
- val list: List = emptyList(),
- val userTime: ZonedDateTime = ZonedDateTime.now(),
- val updateTime: Boolean = true,
-)
+internal sealed class TimeZoneUIState {
+ data object Loading : TimeZoneUIState()
+
+ data class Ready(
+ val favorites: List,
+ val customUserTime: ZonedDateTime?,
+ val userTimeZone: TimeZone,
+ val selectedTimeZone: FavoriteZone?,
+ val dialogState: TimeZoneDialogState,
+ ) : TimeZoneUIState()
+}
+
+internal sealed class TimeZoneDialogState {
+ data object Nothing : TimeZoneDialogState()
+
+ data class UserTimePicker(
+ val time: ZonedDateTime,
+ ) : TimeZoneDialogState()
+
+ data class FavoriteTimePicker(
+ val timeZone: FavoriteZone,
+ val time: ZonedDateTime,
+ ) : TimeZoneDialogState()
+
+ data class LabelEditPicker(
+ val timeZone: FavoriteZone,
+ ) : TimeZoneDialogState()
+}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneViewModel.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneViewModel.kt
index efb9f3c8..accf81e9 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneViewModel.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/TimeZoneViewModel.kt
@@ -18,61 +18,72 @@
package com.sadellie.unitto.feature.timezone
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.sadellie.unitto.data.model.UnittoTimeZone
+import com.sadellie.unitto.data.common.stateIn
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
import com.sadellie.unitto.data.timezone.TimeZonesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.ZonedDateTime
import javax.inject.Inject
+@RequiresApi(Build.VERSION_CODES.N)
@HiltViewModel
-class TimeZoneViewModel @Inject constructor(
+internal class TimeZoneViewModel @Inject constructor(
private val timezonesRepository: TimeZonesRepository
): ViewModel() {
+ private val _userTimeZone = MutableStateFlow(TimeZone.getDefault())
+ private val _customUserTime = MutableStateFlow(null)
+ private val _selectedTimeZone = MutableStateFlow(null)
+ private val _dialogState = MutableStateFlow(TimeZoneDialogState.Nothing)
- private val _userTime = MutableStateFlow(ZonedDateTime.now())
- private val _updateTime = MutableStateFlow(true)
-
- val timeZoneUIState = combine(
- _userTime,
- _updateTime,
- timezonesRepository.favoriteTimeZones
- ) { userTime, updateTime, favorites ->
- return@combine TimeZoneUIState(
- list = favorites,
- userTime = userTime,
- updateTime = updateTime
+ val uiState = combine(
+ _customUserTime,
+ _userTimeZone,
+ _selectedTimeZone,
+ _dialogState,
+ timezonesRepository.favoriteTimeZones,
+ ) { customUserTime, userTimeZone, selectedTimeZone, dialogState, favoriteTimeZones ->
+ return@combine TimeZoneUIState.Ready(
+ favorites = favoriteTimeZones,
+ customUserTime = customUserTime,
+ userTimeZone = userTimeZone,
+ selectedTimeZone = selectedTimeZone,
+ dialogState = dialogState
)
- }.stateIn(
- viewModelScope, SharingStarted.WhileSubscribed(5000), TimeZoneUIState()
- )
+ }
+ .stateIn(viewModelScope, TimeZoneUIState.Loading)
- fun onDragEnd(from: String, to: String) = viewModelScope.launch {
- timezonesRepository.swapTimeZones(from, to)
+ fun setCurrentTime() = _customUserTime.update { null }
+
+ fun setSelectedTime(time: ZonedDateTime) = _customUserTime.update { time }
+
+ fun setDialogState(state: TimeZoneDialogState) = _dialogState.update { state }
+
+ fun onDragEnd(
+ tz: FavoriteZone,
+ targetPosition: Int
+ ) = viewModelScope.launch {
+ timezonesRepository.moveTimeZone(tz, targetPosition)
}
- fun onDelete(timeZone: UnittoTimeZone) = viewModelScope.launch {
- timezonesRepository.delete(timeZone)
+ fun delete(timeZone: FavoriteZone) = viewModelScope.launch {
+ timezonesRepository.removeFromFavorites(timeZone)
}
- fun setCustomTime(time: ZonedDateTime) = viewModelScope.launch(Dispatchers.Default) {
- _updateTime.update { false }
- _userTime.update { time }
- }
+ fun selectTimeZone(timeZone: FavoriteZone?) = _selectedTimeZone.update { timeZone }
- fun resetTime() = viewModelScope.launch(Dispatchers.Default) {
- _updateTime.update { true }
- }
-
- fun setCurrentTime() = viewModelScope.launch(Dispatchers.Default) {
- _userTime.update { ZonedDateTime.now() }
+ fun updateLabel(
+ timeZone: FavoriteZone,
+ label: String
+ ) = viewModelScope.launch {
+ timezonesRepository.updateLabel(timeZone = timeZone, label = label)
}
}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZoneItem.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZoneItem.kt
new file mode 100644
index 00000000..4a9fe390
--- /dev/null
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/FavoriteTimeZoneItem.kt
@@ -0,0 +1,317 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.feature.timezone.components
+
+import android.icu.util.TimeZone
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Schedule
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import com.sadellie.unitto.core.base.R
+import com.sadellie.unitto.core.ui.datetime.formatLocal
+import com.sadellie.unitto.core.ui.datetime.formatOffset
+import com.sadellie.unitto.core.ui.theme.numberHeadlineMedium
+import com.sadellie.unitto.data.common.offset
+import com.sadellie.unitto.data.model.timezone.FavoriteZone
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+@RequiresApi(Build.VERSION_CODES.N)
+@Composable
+internal fun FavoriteTimeZoneItem(
+ modifier: Modifier,
+ item: FavoriteZone,
+ fromTime: ZonedDateTime,
+ isDragging: Boolean,
+ expanded: Boolean,
+ onClick: () -> Unit,
+ onDelete: () -> Unit,
+ onLabelClick: () -> Unit,
+ onPrimaryClick: (ZonedDateTime) -> Unit,
+) {
+ var deleteAnimationRunning by remember { mutableStateOf(false) }
+ val animatedAlpha by animateFloatAsState(
+ label = "delete animation",
+ targetValue = if (deleteAnimationRunning) 0f else 1f,
+ finishedListener = { if (it == 0f) onDelete() }
+ )
+
+ val offsetTime by remember(fromTime) { mutableStateOf(item.timeZone.offset(fromTime)) }
+ val offsetTimeFormatted = offsetTime.formatOffset(fromTime)
+
+ Column(
+ modifier = Modifier
+ .graphicsLayer(alpha = animatedAlpha)
+ .then(modifier)
+ .clickable(enabled = !isDragging) { onClick() }
+ .padding(vertical = 16.dp, horizontal = 12.dp)
+ ) {
+ TimeZoneLabel(
+ label = item.label,
+ expanded = expanded,
+ onLabelClick = onLabelClick
+ )
+ Row(
+ modifier = Modifier
+ .padding()
+ .heightIn(min = 56.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(2.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ text = item.timeZone.displayName,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ AnimatedVisibility(
+ visible = offsetTimeFormatted != null,
+ label = "Nullable offset"
+ ) {
+ Text(
+ text = offsetTimeFormatted ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ AnimatedContent(
+ targetState = offsetTime.formatLocal(),
+ label = "Time change",
+ transitionSpec = {
+ fadeIn() togetherWith fadeOut() using (SizeTransform(clip = false))
+ }
+ ) { time ->
+ // TODO Add AM PM as dots (apply to 12 and 24 hour systems)
+ Text(
+ text = time,
+ style = MaterialTheme.typography.numberHeadlineMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = expanded) {
+ Column {
+ TimeZoneOption(
+ title = stringResource(R.string.select_time_label),
+ icon = Icons.Outlined.Schedule,
+ contentDescription = stringResource(R.string.select_time_label),
+ onClick = { onPrimaryClick(offsetTime) }
+ )
+ TimeZoneOption(
+ title = stringResource(R.string.delete_label),
+ icon = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.delete_label),
+ onClick = { deleteAnimationRunning = true }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TimeZoneOption(
+ title: String,
+ icon: ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(4.dp))
+ .clickable { onClick() }
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun TimeZoneLabel(
+ label: String,
+ expanded: Boolean,
+ onLabelClick: () -> Unit,
+) {
+ AnimatedContent(
+ targetState = label.isBlank(),
+ modifier = if (expanded) Modifier.clickable { onLabelClick() } else Modifier,
+ ) { blank ->
+ if (blank) {
+ AnimatedVisibility(expanded) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(R.string.add_label),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ AnimatedVisibility(visible = expanded) {
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Text(
+ text = label,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
+
+private data class FavoriteTimeZoneItemParameter(
+ val expanded: Boolean,
+ val tz: FavoriteZone,
+)
+
+@RequiresApi(Build.VERSION_CODES.N)
+private class FavoriteTimeZoneItemParameterProvider :
+ PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ FavoriteTimeZoneItemParameter(
+ expanded = false,
+ tz = FavoriteZone(
+ timeZone = TimeZone.getDefault(),
+ position = 1,
+ label = ""
+ ),
+ ),
+ FavoriteTimeZoneItemParameter(
+ expanded = false,
+ tz = FavoriteZone(
+ timeZone = TimeZone.getDefault(),
+ position = 1,
+ label = "Some text"
+ ),
+ ),
+ FavoriteTimeZoneItemParameter(
+ expanded = true,
+ tz = FavoriteZone(
+ timeZone = TimeZone.getDefault(),
+ position = 1,
+ label = ""
+ ),
+ ),
+ FavoriteTimeZoneItemParameter(
+ expanded = true,
+ tz = FavoriteZone(
+ timeZone = TimeZone.getDefault(),
+ position = 1,
+ label = "Some text"
+ ),
+ )
+ )
+}
+
+@RequiresApi(Build.VERSION_CODES.N)
+@Preview(showBackground = true, backgroundColor = 0xFFC1C9FF)
+@Composable
+private fun PreviewFavoriteTimeZones(
+ @PreviewParameter(FavoriteTimeZoneItemParameterProvider::class) tz: FavoriteTimeZoneItemParameter,
+) {
+ var expanded by remember { mutableStateOf(tz.expanded) }
+
+ FavoriteTimeZoneItem(
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ item = tz.tz,
+ fromTime = ZonedDateTime.parse(
+ "2023-05-01T14:00+03:00[Africa/Addis_Ababa]",
+ DateTimeFormatter.ISO_ZONED_DATE_TIME
+ ),
+ expanded = expanded,
+ onClick = { expanded = !expanded },
+ onDelete = {},
+ onPrimaryClick = {},
+ onLabelClick = {},
+ isDragging = false
+ )
+}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/SelectableTimeZone.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/SelectableTimeZone.kt
deleted file mode 100644
index 24950a7d..00000000
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/SelectableTimeZone.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Unitto is a unit converter for Android
- * Copyright (c) 2023 Elshan Agaev
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.sadellie.unitto.feature.timezone.components
-
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.sadellie.unitto.core.ui.datetime.formatLocal
-import com.sadellie.unitto.data.model.UnittoTimeZone
-import java.time.ZonedDateTime
-
-@Composable
-fun SelectableTimeZone(
- modifier: Modifier = Modifier,
- timeZone: UnittoTimeZone,
- currentTime: ZonedDateTime?,
-) {
- val offsetTime: ZonedDateTime? by remember(currentTime) {
- mutableStateOf(currentTime?.let { timeZone.offsetFrom(it) })
- }
-
- ListItem(
- modifier = modifier,
- headlineContent = {
- Text(text = timeZone.nameRes)
- },
- supportingContent = {
- Text(text = timeZone.id)
- },
- trailingContent = {
- Text(text = offsetTime?.formatLocal() ?: "", style = MaterialTheme.typography.headlineSmall)
- }
- )
-}
-
-@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
-@Composable
-fun PreviewSelectableTimeZone() {
- SelectableTimeZone(
- timeZone = UnittoTimeZone(
- id = "text",
- nameRes = "Time zone"
- ),
- modifier = Modifier.width(440.dp),
- currentTime = ZonedDateTime.now(),
- )
-}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/TimeZoneListItem.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/TimeZoneListItem.kt
deleted file mode 100644
index 1a2ef1a9..00000000
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/TimeZoneListItem.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Unitto is a unit converter for Android
- * Copyright (c) 2023 Elshan Agaev
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.sadellie.unitto.feature.timezone.components
-
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.SizeTransform
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.AnchoredDraggableState
-import androidx.compose.foundation.gestures.DraggableAnchors
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.anchoredDraggable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.offset
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import com.sadellie.unitto.core.ui.datetime.formatLocal
-import com.sadellie.unitto.core.ui.datetime.formatOffset
-import com.sadellie.unitto.data.model.UnittoTimeZone
-import java.time.ZonedDateTime
-import kotlin.math.roundToInt
-
-@Composable
-fun TimeZoneListItem(
- modifier: Modifier,
- timeZone: UnittoTimeZone,
- currentTime: ZonedDateTime,
- onClick: () -> Unit = {},
- onDelete: (UnittoTimeZone) -> Unit = {},
- color: Color = MaterialTheme.colorScheme.surfaceVariant,
- onSwipe: (UnittoTimeZone) -> Unit = {},
- draggableState: AnchoredDraggableState,
-) {
- val offsetTime by remember(currentTime) { mutableStateOf(timeZone.offsetFrom(currentTime)) }
- val offsetTimeFormatted = offsetTime.formatOffset(currentTime)
-
- // TODO Animate deleting
- Box(
- modifier = modifier
- .heightIn(72.dp)
- .clip(RoundedCornerShape(25))
- .anchoredDraggable(
- state = draggableState,
- orientation = Orientation.Horizontal,
- ),
- contentAlignment = Alignment.Center,
- ) {
- // TODO Reveal animation
- Box(
- modifier = Modifier
- .clickable { onDelete(timeZone) }
- .background(MaterialTheme.colorScheme.errorContainer)
- .matchParentSize()
- ) {
- Icon(
- modifier = Modifier
- .align(Alignment.CenterEnd)
- .padding(horizontal = 24.dp)
- .size(32.dp),
- imageVector = Icons.Outlined.Delete,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onErrorContainer
- )
- }
-
- Row(
- modifier = Modifier
- .offset {
- IntOffset(
- y = 0,
- x = draggableState
- .requireOffset()
- .roundToInt()
- )
- }
- .clickable {}
- .background(color)
- .matchParentSize()
- .padding(12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(Modifier.weight(1f)) {
- // TODO Name
- Text(
- text = timeZone.nameRes,
- style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
- AnimatedVisibility(
- visible = offsetTimeFormatted != null,
- label = "Nullable offset"
- ) {
- Text(offsetTimeFormatted ?: "", style = MaterialTheme.typography.bodyMedium)
- }
- }
- Column {
- // Time
- AnimatedContent(
- targetState = offsetTime.formatLocal(),
- label = "Time change",
- transitionSpec = {
- fadeIn() togetherWith fadeOut() using (SizeTransform(clip = false))
- }
- ) { time ->
- Text(time, style = MaterialTheme.typography.headlineMedium)
- }
- }
- }
- }
-}
-
-@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
-@Composable
-fun PreviewTimeZoneListItem() {
-
- val maxDrag = -with(LocalDensity.current) { 80.dp.toPx() }
- val draggableState = remember {
- AnchoredDraggableState(
- initialValue = false,
- anchors = DraggableAnchors {
- false at 0f
- true at maxDrag
- },
- positionalThreshold = { 0f },
- velocityThreshold = { 0f },
- animationSpec = tween(),
- confirmValueChange = { true }
- )
- }
-
- TimeZoneListItem(
- modifier = Modifier,
- timeZone = UnittoTimeZone(
- id = "timezone",
- offsetSeconds = -10800,
- nameRes = "Time zone"
- ),
- currentTime = ZonedDateTime.now(),
- draggableState = draggableState
- )
-}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/UserTimeZone.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/UserTimeZone.kt
new file mode 100644
index 00000000..628e3e97
--- /dev/null
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/components/UserTimeZone.kt
@@ -0,0 +1,161 @@
+/*
+ * Unitto is a unit converter for Android
+ * Copyright (c) 2023 Elshan Agaev
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.sadellie.unitto.feature.timezone.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.History
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.sadellie.unitto.core.ui.common.squashable
+import com.sadellie.unitto.core.ui.datetime.UnittoDateTimeFormatter
+import com.sadellie.unitto.core.ui.datetime.formatOnlyHours
+import com.sadellie.unitto.core.ui.datetime.formatOnlyMinutes
+import com.sadellie.unitto.core.ui.theme.numberBodyLarge
+import com.sadellie.unitto.core.ui.theme.numberDisplayLarge
+import java.time.ZonedDateTime
+
+@Composable
+internal fun UserTimeZone(
+ modifier: Modifier,
+ userTime: ZonedDateTime,
+ onClick: () -> Unit,
+ onResetClick: () -> Unit,
+ showReset: Boolean,
+) {
+
+ Row(
+ modifier = modifier
+ .squashable(
+ onClick = onClick,
+ onLongClick = onResetClick,
+ cornerRadiusRange = 8.dp..32.dp,
+ interactionSource = remember { MutableInteractionSource() }
+ )
+ .background(MaterialTheme.colorScheme.tertiaryContainer)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = userTime.format(UnittoDateTimeFormatter.zoneFormatPattern),
+ style = MaterialTheme.typography.numberBodyLarge,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+
+ Row(
+ verticalAlignment = Alignment.Bottom
+ ) {
+ SlidingText(text = userTime.formatOnlyHours())
+ TimeSeparator()
+ SlidingText(text = userTime.formatOnlyMinutes())
+ }
+
+ Text(
+ text = userTime.format(UnittoDateTimeFormatter.dayMonthYear),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ AnimatedVisibility(
+ visible = showReset,
+ enter = scaleIn() + fadeIn(),
+ exit = scaleOut() + fadeOut(),
+ ) {
+ IconButton(onResetClick) {
+ Icon(
+ imageVector = Icons.Outlined.History,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SlidingText(
+ text: String,
+ style: TextStyle = MaterialTheme.typography.numberDisplayLarge
+) {
+ AnimatedContent(
+ targetState = text,
+ label = "user time change",
+ transitionSpec = {
+ slideInVertically { height -> height } + fadeIn() togetherWith
+ slideOutVertically { height -> -height } + fadeOut() using
+ SizeTransform()
+ }
+ ) { target ->
+ Text(
+ text = target,
+ style = style,
+ color = MaterialTheme.colorScheme.onTertiaryContainer,
+ overflow = TextOverflow.Visible,
+ maxLines = 1
+ )
+ }
+}
+
+@Composable
+private fun TimeSeparator(
+ text: String = ":",
+ style: TextStyle = MaterialTheme.typography.numberDisplayLarge
+) {
+ Text(
+ text = text,
+ style = style,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+}
+
+@Preview
+@Composable
+private fun PreviewUserTimeZone() {
+ UserTimeZone(
+ modifier = Modifier,
+ userTime = ZonedDateTime.now(),
+ onClick = {},
+ onResetClick = {},
+ showReset = true
+ )
+}
diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/navigation/TimeZoneNavigation.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/navigation/TimeZoneNavigation.kt
index a1bf34d1..0d15f5d1 100644
--- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/navigation/TimeZoneNavigation.kt
+++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/navigation/TimeZoneNavigation.kt
@@ -18,6 +18,7 @@
package com.sadellie.unitto.feature.timezone.navigation
+import android.os.Build
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
@@ -25,6 +26,7 @@ import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.sadellie.unitto.core.base.TopLevelDestinations
+import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen
import com.sadellie.unitto.core.ui.unittoComposable
import com.sadellie.unitto.core.ui.unittoNavigation
import com.sadellie.unitto.feature.timezone.AddTimeZoneRoute
@@ -38,11 +40,11 @@ private const val ADD_TIME_ZONE_ROUTE = "ADD_TIME_ZONE_ROUTE"
private const val USER_TIME_ARG = "USER_TIME_ARG"
private fun NavController.navigateToAddTimeZone(
- userTime: ZonedDateTime?
+ userTime: ZonedDateTime
) {
val formattedTime = userTime
- ?.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)
- ?.replace("/", "|") // this is so wrong
+ .format(DateTimeFormatter.ISO_ZONED_DATE_TIME)
+ .replace("/", "|") // this is so wrong
navigate("$ADD_TIME_ZONE_ROUTE/$formattedTime")
}
@@ -60,8 +62,13 @@ fun NavGraphBuilder.timeZoneGraph(
)
) {
unittoComposable(start) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ UnittoEmptyScreen()
+ return@unittoComposable
+ }
+
TimeZoneRoute(
- navigateToMenu = navigateToMenu,
+ openMenu = navigateToMenu,
navigateToSettings = navigateToSettings,
navigateToAddTimeZone = navController::navigateToAddTimeZone
)
@@ -77,10 +84,16 @@ fun NavGraphBuilder.timeZoneGraph(
}
)
) { stackEntry ->
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ UnittoEmptyScreen()
+ return@unittoComposable
+ }
+
val userTime = stackEntry.arguments
?.getString(USER_TIME_ARG)
?.replace("|", "/") // war crime, don't look
?.let { ZonedDateTime.parse(it, DateTimeFormatter.ISO_ZONED_DATE_TIME) }
+ ?: ZonedDateTime.now()
AddTimeZoneRoute(
navigateUp = navController::navigateUp,