Added formatter for time

This commit is contained in:
Sad Ellie 2023-01-28 15:00:38 +04:00
parent d5cc855b3d
commit 542a95acf7
10 changed files with 214 additions and 7 deletions

View File

@ -37,6 +37,8 @@ class UnittoLibraryComposePlugin : Plugin<Project> {
"implementation"(libs.findLibrary("androidx.compose.material3").get()) "implementation"(libs.findLibrary("androidx.compose.material3").get())
"implementation"(libs.findLibrary("androidx.lifecycle.runtime.compose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.runtime.compose").get())
"implementation"(libs.findLibrary("androidx.compose.material.icons.extended").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())
} }
} }
} }

View File

@ -24,10 +24,20 @@ plugins {
android { android {
namespace = "com.sadellie.unitto.core.ui" namespace = "com.sadellie.unitto.core.ui"
// Workaround from https://github.com/robolectric/robolectric/pull/4736
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
dependencies { dependencies {
testImplementation(libs.junit) 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"))) implementation(project(mapOf("path" to ":core:base")))
} }

View File

@ -18,14 +18,20 @@
package com.sadellie.unitto.core.ui 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.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_COMMA
import com.sadellie.unitto.core.base.KEY_DOT import com.sadellie.unitto.core.base.KEY_DOT
import com.sadellie.unitto.core.base.KEY_E import com.sadellie.unitto.core.base.KEY_E
import com.sadellie.unitto.core.base.KEY_LEFT_BRACKET 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.KEY_RIGHT_BRACKET
import com.sadellie.unitto.core.base.OPERATORS import com.sadellie.unitto.core.base.OPERATORS
import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.base.Separator
import java.math.BigDecimal
import java.math.RoundingMode
object Formatter { object Formatter {
private const val SPACE = " " private const val SPACE = " "
@ -42,6 +48,19 @@ object Formatter {
*/ */
var fractional = KEY_COMMA 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]. * Change current separator to another [separator].
* *
@ -118,4 +137,42 @@ object Formatter {
return output 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()
}
} }

View File

@ -18,9 +18,14 @@
package com.sadellie.unitto.core.ui package com.sadellie.unitto.core.ui
import androidx.compose.ui.test.junit4.createComposeRule
import com.sadellie.unitto.core.base.Separator import com.sadellie.unitto.core.base.Separator
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.math.BigDecimal
private val formatter = Formatter private val formatter = Formatter
@ -33,8 +38,12 @@ private const val INCOMPLETE_EXPR = "50+123456÷8×0.812+"
private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)" private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)"
private const val SOME_BRACKETS = "((((((((" private const val SOME_BRACKETS = "(((((((("
@RunWith(RobolectricTestRunner::class)
class FormatterTest { class FormatterTest {
@get: Rule
val composeTestRule = createComposeRule()
@Test @Test
fun setSeparatorSpaces() { fun setSeparatorSpaces() {
formatter.setSeparator(Separator.SPACES) formatter.setSeparator(Separator.SPACES)
@ -77,4 +86,101 @@ class FormatterTest {
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) 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))
}
}
} }

View File

@ -21,7 +21,7 @@ package com.sadellie.unitto.data
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T1, T2, T3, T4, T5, T6, T7, R> combine( fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
flow: Flow<T1>, flow: Flow<T1>,
flow2: Flow<T2>, flow2: Flow<T2>,
flow3: Flow<T3>, flow3: Flow<T3>,
@ -29,9 +29,10 @@ fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow5: Flow<T5>, flow5: Flow<T5>,
flow6: Flow<T6>, flow6: Flow<T6>,
flow7: Flow<T7>, flow7: Flow<T7>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R flow8: Flow<T8>,
transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
): Flow<R> = ): Flow<R> =
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( transform(
args[0] as T1, args[0] as T1,
args[1] as T2, args[1] as T2,
@ -40,5 +41,6 @@ fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
args[4] as T5, args[4] as T5,
args[5] as T6, args[5] as T6,
args[6] as T7, args[6] as T7,
args[7] as T8,
) )
} }

View File

@ -83,6 +83,7 @@ internal fun MainScreen(
processInput = { viewModel.processInput(it) }, processInput = { viewModel.processInput(it) },
deleteDigit = { viewModel.deleteDigit() }, deleteDigit = { viewModel.deleteDigit() },
clearInput = { viewModel.clearInput() }, clearInput = { viewModel.clearInput() },
onOutputTextFieldClick = { viewModel.toggleFormatTime() }
) )
} }
) )
@ -108,6 +109,7 @@ private fun MainScreenContent(
processInput: (String) -> Unit = {}, processInput: (String) -> Unit = {},
deleteDigit: () -> Unit = {}, deleteDigit: () -> Unit = {},
clearInput: () -> Unit = {}, clearInput: () -> Unit = {},
onOutputTextFieldClick: () -> Unit
) { ) {
PortraitLandscape( PortraitLandscape(
modifier = modifier, modifier = modifier,
@ -125,6 +127,8 @@ private fun MainScreenContent(
navigateToRightScreen = navigateToRightScreen, navigateToRightScreen = navigateToRightScreen,
swapUnits = swapMeasurements, swapUnits = swapMeasurements,
converterMode = mainScreenUIState.mode, converterMode = mainScreenUIState.mode,
formatTime = mainScreenUIState.formatTime,
onOutputTextFieldClick = onOutputTextFieldClick
) )
}, },
content2 = { content2 = {

View File

@ -33,6 +33,7 @@ import com.sadellie.unitto.data.units.AbstractUnit
* @property unitFrom Unit on the left. * @property unitFrom Unit on the left.
* @property unitTo Unit on the right. * @property unitTo Unit on the right.
* @property mode * @property mode
* @property formatTime If true will format output when converting time.
*/ */
data class MainScreenUIState( data class MainScreenUIState(
val inputValue: String = KEY_0, val inputValue: String = KEY_0,
@ -43,6 +44,7 @@ data class MainScreenUIState(
val unitFrom: AbstractUnit? = null, val unitFrom: AbstractUnit? = null,
val unitTo: AbstractUnit? = null, val unitTo: AbstractUnit? = null,
val mode: ConverterMode = ConverterMode.DEFAULT, val mode: ConverterMode = ConverterMode.DEFAULT,
val formatTime: Boolean = true
) )
enum class ConverterMode { enum class ConverterMode {

View File

@ -129,6 +129,8 @@ class MainViewModel @Inject constructor(
*/ */
private val _showError: MutableStateFlow<Boolean> = MutableStateFlow(false) private val _showError: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val _formatTime: MutableStateFlow<Boolean> = MutableStateFlow(true)
/** /**
* Current state of UI. * Current state of UI.
*/ */
@ -139,8 +141,9 @@ class MainViewModel @Inject constructor(
_calculated, _calculated,
_result, _result,
_showLoading, _showLoading,
_showError _showError,
) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue -> _formatTime
) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue, formatTime ->
return@combine MainScreenUIState( return@combine MainScreenUIState(
inputValue = inputValue, inputValue = inputValue,
calculatedValue = calculatedValue, 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 * If there will be more modes, this should be a separate value which we update when
* changing units. * 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( .stateIn(
@ -389,6 +393,10 @@ class MainViewModel @Inject constructor(
return _calculated.value ?: _input.value return _calculated.value ?: _input.value
} }
fun toggleFormatTime() {
_formatTime.update { !it }
}
private suspend fun convertInput() { private suspend fun convertInput() {
// Loading don't do anything // Loading don't do anything
if ((_unitFrom.value == null) or (_unitTo.value == null)) return if ((_unitFrom.value == null) or (_unitTo.value == null)) return

View File

@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.converter.components
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.core.ui.Formatter
import com.sadellie.unitto.data.units.AbstractUnit import com.sadellie.unitto.data.units.AbstractUnit
import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.R
import com.sadellie.unitto.data.units.UnitGroup
import com.sadellie.unitto.feature.converter.ConverterMode 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 navigateToRightScreen Function that is called when clicking right unit selection button.
* @param swapUnits Method to swap units. * @param swapUnits Method to swap units.
* @param converterMode [ConverterMode.BASE] doesn't use formatting for input/output. * @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 @Composable
internal fun TopScreenPart( internal fun TopScreenPart(
@ -77,6 +81,8 @@ internal fun TopScreenPart(
navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit,
swapUnits: () -> Unit, swapUnits: () -> Unit,
converterMode: ConverterMode, converterMode: ConverterMode,
formatTime: Boolean,
onOutputTextFieldClick: () -> Unit
) { ) {
var swapped by remember { mutableStateOf(false) } var swapped by remember { mutableStateOf(false) }
val swapButtonRotation: Float by animateFloatAsState( val swapButtonRotation: Float by animateFloatAsState(
@ -101,12 +107,20 @@ internal fun TopScreenPart(
textToCopy = calculatedValue ?: inputValue, textToCopy = calculatedValue ?: inputValue,
) )
MyTextField( MyTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onOutputTextFieldClick),
primaryText = { primaryText = {
when { when {
networkLoading -> stringResource(R.string.loading_label) networkLoading -> stringResource(R.string.loading_label)
networkError -> stringResource(R.string.error_label) networkError -> stringResource(R.string.error_label)
converterMode == ConverterMode.BASE -> outputValue.uppercase() converterMode == ConverterMode.BASE -> outputValue.uppercase()
formatTime and (unitTo?.group == UnitGroup.TIME) -> {
Formatter.formatTime(
input = calculatedValue ?: inputValue,
basicUnit = unitFrom?.basicUnit
)
}
else -> Formatter.format(outputValue) else -> Formatter.format(outputValue)
} }
}, },

View File

@ -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 = { 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-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-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-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-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" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" }