From 542a95acf70b8a54f963f30dbbc35e02358416a1 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sat, 28 Jan 2023 15:00:38 +0400 Subject: [PATCH] Added formatter for time --- .../main/java/UnittoLibraryComposePlugin.kt | 2 + core/ui/build.gradle.kts | 10 ++ .../com/sadellie/unitto/core/ui/Formatter.kt | 57 ++++++++++ .../sadellie/unitto/core/ui/FormatterTest.kt | 106 ++++++++++++++++++ .../com/sadellie/unitto/data/FlowUtils.kt | 8 +- .../unitto/feature/converter/MainScreen.kt | 4 + .../feature/converter/MainScreenUIState.kt | 2 + .../unitto/feature/converter/MainViewModel.kt | 14 ++- .../feature/converter/components/TopScreen.kt | 16 ++- gradle/libs.versions.toml | 2 + 10 files changed, 214 insertions(+), 7 deletions(-) diff --git a/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt b/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt index d3e799b3..37a429f2 100644 --- a/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt +++ b/build-logic/convention/src/main/java/UnittoLibraryComposePlugin.kt @@ -37,6 +37,8 @@ class UnittoLibraryComposePlugin : Plugin { "implementation"(libs.findLibrary("androidx.compose.material3").get()) "implementation"(libs.findLibrary("androidx.lifecycle.runtime.compose").get()) "implementation"(libs.findLibrary("androidx.compose.material.icons.extended").get()) + "implementation"(libs.findLibrary("androidx.compose.ui.tooling").get()) + "implementation"(libs.findLibrary("androidx.compose.ui.tooling.preview").get()) } } } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7ac1d3c2..e7cbbc37 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -24,10 +24,20 @@ plugins { android { namespace = "com.sadellie.unitto.core.ui" + + // Workaround from https://github.com/robolectric/robolectric/pull/4736 + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { testImplementation(libs.junit) + testImplementation(libs.org.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) implementation(project(mapOf("path" to ":core:base"))) } diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt index 83222fbf..70cdab7e 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt @@ -18,14 +18,20 @@ package com.sadellie.unitto.core.ui +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import com.sadellie.unitto.core.base.INTERNAL_DISPLAY +import com.sadellie.unitto.core.base.KEY_0 import com.sadellie.unitto.core.base.KEY_COMMA import com.sadellie.unitto.core.base.KEY_DOT import com.sadellie.unitto.core.base.KEY_E import com.sadellie.unitto.core.base.KEY_LEFT_BRACKET +import com.sadellie.unitto.core.base.KEY_MINUS import com.sadellie.unitto.core.base.KEY_RIGHT_BRACKET import com.sadellie.unitto.core.base.OPERATORS import com.sadellie.unitto.core.base.Separator +import java.math.BigDecimal +import java.math.RoundingMode object Formatter { private const val SPACE = " " @@ -42,6 +48,19 @@ object Formatter { */ var fractional = KEY_COMMA + private val timeDivisions by lazy { + mapOf( + R.string.day_short to BigDecimal("86400000000000000000000"), + R.string.hour_short to BigDecimal("3600000000000000000000"), + R.string.minute_short to BigDecimal("60000000000000000000"), + R.string.second_short to BigDecimal("1000000000000000000"), + R.string.millisecond_short to BigDecimal("1000000000000000"), + R.string.microsecond_short to BigDecimal("1000000000000"), + R.string.nanosecond_short to BigDecimal("1000000000"), + R.string.attosecond_short to BigDecimal("1"), + ) + } + /** * Change current separator to another [separator]. * @@ -118,4 +137,42 @@ object Formatter { return output } + + /** + * Takes [input] and [basicUnit] of the unit to format it to be more human readable. + * + * @return String like "1d 12h 12s". + */ + @Composable + fun formatTime(input: String, basicUnit: BigDecimal?): String { + if (basicUnit == null) return KEY_0 + + try { + // Don't need magic if the input is zero + if (BigDecimal(input).compareTo(BigDecimal.ZERO) == 0) return KEY_0 + } catch (e: NumberFormatException) { + // For case such as "10-" and "(" + return KEY_0 + } + // Attoseconds don't need "magic" + if (basicUnit.compareTo(BigDecimal.ONE) == 0) return formatNumber(input) + + var result = if (input.startsWith(KEY_MINUS)) KEY_MINUS else "" + var remainingSeconds = BigDecimal(input) + .abs() + .multiply(basicUnit) + .setScale(0, RoundingMode.HALF_EVEN) + + if (remainingSeconds.compareTo(BigDecimal.ZERO) == 0) return KEY_0 + + timeDivisions.forEach { (timeStr, divider) -> + val division = remainingSeconds.divideAndRemainder(divider) + val time = division.component1() + remainingSeconds = division.component2() + if (time.compareTo(BigDecimal.ZERO) == 1) { + result += "${formatNumber(time.toPlainString())}${stringResource(timeStr)} " + } + } + return result.trimEnd() + } } diff --git a/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt b/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt index 9358323f..237111ba 100644 --- a/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt +++ b/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt @@ -18,9 +18,14 @@ package com.sadellie.unitto.core.ui +import androidx.compose.ui.test.junit4.createComposeRule import com.sadellie.unitto.core.base.Separator import org.junit.Assert.assertEquals +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.math.BigDecimal private val formatter = Formatter @@ -33,8 +38,12 @@ private const val INCOMPLETE_EXPR = "50+123456÷8×0.8–12+" private const val COMPLETE_EXPR = "50+123456÷8×0.8–12+0-√9*4^9+2×(9+8×7)" private const val SOME_BRACKETS = "((((((((" +@RunWith(RobolectricTestRunner::class) class FormatterTest { + @get: Rule + val composeTestRule = createComposeRule() + @Test fun setSeparatorSpaces() { formatter.setSeparator(Separator.SPACES) @@ -77,4 +86,101 @@ class FormatterTest { assertEquals("((((((((", formatter.format(SOME_BRACKETS)) } + @Test + fun formatTimeTest() { + formatter.setSeparator(Separator.SPACES) + composeTestRule.setContent { + var basicValue = BigDecimal.valueOf(1) + assertEquals("-28", Formatter.formatTime("-28", basicValue)) + assertEquals("-0.05", Formatter.formatTime("-0.05", basicValue)) + assertEquals("0", Formatter.formatTime("0", basicValue)) + assertEquals("0", Formatter.formatTime("-0", basicValue)) + + basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) + assertEquals("-28d", Formatter.formatTime("-28", basicValue)) + assertEquals("-1h 12m", Formatter.formatTime("-0.05", basicValue)) + assertEquals("0", Formatter.formatTime("0", basicValue)) + assertEquals("0", Formatter.formatTime("-0", basicValue)) + + // DAYS + basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) + assertEquals("12h", Formatter.formatTime("0.5", basicValue)) + assertEquals("1h 12m", Formatter.formatTime("0.05", basicValue)) + assertEquals("7m 12s", Formatter.formatTime("0.005", basicValue)) + assertEquals("28d", Formatter.formatTime("28", basicValue)) + assertEquals("90d", Formatter.formatTime("90", basicValue)) + assertEquals("90d 12h", Formatter.formatTime("90.5", basicValue)) + assertEquals("90d 7m 12s", Formatter.formatTime("90.005", basicValue)) + + // HOURS + basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0) + assertEquals("30m", Formatter.formatTime("0.5", basicValue)) + assertEquals("3m", Formatter.formatTime("0.05", basicValue)) + assertEquals("18s", Formatter.formatTime("0.005", basicValue)) + assertEquals("1d 4h", Formatter.formatTime("28", basicValue)) + assertEquals("3d 18h", Formatter.formatTime("90", basicValue)) + assertEquals("3d 18h 30m", Formatter.formatTime("90.5", basicValue)) + assertEquals("3d 18h 18s", Formatter.formatTime("90.005", basicValue)) + + // MINUTES + basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0) + assertEquals("30s", Formatter.formatTime("0.5", basicValue)) + assertEquals("3s", Formatter.formatTime("0.05", basicValue)) + assertEquals("300ms", Formatter.formatTime("0.005", basicValue)) + assertEquals("28m", Formatter.formatTime("28", basicValue)) + assertEquals("1h 30m", Formatter.formatTime("90", basicValue)) + assertEquals("1h 30m 30s", Formatter.formatTime("90.5", basicValue)) + assertEquals("1h 30m 300ms", Formatter.formatTime("90.005", basicValue)) + + // SECONDS + basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000) + assertEquals("500ms", Formatter.formatTime("0.5", basicValue)) + assertEquals("50ms", Formatter.formatTime("0.05", basicValue)) + assertEquals("5ms", Formatter.formatTime("0.005", basicValue)) + assertEquals("28s", Formatter.formatTime("28", basicValue)) + assertEquals("1m 30s", Formatter.formatTime("90", basicValue)) + assertEquals("1m 30s 500ms", Formatter.formatTime("90.5", basicValue)) + assertEquals("1m 30s 5ms", Formatter.formatTime("90.005", basicValue)) + + // MILLISECONDS + basicValue = BigDecimal.valueOf(1_000_000_000_000_000) + assertEquals("500µs", Formatter.formatTime("0.5", basicValue)) + assertEquals("50µs", Formatter.formatTime("0.05", basicValue)) + assertEquals("5µs", Formatter.formatTime("0.005", basicValue)) + assertEquals("28ms", Formatter.formatTime("28", basicValue)) + assertEquals("90ms", Formatter.formatTime("90", basicValue)) + assertEquals("90ms 500µs", Formatter.formatTime("90.5", basicValue)) + assertEquals("90ms 5µs", Formatter.formatTime("90.005", basicValue)) + + // MICROSECONDS + basicValue = BigDecimal.valueOf(1_000_000_000_000) + assertEquals("500ns", Formatter.formatTime("0.5", basicValue)) + assertEquals("50ns", Formatter.formatTime("0.05", basicValue)) + assertEquals("5ns", Formatter.formatTime("0.005", basicValue)) + assertEquals("28µs", Formatter.formatTime("28", basicValue)) + assertEquals("90µs", Formatter.formatTime("90", basicValue)) + assertEquals("90µs 500ns", Formatter.formatTime("90.5", basicValue)) + assertEquals("90µs 5ns", Formatter.formatTime("90.005", basicValue)) + + // NANOSECONDS + basicValue = BigDecimal.valueOf(1_000_000_000) + assertEquals("500 000 000as", Formatter.formatTime("0.5", basicValue)) + assertEquals("50 000 000as", Formatter.formatTime("0.05", basicValue)) + assertEquals("5 000 000as", Formatter.formatTime("0.005", basicValue)) + assertEquals("28ns", Formatter.formatTime("28", basicValue)) + assertEquals("90ns", Formatter.formatTime("90", basicValue)) + assertEquals("90ns 500 000 000as", Formatter.formatTime("90.5", basicValue)) + assertEquals("90ns 5 000 000as", Formatter.formatTime("90.005", basicValue)) + + // ATTOSECONDS + basicValue = BigDecimal.valueOf(1) + assertEquals("0.5", Formatter.formatTime("0.5", basicValue)) + assertEquals("0.05", Formatter.formatTime("0.05", basicValue)) + assertEquals("0.005", Formatter.formatTime("0.005", basicValue)) + assertEquals("28", Formatter.formatTime("28", basicValue)) + assertEquals("90", Formatter.formatTime("90", basicValue)) + assertEquals("90.5", Formatter.formatTime("90.5", basicValue)) + assertEquals("90.005", Formatter.formatTime("90.005", basicValue)) + } + } } \ No newline at end of file diff --git a/data/src/main/java/com/sadellie/unitto/data/FlowUtils.kt b/data/src/main/java/com/sadellie/unitto/data/FlowUtils.kt index 5c580253..0cdb156e 100644 --- a/data/src/main/java/com/sadellie/unitto/data/FlowUtils.kt +++ b/data/src/main/java/com/sadellie/unitto/data/FlowUtils.kt @@ -21,7 +21,7 @@ package com.sadellie.unitto.data import kotlinx.coroutines.flow.Flow @Suppress("UNCHECKED_CAST") -fun combine( +fun combine( flow: Flow, flow2: Flow, flow3: Flow, @@ -29,9 +29,10 @@ fun combine( flow5: Flow, flow6: Flow, flow7: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R + flow8: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R ): Flow = - kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> -> + kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> transform( args[0] as T1, args[1] as T2, @@ -40,5 +41,6 @@ fun combine( args[4] as T5, args[5] as T6, args[6] as T7, + args[7] as T8, ) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreen.kt index f0e48484..8ae6272e 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreen.kt @@ -83,6 +83,7 @@ internal fun MainScreen( processInput = { viewModel.processInput(it) }, deleteDigit = { viewModel.deleteDigit() }, clearInput = { viewModel.clearInput() }, + onOutputTextFieldClick = { viewModel.toggleFormatTime() } ) } ) @@ -108,6 +109,7 @@ private fun MainScreenContent( processInput: (String) -> Unit = {}, deleteDigit: () -> Unit = {}, clearInput: () -> Unit = {}, + onOutputTextFieldClick: () -> Unit ) { PortraitLandscape( modifier = modifier, @@ -125,6 +127,8 @@ private fun MainScreenContent( navigateToRightScreen = navigateToRightScreen, swapUnits = swapMeasurements, converterMode = mainScreenUIState.mode, + formatTime = mainScreenUIState.formatTime, + onOutputTextFieldClick = onOutputTextFieldClick ) }, content2 = { diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreenUIState.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreenUIState.kt index 3bcc541a..8658972b 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreenUIState.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainScreenUIState.kt @@ -33,6 +33,7 @@ import com.sadellie.unitto.data.units.AbstractUnit * @property unitFrom Unit on the left. * @property unitTo Unit on the right. * @property mode + * @property formatTime If true will format output when converting time. */ data class MainScreenUIState( val inputValue: String = KEY_0, @@ -43,6 +44,7 @@ data class MainScreenUIState( val unitFrom: AbstractUnit? = null, val unitTo: AbstractUnit? = null, val mode: ConverterMode = ConverterMode.DEFAULT, + val formatTime: Boolean = true ) enum class ConverterMode { diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainViewModel.kt index 5224e9ff..1bd3831e 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/MainViewModel.kt @@ -129,6 +129,8 @@ class MainViewModel @Inject constructor( */ private val _showError: MutableStateFlow = MutableStateFlow(false) + private val _formatTime: MutableStateFlow = MutableStateFlow(true) + /** * Current state of UI. */ @@ -139,8 +141,9 @@ class MainViewModel @Inject constructor( _calculated, _result, _showLoading, - _showError - ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue -> + _showError, + _formatTime + ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue, formatTime -> return@combine MainScreenUIState( inputValue = inputValue, calculatedValue = calculatedValue, @@ -153,7 +156,8 @@ class MainViewModel @Inject constructor( * If there will be more modes, this should be a separate value which we update when * changing units. */ - mode = if (_unitFrom.value is NumberBaseUnit) ConverterMode.BASE else ConverterMode.DEFAULT + mode = if (_unitFrom.value is NumberBaseUnit) ConverterMode.BASE else ConverterMode.DEFAULT, + formatTime = formatTime ) } .stateIn( @@ -389,6 +393,10 @@ class MainViewModel @Inject constructor( return _calculated.value ?: _input.value } + fun toggleFormatTime() { + _formatTime.update { !it } + } + private suspend fun convertInput() { // Loading don't do anything if ((_unitFrom.value == null) or (_unitTo.value == null)) return diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt index 02caee5b..237b6695 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt @@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.converter.components import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.data.units.AbstractUnit import com.sadellie.unitto.core.ui.R +import com.sadellie.unitto.data.units.UnitGroup import com.sadellie.unitto.feature.converter.ConverterMode /** @@ -62,6 +64,8 @@ import com.sadellie.unitto.feature.converter.ConverterMode * @param navigateToRightScreen Function that is called when clicking right unit selection button. * @param swapUnits Method to swap units. * @param converterMode [ConverterMode.BASE] doesn't use formatting for input/output. + * @param formatTime If True will use [Formatter.formatTime]. + * @param onOutputTextFieldClick Action to be called when user clicks on output text field. */ @Composable internal fun TopScreenPart( @@ -77,6 +81,8 @@ internal fun TopScreenPart( navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, swapUnits: () -> Unit, converterMode: ConverterMode, + formatTime: Boolean, + onOutputTextFieldClick: () -> Unit ) { var swapped by remember { mutableStateOf(false) } val swapButtonRotation: Float by animateFloatAsState( @@ -101,12 +107,20 @@ internal fun TopScreenPart( textToCopy = calculatedValue ?: inputValue, ) MyTextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOutputTextFieldClick), primaryText = { when { networkLoading -> stringResource(R.string.loading_label) networkError -> stringResource(R.string.error_label) converterMode == ConverterMode.BASE -> outputValue.uppercase() + formatTime and (unitTo?.group == UnitGroup.TIME) -> { + Formatter.formatTime( + input = calculatedValue ?: inputValue, + basicUnit = unitFrom?.basicUnit + ) + } else -> Formatter.format(outputValue) } }, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a173a15..79515e4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,8 @@ org-jetbrains-kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidxComposeUi" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxComposeUi" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxComposeUi" } +androidx-compose-ui-test-junit4 = { group= "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxCompose" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidxCompose" } androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycleRuntimeCompose" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" }