diff --git a/core/base/src/main/res/font/lato_regular.ttf b/core/base/src/main/res/font/lato_regular.ttf index bb2e8875..74decd9e 100644 Binary files a/core/base/src/main/res/font/lato_regular.ttf and b/core/base/src/main/res/font/lato_regular.ttf differ diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt index 06490702..f2ada8cd 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier @@ -87,7 +88,7 @@ private val FontFamily.Companion.lato: FontFamily @Preview(widthDp = 480) @Composable -private fun PreviewTypography() { +private fun PreviewSystemTypography() { MaterialTheme( typography = TypographySystem ) { @@ -123,3 +124,30 @@ private fun PreviewTypography() { } } } + +@Preview(widthDp = 480) +@Composable +private fun PreviewNumberTypography() { + CompositionLocalProvider( + LocalNumberTypography provides NumberTypographyUnitto + ) { + val textStyles = mapOf( + "displayLarge" to LocalNumberTypography.current.displayLarge, + "displayMedium" to LocalNumberTypography.current.displayMedium, + "displaySmall" to LocalNumberTypography.current.displaySmall, + ) + + LazyColumn(Modifier.background(MaterialTheme.colorScheme.background)) { + + textStyles.forEach { (label, style) -> + item { + Text( + text = "$label 123 Error 7 1⁄2", + style = style, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + } +} diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt index 3bdcaf9f..ef30d655 100644 --- a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt @@ -43,7 +43,11 @@ sealed class ExpressionException(override val message: String): Exception(messag class TooBig : ExpressionException("Value is too big") } -class Expression(input: String, private val radianMode: Boolean = true) { +class Expression( + input: String, + private val radianMode: Boolean = true, + private val roundingMode: RoundingMode = RoundingMode.HALF_EVEN +) { private val tokens = Tokenizer(input).tokenize() private var cursorPosition = 0 @@ -104,7 +108,7 @@ class Expression(input: String, private val radianMode: Boolean = true) { val divisor = parseFactor() if (divisor.compareTo(BigDecimal.ZERO) == 0) throw ExpressionException.DivideByZero() - expression = expression.divide(divisor, RoundingMode.HALF_EVEN) + expression = expression.divide(divisor, roundingMode) } } } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt index 5b8d1d87..001f5ce9 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt @@ -43,9 +43,11 @@ internal sealed class CalculatorUIState { } sealed class CalculationResult { - data class Default(val text: String = "") : CalculationResult() + data class Default(val text: String) : CalculationResult() - data object Empty: CalculationResult() + data class Fraction(val text: String) : CalculationResult() + + data object Empty : CalculationResult() data object DivideByZeroError : CalculationResult() { @StringRes diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt index eda1af6d..d50e6698 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt @@ -40,6 +40,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sadellie.evaluatto.Expression import io.github.sadellie.evaluatto.ExpressionException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -48,6 +49,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.math.BigDecimal +import java.math.RoundingMode import javax.inject.Inject @HiltViewModel @@ -62,6 +64,7 @@ internal class CalculatorViewModel @Inject constructor( private val _equalClicked = MutableStateFlow(false) private val _prefs = userPrefsRepository.calculatorPrefs .stateIn(viewModelScope, null) + private var _fractionJob: Job? = null val uiState: StateFlow = combine( _input, @@ -123,6 +126,7 @@ internal class CalculatorViewModel @Inject constructor( } else { it.addTokens(tokens) } + _fractionJob?.cancel() savedStateHandle[_inputKey] = newValue.text newValue } @@ -134,6 +138,7 @@ internal class CalculatorViewModel @Inject constructor( } else { it.addBracket() } + _fractionJob?.cancel() savedStateHandle[_inputKey] = newValue.text newValue } @@ -145,12 +150,14 @@ internal class CalculatorViewModel @Inject constructor( } else { it.deleteTokens() } + _fractionJob?.cancel() savedStateHandle[_inputKey] = newValue.text newValue } fun clearInput() = _input.update { _equalClicked.update { false } + _fractionJob?.cancel() savedStateHandle[_inputKey] = "" TextFieldValue() } @@ -171,7 +178,7 @@ internal class CalculatorViewModel @Inject constructor( if (!_input.value.text.isExpression()) return@launch val result = try { - calculate(_input.value.text, prefs.radianMode) + calculate(_input.value.text, prefs.radianMode, RoundingMode.DOWN) } catch (e: ExpressionException.DivideByZero) { _equalClicked.update { true } _result.update { CalculationResult.DivideByZeroError } @@ -185,24 +192,36 @@ internal class CalculatorViewModel @Inject constructor( _result.update { CalculationResult.Error } return@launch } + + _equalClicked.update { true } + + val resultFormatted = result .setMinimumRequiredScale(prefs.precision) .trimZeros() .toStringWith(prefs.outputFormat) - calculatorHistoryRepository.add( - expression = _input.value.text.replace("-", Token.Operator.minus), - result = result - ) + withContext(Dispatchers.IO) { + calculatorHistoryRepository.add( + expression = _input.value.text.replace("-", Token.Operator.minus), + result = resultFormatted + ) + } - _input.update { TextFieldValue(result, TextRange(result.length)) } - _result.update { CalculationResult.Empty } + _fractionJob?.cancel() + _fractionJob = launch(Dispatchers.Default) { + val fraction = result.toFractionalString() + + _input.update { TextFieldValue(resultFormatted, TextRange(resultFormatted.length)) } + _result.update { CalculationResult.Fraction(fraction) } + } } private suspend fun calculate( input: String, radianMode: Boolean, + roundingMode: RoundingMode = RoundingMode.HALF_EVEN, ): BigDecimal = withContext(Dispatchers.Default) { - Expression(input, radianMode) + Expression(input, radianMode, roundingMode) .calculate() .also { if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/DecimalToFraction.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/DecimalToFraction.kt new file mode 100644 index 00000000..c89edcd4 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/DecimalToFraction.kt @@ -0,0 +1,105 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.calculator + +import com.sadellie.unitto.core.base.Token +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +fun BigDecimal.toFractionalString(): String { + // https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-1 + // https://www.khanacademy.org/math/cc-eighth-grade-math/cc-8th-numbers-operations/cc-8th-repeating-decimals/v/coverting-repeating-decimals-to-fractions-2 + val (integral, fractional) = this.divideAndRemainder(BigDecimal.ONE) + val integralBI = integral.toBigInteger() + + if (fractional.isEqualTo(BigDecimal.ZERO)) return integralBI.toString() + + val res: String = if (integral.isEqualTo(BigDecimal.ZERO)) "" else "$integralBI " + + val repeatingDecimals = fractional.repeatingDecimals() + + val (finalNumerator, finalDenominator) = if (repeatingDecimals == null) { + fractional.notRepeatingFractional() + } else { + fractional.repeatingFractional(repeatingDecimals.length) + } + + if (finalDenominator > maxDenominator) return "" + + return "$res$finalNumerator⁄$finalDenominator" +} + +private fun BigDecimal.notRepeatingFractional(): Pair { + val fractionalPrecision = BigInteger.TEN.pow(scale()) + + // 0.000123456 -> 123456 + val fractionalBI: BigInteger = (this * fractionalPrecision.toBigDecimal()).toBigInteger() + + val gcdVal = fractionalBI.gcd(fractionalPrecision) + val numerator = fractionalBI / gcdVal + val denominator = fractionalPrecision / gcdVal + + return numerator to denominator +} + +private fun BigDecimal.repeatingFractional( + repeatingLength: Int +): Pair { + val multiplier = BigInteger.TEN.pow(repeatingLength) + + val multiplied = (this * multiplier.toBigDecimal()).stripTrailingZeros() + + val numerator = (multiplied - this.setScale(multiplied.scale(), RoundingMode.DOWN)).stripTrailingZeros() + val denominator = multiplier - BigInteger.ONE + + // get rid of decimal in numerator + val bigIntegerMultiplies = BigDecimal.TEN.pow(scale()) + var finalNumerator = numerator.multiply(bigIntegerMultiplies).toBigInteger() + var finalDenominator = denominator.multiply(bigIntegerMultiplies.toBigInteger()) + + val gcd = finalNumerator.gcd(finalDenominator) + finalNumerator /= gcd + finalDenominator /= gcd + + return finalNumerator to finalDenominator +} + +private fun BigDecimal.repeatingDecimals(): String? { + val inputString = scaleByPowerOfTen(scale()).toBigInteger().toString() + + repeat(inputString.length) { index -> + val stringInFront = inputString.substring(index) + (1..stringInFront.length/2).forEach checkLoop@{ loop -> + val pattern = stringInFront.take(loop) + val checkRange = stringInFront.substring(0, stringInFront.length - stringInFront.length % pattern.length) + val checkChunks = checkRange.chunked(pattern.length) + val matched = checkChunks.all { it == pattern } + + if (matched) { + return if (pattern == Token.Digit._0) null else pattern + } + } + } + + return null +} + +private fun BigDecimal.isEqualTo(bd: BigDecimal): Boolean = compareTo(bd) == 0 +private val maxDenominator by lazy { BigInteger("1000000000") } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt index 64669cd4..d04b609d 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/TextBox.kt @@ -112,6 +112,23 @@ fun TextBox( ) } + is CalculationResult.Fraction -> { + var outputTF by remember(output) { + mutableStateOf(TextFieldValue(output.text)) + } + UnformattedTextField( + modifier = Modifier + .weight(2f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = outputTF, + minRatio = 1f, + onCursorChange = { outputTF = outputTF.copy(selection = it) }, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f), + readOnly = true, + ) + } + is CalculationResult.DivideByZeroError -> { UnformattedTextField( modifier = Modifier diff --git a/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/DecimalToFractionTest.kt b/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/DecimalToFractionTest.kt new file mode 100644 index 00000000..70da9372 --- /dev/null +++ b/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/DecimalToFractionTest.kt @@ -0,0 +1,84 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.feature.calculator + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal + +class DecimalToFractionTest { + @Test + fun testNoDecimal1() { + val bd = BigDecimal("100") + assertFractional("100", bd.toFractionalString()) + } + + @Test + fun testNoDecimal2() { + val bd = BigDecimal("100.000000000") + assertFractional("100", bd.toFractionalString()) + } + + @Test + fun testSimpleDecimal1() { + val bd = BigDecimal("0.25") + assertFractional("1/4", bd.toFractionalString()) + } + + @Test + fun testSimpleDecimal2() { + val bd = BigDecimal("100.25") + assertFractional("100 1/4", bd.toFractionalString()) + } + + @Test + fun testRepeating1() { + val bd = BigDecimal("0.666666666") + assertFractional("2/3", bd.toFractionalString()) + } + + @Test + fun testRepeating2() { + val bd = BigDecimal("4.666666666") + assertFractional("4 2/3", bd.toFractionalString()) + } + + @Test + fun testRepeating3() { + val bd = BigDecimal("0.78571428571428571428") + assertFractional("11/14", bd.toFractionalString()) + } + + @Test + fun testRepeating4() { + val bd = BigDecimal("66.78571428571428571428") + assertFractional("66 11/14", bd.toFractionalString()) + } + + @Test + fun testRepeating5() { + val bd = BigDecimal("0.666000") + assertFractional("333/500", bd.toFractionalString()) + } + + private fun assertFractional(expected: String, actual: String) = assertEquals( + expected, + actual.replace("⁄", "/") + ) +}