Time zone converter rewrite

ref: #88
This commit is contained in:
Sad Ellie 2023-10-07 00:30:04 +03:00
parent f3ba0bd06a
commit 4fc9fc6b0c
30 changed files with 1345 additions and 823 deletions

View File

@ -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() }

View File

@ -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 {
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!

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- https://s3.eu-west-1.amazonaws.com/po-pub/i/QNmUuI88wb1Fm5P8wRIZajZF.png -->
<string name="add_label">Add</string>
<string name="app_name" translatable="false">Unitto</string>
<string name="calculator_clear_history_support">All expressions from history will be deleted forever. This action can\'t be undone!</string>
<string name="calculator_divide_by_zero_error">Can\'t divide by 0</string>
<string name="calculator_no_history">No history</string>
<!-- Calculator -->
<string name="calculator_title">Calculator</string>
<string name="cancel_label">Cancel</string>
<string name="checked_filter_description">Checked filter</string>
@ -14,8 +15,6 @@
<string name="clear_label">Clear</string>
<string name="click_to_try_again_label">Click to try again</string>
<string name="converter_favorite_button_description">Add or remove unit from favorites</string>
<!-- Screen names -->
<string name="converter_left_side_title">Convert from</string>
<string name="converter_no_results_support">Make sure there are no typos, try different filters or check for disabled unit groups.</string>
<string name="converter_right_side_title">Convert to</string>
@ -43,16 +42,15 @@
<!-- https://s3.eu-west-1.amazonaws.com/po-pub/i/33QTn2NVrjJT772cBDFRqSMH.png -->
<string name="date_calculator_years">Years</string>
<string name="delete_label">Delete</string>
<string name="disabled_label">Disabled</string>
<string name="drop_down_description">Open or close drop down menu</string>
<string name="enabled_label">Enabled</string>
<!-- Tools -->
<string name="epoch_converter_title">Epoch converter</string>
<string name="error_label">Error</string>
<string name="hello_label">Hello!</string>
<!-- MISC. -->
<!-- https://s3.eu-west-1.amazonaws.com/po-pub/i/iqEp9rZZ9XqtJ3CKVpy33lre.png -->
<string name="label_label">Label</string>
<string name="loading_label">Loading…</string>
<string name="locale_de" translatable="false">Deutsch</string>
<string name="locale_en" translatable="false">English</string>
@ -65,8 +63,6 @@
<string name="locale_nl" translatable="false">Dutch</string>
<string name="locale_ru" translatable="false">Русский</string>
<string name="locale_tr" translatable="false">Türkçe</string>
<!-- Content descriptions -->
<string name="navigate_up_description">Navigate up</string>
<!-- https://s3.eu-west-1.amazonaws.com/po-pub/i/9lSfdkfKShwyQFEF4nvbVaIb.jpg
@ -77,7 +73,6 @@ Used in this dialog window. Should be short -->
<string name="no_results_label">No results found</string>
<string name="ok_label" translatable="false">OK</string>
<string name="open_menu_description">Open menu</string>
<string name="open_settings_description">Open settings</string>
<string name="open_settings_label">Open settings</string>
<string name="search_button_description">Search button</string>
<string name="search_text_field_placeholder">Search…</string>
@ -102,15 +97,11 @@ Used in this dialog window. Should be short -->
<string name="settings_dark_mode">Dark</string>
<string name="settings_disable_unit_group_description">Disable unit group</string>
<string name="settings_display">Display</string>
<!-- Theme -->
<string name="settings_display_support">App look and feel</string>
<string name="settings_dynamic_colors">Dynamic colors</string>
<string name="settings_dynamic_colors_support">Use colors from your wallpaper</string>
<string name="settings_enable_unit_group_description">Enable unit group</string>
<string name="settings_exponential_notation">Exponential notation</string>
<!-- Output format -->
<string name="settings_exponential_notation_support">Replace part of the number with E</string>
<string name="settings_format_time">Format time</string>
<string name="settings_format_time_support">Example: Show 130 minutes as 2h 10m</string>
@ -134,8 +125,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="settings_precision">Precision</string>
<string name="settings_precision_info">Converted values may have a precision higher than the preferred one.</string>
<string name="settings_precision_max">%1$s (Max)</string>
<!-- Precision -->
<string name="settings_precision_support">Number of decimal places</string>
<string name="settings_privacy_policy">Privacy Policy</string>
<string name="settings_rate_this_app">Rate this app</string>
@ -143,14 +132,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="settings_selected_color">Selected color</string>
<string name="settings_selected_style">Selected style</string>
<string name="settings_separator">Separator</string>
<!-- Separator -->
<string name="settings_separator_support">Group separator symbol</string>
<string name="settings_sort_by_alphabetical">Alphabetical</string>
<string name="settings_sort_by_scale_asc">Scale (Asc.)</string>
<string name="settings_sort_by_scale_desc">Scale (Desc.)</string>
<!-- Units list sorting -->
<string name="settings_sort_by_usage">Usage</string>
<string name="settings_space">Space</string>
<string name="settings_starting_screen">Starting screen</string>
@ -162,9 +147,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="settings_system_font">System font</string>
<string name="settings_system_font_support">Use system font for texts in app</string>
<string name="settings_terms_and_conditions">Terms and Conditions</string>
<!-- Settings items -->
<string name="settings_theme_title">Themes</string>
<string name="settings_third_party_licenses">Third party licenses</string>
<string name="settings_title">Settings</string>
<string name="settings_translate_app">Translate this app</string>
@ -184,57 +166,35 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_acre_short">ac</string>
<string name="unit_angle_minute">Minute</string>
<string name="unit_angle_minute_short" translatable="false">\'</string>
<!-- Angle -->
<string name="unit_angle_second">Second</string>
<string name="unit_angle_second_short" translatable="false">\"</string>
<string name="unit_apostilb">Apostilb</string>
<string name="unit_apostilb_short">asb</string>
<string name="unit_atomic_mass_unit">Dalton</string>
<string name="unit_atomic_mass_unit_short">u</string>
<!-- ELECTROSTATIC CAPACITANCE -->
<string name="unit_attofarad">Attofarad</string>
<string name="unit_attofarad_short">aF</string>
<string name="unit_attojoule">Attojoule</string>
<string name="unit_attojoule_short">aJ</string>
<!-- Volume -->
<string name="unit_attoliter">Attoliter</string>
<string name="unit_attoliter_short">aL</string>
<!-- Length -->
<string name="unit_attometer">Attometer</string>
<!-- Acceleration -->
<string name="unit_attometer_per_square_second">Attometer/square second</string>
<string name="unit_attometer_per_square_second_short">am/s^2</string>
<string name="unit_attometer_short">am</string>
<string name="unit_attonewton">Attonewton</string>
<string name="unit_attonewton_short">aN</string>
<!-- Pressure -->
<string name="unit_attopascal">Attopascal</string>
<string name="unit_attopascal_short">aPa</string>
<!-- Time -->
<string name="unit_attosecond">Attosecond</string>
<string name="unit_attosecond_short">as</string>
<!-- Power -->
<string name="unit_attowatt">Attowatt</string>
<string name="unit_attowatt_short">aW</string>
<string name="unit_bar">Bar</string>
<string name="unit_bar_short">bar</string>
<!-- Number base -->
<string name="unit_binary">Binary</string>
<string name="unit_binary_short">base2</string>
<!-- File size -->
<string name="unit_bit">Bit</string>
<!-- Data transfer -->
<string name="unit_bit_per_second">Bit/second</string>
<string name="unit_bit_per_second_short">b/s</string>
<string name="unit_bit_short">b</string>
@ -254,14 +214,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_candela_per_square_foot_short">cd/ft^2</string>
<string name="unit_candela_per_square_inch">Candela/square inch</string>
<string name="unit_candela_per_square_inch_short">cd/in^2</string>
<!-- Luminance -->
<string name="unit_candela_per_square_meter">Candela/square meter</string>
<string name="unit_candela_per_square_meter_short">cd/m^2</string>
<string name="unit_carat">Carat</string>
<string name="unit_carat_short">ct</string>
<!-- Temperature -->
<string name="unit_celsius">Celsius</string>
<string name="unit_celsius_short" translatable="false">°C</string>
<string name="unit_cent">Cent</string>
@ -278,8 +234,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_centimeter_short">cm</string>
<string name="unit_centipascal">Centipascal</string>
<string name="unit_centipascal_short">cPa</string>
<!-- Tools -->
<string name="unit_converter_title">Unit converter</string>
<string name="unit_cosmic_velocity_first">First Cosmic Velocity</string>
<string name="unit_cosmic_velocity_first_short" translatable="false">v1</string>
@ -313,8 +267,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_cubic_millimeter_per_second">Cubic Millimeter/second</string>
<string name="unit_cubic_millimeter_per_second_short">mm3/s</string>
<string name="unit_cubic_millimeter_short">mm^3</string>
<!-- Currency -->
<string name="unit_currency_1inch" translatable="false">1inch Network</string>
<string name="unit_currency_1inch_short" translatable="false">NCH</string>
<string name="unit_currency_ada">Cardano</string>
@ -765,16 +717,10 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_earth_surface_gravity_short">Earth g</string>
<string name="unit_earths_orbital_speed">Earth\'s orbital speed</string>
<string name="unit_earths_orbital_speed_short" translatable="false">ve</string>
<!-- Area -->
<string name="unit_electron_cross_section">Electron cross section</string>
<string name="unit_electron_cross_section_short">ecs</string>
<!-- Mass -->
<string name="unit_electron_mass_rest">Electron mass</string>
<string name="unit_electron_mass_rest_short">me</string>
<!-- Energy -->
<string name="unit_electron_volt">Electron volt</string>
<string name="unit_electron_volt_short">eV</string>
<string name="unit_energy_horse_power_metric">Horse power</string>
@ -881,8 +827,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_group_flux">Flux</string>
<string name="unit_group_force">Force</string>
<string name="unit_group_fuel_consumption">Fuel</string>
<!-- Groups -->
<string name="unit_group_length">Length</string>
<string name="unit_group_luminance">Luminance</string>
<string name="unit_group_mass">Mass</string>
@ -1018,8 +962,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_light_year">Light year</string>
<string name="unit_light_year_short">ly</string>
<string name="unit_liter">Liter</string>
<!-- Flow rate -->
<string name="unit_liter_per_hour">Liter/hour</string>
<string name="unit_liter_per_hour_short">L/h</string>
<string name="unit_liter_per_minute">Liter/minute</string>
@ -1043,8 +985,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_mars_mass_short">Mars M</string>
<string name="unit_mars_surface_gravity">Mars surface gravity</string>
<string name="unit_mars_surface_gravity_short">Mars g</string>
<!-- Flux -->
<string name="unit_maxwell">Maxwell</string>
<string name="unit_maxwell_short">Mx</string>
<string name="unit_mebibit">Mebibit</string>
@ -1144,8 +1084,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_millimeter">Millimeter</string>
<string name="unit_millimeter_of_mercury">Millimeter of mercury</string>
<string name="unit_millimeter_of_mercury_short">mm Hg</string>
<!-- Speed -->
<string name="unit_millimeter_per_hour">Millimeter/hour</string>
<string name="unit_millimeter_per_hour_short">mm/h</string>
<string name="unit_millimeter_per_minute">Millimeter/minute</string>
@ -1186,13 +1124,9 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_neptune_mass_short">Neptune M</string>
<string name="unit_neptune_surface_gravity">Neptune surface gravity</string>
<string name="unit_neptune_surface_gravity_short">Neptune g</string>
<!-- Force -->
<string name="unit_newton">Newton</string>
<string name="unit_newton_centimeter">Newton centimeter</string>
<string name="unit_newton_centimeter_short">N*cm</string>
<!-- Torque -->
<string name="unit_newton_meter">Newton meter</string>
<string name="unit_newton_meter_short">N*m</string>
<string name="unit_newton_millimeter">Newton millimeter</string>
@ -1280,8 +1214,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_prefix_pico_short">p</string>
<string name="unit_prefix_quecto">Quecto</string>
<string name="unit_prefix_quecto_short">q</string>
<!-- Prefixes -->
<string name="unit_prefix_quetta">Quetta</string>
<string name="unit_prefix_quetta_short">Q</string>
<string name="unit_prefix_ronna">Ronna</string>
@ -1422,8 +1354,6 @@ Maybe this can be labeled better? Let me know. It should be something that can d
<string name="unit_yard_per_second">Yard/second</string>
<string name="unit_yard_per_second_short">yd/s</string>
<string name="unit_yard_short">yd</string>
<!-- Epoch -->
<string name="unit_year_short">y</string>
<string name="yesterday">Yesterday</string>
</resources>

View File

@ -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()
) {

View File

@ -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
*/

View File

@ -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)}"

View File

@ -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,
)
}
}
}
}

View File

@ -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(

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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()

View File

@ -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<List<TimeZoneEntity>>
@Query("SELECT * FROM time_zones WHERE position > 0 ORDER BY position ASC")
fun getFavorites(): Flow<List<TimeZoneEntity>>
@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)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)

View File

@ -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<String, UnittoTimeZone> = 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<String, String> by lazy {
// hashMapOf()
// }
val favoriteTimeZones: Flow<List<UnittoTimeZone>> = dao
.getAll()
val favoriteTimeZones: Flow<List<FavoriteZone>> = dao
.getFavorites()
.map { list ->
val favorites = mutableListOf<UnittoTimeZone>()
val favorites = mutableListOf<FavoriteZone>()
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<UnittoTimeZone> =
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<SearchResultZone> =
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<Pair<UnittoTimeZone, Int>>()
val timeZonesWithDist = mutableListOf<Pair<SearchResultZone, Int>>()
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)
return@forEach
}
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
timeZoneName.startsWith(query) -> {
timeZonesWithDist.add(timeZone to 1)
name.startsWith(query) -> {
timeZonesWithDist.add(SearchResultZone(timeZone, id) to 1)
return@forEach
}
timeZoneName.contains(query) -> {
timeZonesWithDist.add(timeZone to 2)
name.contains(query) -> {
timeZonesWithDist.add(SearchResultZone(timeZone, id) to 2)
return@forEach
}
}
val levDist = timeZoneName
.substring(0, minOf(query.length, timeZoneName.length))
val nameLevDist = name
.substring(0, minOf(query.length, name.length))
.lev(query)
if (nameLevDist < threshold) {
timeZonesWithDist.add(SearchResultZone(timeZone, id) to nameLevDist)
return@forEach
}
if (levDist < threshold) {
timeZonesWithDist.add(timeZone to levDist)
// ID Match
when {
// not zero, so that lev can have that
id.startsWith(query) -> {
timeZonesWithDist.add(SearchResultZone(timeZone, id) to 1)
return@forEach
}
id.contains(query) -> {
timeZonesWithDist.add(SearchResultZone(timeZone, id) to 2)
return@forEach
}
}
val idLevDist = id
.substring(0, minOf(query.length, id.length))
.lev(query)
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()
)
)
}
}

View File

@ -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)
)
}

View File

@ -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))
}
}
}

View File

@ -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)

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<ComponentActivity>()
@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()
}
}

View File

@ -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)
}
}
AddTimeZoneScreen(
uiState = uiState.value,
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
)
}
}
@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,
Crossfade(targetState = uiState.list.isEmpty()) { empty ->
if (empty) {
UnittoEmptyScreen()
} else {
LazyColumn(contentPadding = paddingValues) {
items(uiState.list, { it.timeZone.id }) {
UnittoListItem(
modifier = Modifier
.clickable { addToFavorites(it); navigateUp() }
.fillMaxWidth(),
currentTime = uiState.userTime
.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()
)
}

View File

@ -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<UnittoTimeZone> = emptyList(),
val userTime: ZonedDateTime? = null,
)
sealed class AddTimeZoneUIState {
data object Loading: AddTimeZoneUIState()
data class Ready(
val query: TextFieldValue,
val list: List<SearchResultZone>,
): AddTimeZoneUIState()
}

View File

@ -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?>(ZonedDateTime.now())
private val _query = MutableStateFlow(TextFieldValue())
private val _result = MutableStateFlow(emptyList<SearchResultZone>())
private val _filteredTimeZones = MutableStateFlow(emptyList<UnittoTimeZone>())
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()
}
}

View File

@ -16,22 +16,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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))

View File

@ -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()
when (val uiState = viewModel.uiState.collectAsStateWithLifecycle().value) {
TimeZoneUIState.Loading -> UnittoEmptyScreen()
is TimeZoneUIState.Ready -> {
TimeZoneScreen(
uiState = uiState.value,
navigateToMenu = navigateToMenu,
uiState = uiState,
openMenu = openMenu,
navigateToSettings = navigateToSettings,
navigateToAddTimeZone = {
navigateToAddTimeZone(
if (uiState.value.updateTime) null
else uiState.value.userTime
)
},
onDragEnd = viewModel::onDragEnd,
onDelete = viewModel::onDelete,
setSelectedTime = viewModel::setCustomTime,
navigateToAddTimeZone = navigateToAddTimeZone,
setCurrentTime = viewModel::setCurrentTime,
resetTime = viewModel::resetTime,
setSelectedTime = viewModel::setSelectedTime,
onDragEnd = viewModel::onDragEnd,
delete = viewModel::delete,
updateLabel = viewModel::updateLabel,
selectTimeZone = viewModel::selectTimeZone,
setDialogState = viewModel::setDialogState
)
}
}
}
@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<MutableState<UnittoTimeZone?>> { 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) {
when (uiState.dialogState) {
is TimeZoneDialogState.UserTimePicker -> {
TimePickerDialog(
hour = uiState.userTime.hour,
minute = uiState.userTime.minute,
hour = currentUserTime.hour,
minute = currentUserTime.minute,
onConfirm = { hour, minute ->
setSelectedTime(
uiState.userTime
currentUserTime
.withHour(hour)
.withMinute(minute)
)
showTimeSelector = false
setDialogState(TimeZoneDialogState.Nothing)
},
onDismiss = { showTimeSelector = false }
onDismiss = { setDialogState(TimeZoneDialogState.Nothing) }
)
}
}
@Composable
private fun UserTimeZone(
modifier: Modifier,
userTime: ZonedDateTime,
onClick: () -> Unit,
onResetClick: () -> Unit,
showReset: Boolean,
) {
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)
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
setDialogState(TimeZoneDialogState.Nothing)
},
positionalThreshold = { it * 0.5f },
velocityThreshold = { maxDrag },
animationSpec = tween(),
confirmValueChange = {
onSwipe()
true
}
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)
)
)
}
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
}
}
@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",
uiState = TimeZoneUIState.Ready(
favorites = TimeZone
.getAvailableIDs()
.mapIndexed { index, tz ->
FavoriteZone(
timeZone = TimeZone.getTimeZone(tz),
position = index,
label = if (tz == "ACT") "label text" else ""
)
}
},
customUserTime = null,
userTimeZone = TimeZone.getTimeZone("Africa/Addis_Ababa"),
selectedTimeZone = null,
dialogState = TimeZoneDialogState.Nothing
),
navigateToMenu = {},
openMenu = {},
navigateToSettings = {},
navigateToAddTimeZone = {},
onDragEnd = { _, _ -> },
onDelete = {},
setSelectedTime = {},
setCurrentTime = {},
resetTime = {},
setSelectedTime = {},
onDragEnd = { _, _ -> },
delete = {},
updateLabel = { _, _ -> },
selectTimeZone = {},
setDialogState = {}
)
}
}

View File

@ -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<UnittoTimeZone> = emptyList(),
val userTime: ZonedDateTime = ZonedDateTime.now(),
val updateTime: Boolean = true,
)
internal sealed class TimeZoneUIState {
data object Loading : TimeZoneUIState()
data class Ready(
val favorites: List<FavoriteZone>,
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()
}

View File

@ -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<ZonedDateTime?>(null)
private val _selectedTimeZone = MutableStateFlow<FavoriteZone?>(null)
private val _dialogState = MutableStateFlow<TimeZoneDialogState>(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
)
}.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5000), TimeZoneUIState()
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, 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)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<FavoriteTimeZoneItemParameter> {
override val values: Sequence<FavoriteTimeZoneItemParameter>
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
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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(),
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean>,
) {
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
)
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
}

View File

@ -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,