diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4b47310..2b930ffa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -111,7 +111,7 @@ dependencies { implementation(project(mapOf("path" to ":feature:calculator"))) implementation(project(mapOf("path" to ":feature:settings"))) implementation(project(mapOf("path" to ":feature:unitslist"))) - implementation(project(mapOf("path" to ":feature:epoch"))) + // implementation(project(mapOf("path" to ":feature:epoch"))) implementation(project(mapOf("path" to ":data:units"))) implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index 0de72cbd..edb57b7f 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -25,7 +25,6 @@ import androidx.navigation.compose.NavHost import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen import com.sadellie.unitto.feature.converter.ConverterViewModel import com.sadellie.unitto.feature.converter.navigation.converterScreen -import com.sadellie.unitto.feature.epoch.navigation.epochScreen import com.sadellie.unitto.feature.settings.SettingsViewModel import com.sadellie.unitto.feature.settings.navigation.navigateToSettings import com.sadellie.unitto.feature.settings.navigation.navigateToUnitGroups @@ -95,6 +94,6 @@ internal fun UnittoNavigation( navigateToSettings = ::navigateToSettings ) - epochScreen(navigateToMenu = openDrawer) + // epochScreen(navigateToMenu = openDrawer) } } diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt index 8ea12f19..dd20a0c8 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/Token.kt @@ -19,103 +19,126 @@ package com.sadellie.unitto.core.base object Token { - const val _1 = "1" - const val _2 = "2" - const val _3 = "3" - const val _4 = "4" - const val _5 = "5" - const val _6 = "6" - const val _7 = "7" - const val _8 = "8" - const val _9 = "9" - const val _0 = "0" + object Digit { + const val _1 = "1" + const val _2 = "2" + const val _3 = "3" + const val _4 = "4" + const val _5 = "5" + const val _6 = "6" + const val _7 = "7" + const val _8 = "8" + const val _9 = "9" + const val _0 = "0" + const val dot = "." - const val baseA = "A" - const val baseB = "B" - const val baseC = "C" - const val baseD = "D" - const val baseE = "E" - const val baseF = "F" - - const val dot = "." - const val comma = "," - const val E = "E" - - const val plus = "+" - const val minus = "-" - const val minusDisplay = "–" - - const val divide = "/" - const val divideDisplay = "÷" - - const val multiply = "*" - const val multiplyDisplay = "×" - - const val leftBracket = "(" - const val rightBracket = ")" - const val exponent = "^" - const val sqrt = "√" - const val pi = "π" - const val factorial = "!" - const val sin = "sin(" - const val arSin = "arsin(" - const val cos = "cos(" - const val arCos = "arcos(" - const val tan = "tan(" - const val acTan = "actan(" - const val e = "e" - const val exp = "exp(" - const val modulo = "#" - const val ln = "ln(" - const val log = "log(" - const val percent = "%" - - val operators by lazy { - listOf( - plus, - minus, - minusDisplay, - multiply, - multiplyDisplay, - divide, - divideDisplay, - sqrt, - exponent, - ) + val all by lazy { + listOf(_1, _2, _3, _4, _5, _6, _7, _8, _9, _0) + } + val allWithDot by lazy { all + dot } } - val digits by lazy { - listOf( - _1, - _2, - _3, - _4, - _5, - _6, - _7, - _8, - _9, - _0, - ) + object Letter { + const val _A = "A" + const val _B = "B" + const val _C = "C" + const val _D = "D" + const val _E = "E" + const val _F = "F" + + val all by lazy { + listOf(_A, _B, _C, _D, _E, _F) + } } - val internalToDisplay: Map = hashMapOf( - minus to minusDisplay, - multiply to multiplyDisplay, - divide to divideDisplay - ) + object Operator { + const val plus = "+" + const val minus = "−" + const val multiply = "×" + const val divide = "÷" + const val leftBracket = "(" + const val rightBracket = ")" + const val power = "^" + const val factorial = "!" + const val modulo = "#" + const val percent = "%" + const val sqrt = "√" - val knownSymbols: List by lazy { - listOf( - arSin, arCos, acTan, exp, - sin, cos, tan, ln, log, - leftBracket, rightBracket, - exponent, sqrt, factorial, - modulo, e, percent, pi, - multiply, multiplyDisplay, - plus, minus, minusDisplay, divide, divideDisplay, - baseA, baseB, baseC, baseD, baseE, baseF, - _1, _2, _3, _4, _5, _6, _7, _8, _9, _0 - ).sortedByDescending { it.length } + val all by lazy { + listOf( + plus, minus, multiply, divide, + leftBracket, rightBracket, + power, factorial, modulo, percent, sqrt, + ) + } + } + + object Func { + const val sin = "sin" + const val sinBracket = "$sin(" + const val cos = "cos" + const val cosBracket = "$cos(" + const val tan = "tan" + const val tanBracket = "$tan(" + const val arsin = "sin⁻¹" + const val arsinBracket = "$arsin(" + const val arcos = "cos⁻¹" + const val arcosBracket = "$arcos(" + const val actan = "tan⁻¹" + const val actanBracket = "$actan(" + const val ln = "ln" + const val lnBracket = "$ln(" + const val log = "log" + const val logBracket = "$log(" + const val exp = "exp" + const val expBracket = "$exp(" + + val all by lazy { + listOf( + arsin, arcos, actan, sin, cos, tan, log, exp, ln + ).sortedByDescending { it.length } + } + + val allWithOpeningBracket by lazy { + listOf( + arsinBracket, arcosBracket, actanBracket, sinBracket, cosBracket, tanBracket, + logBracket, expBracket, lnBracket + ) + } + } + + object Const { + const val pi = "π" + const val e = "e" + + val all by lazy { + listOf(pi, e) + } + } + + // Used only in formatter, don't use internally + object DisplayOnly { + const val comma = "," + const val engineeringE = "E" + const val minus = "−" + } + + val expressionTokens by lazy { + Digit.allWithDot + Operator.all + Func.all + Const.all + } + + val numberBaseTokens by lazy { + Digit.all + Letter.all + } + + val sexyToUgly by lazy { + mapOf( + Operator.minus to listOf("-", "–", "—", "—"), + Operator.divide to listOf("/"), + Operator.multiply to listOf("*", "•"), + Func.arsin to listOf("arsin"), + Func.arcos to listOf("arcos"), + Func.actan to listOf("actan") + ) } } diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index b444c12a..ed74eb43 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1270,6 +1270,7 @@ Clear history All expressions from history will be deleted forever. This action can\'t be undone! No history + Can\'t divide by 0 Number of decimal places 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 deleted file mode 100644 index 4311461b..00000000 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-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.core.ui - -import android.content.Context -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.base.Token -import java.math.BigDecimal -import java.math.RoundingMode - -// Legacy, LOL. Will change later -object Formatter : UnittoFormatter() - -open class UnittoFormatter { - /** - * This regex will catch things like "123.456", "123", ".456" - */ - private val numbersRegex = Regex("[\\d.]+") - - private val SPACE = " " - private val PERIOD = "." - private val COMMA = "," - - /** - * Grouping separator. - */ - var grouping: String = SPACE - - /** - * Fractional part separator. - */ - var fractional = Token.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]. - * - * @see [Separator] - */ - fun setSeparator(separator: Int) { - grouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - fractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - } - - /** - * Format [input]. - * - * This will replace operators to their more appealing variants: divide, multiply and minus. - * Plus operator remains unchanged. - * - * Numbers will also be formatted. - * - * @see [formatNumber] - */ - fun format(input: String): String { - // Don't do anything to engineering string. - if (input.contains(Token.E)) return input.replace(Token.dot, fractional) - - var output = input - val allNumbers: List = input.getOnlyNumbers() - - allNumbers.forEach { - output = output.replace(it, formatNumber(it)) - } - - Token.internalToDisplay.forEach { - output = output.replace(it.key, it.value) - } - - return output - } - - /** - * Reapply formatting. Reverses [format] and applies [format] again. - */ - fun reFormat(input: String): String { - // We get 123.45,6789 - // We need 12.345,6789 - - // 123.45,6789 - // Remove grouping - // 12345,6789 - // Replace fractional with "." because formatter accepts only numbers where fractional is a dot - return format( - input - .replace(grouping, "") - .replace(fractional, Token.dot) - ) - } - - /** - * Helper method to change formatting from [input] with a specified [separator] to the one that - * is set for this [UnittoFormatter]. - */ - fun fromSeparator(input: String, separator: Int): String { - val sGrouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - .also { if (it == grouping) return input } - val sFractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - - return input - .replace(sGrouping, "\t") - .replace(sFractional, fractional) - .replace("\t", grouping) - } - - fun toSeparator(input: String, separator: Int): String { - val output = filterUnknownSymbols(input).replace(fractional, Token.dot) - val sGrouping = when (separator) { - Separator.PERIOD -> PERIOD - Separator.COMMA -> COMMA - else -> SPACE - } - val sFractional = if (separator == Separator.PERIOD) Token.comma else Token.dot - - return format(output) - .replace(grouping, "\t") - .replace(fractional, sFractional) - .replace("\t", sGrouping) - } - - fun removeGrouping(input: String): String = input.replace(grouping, "") - - /** - * Takes [input] and [basicUnit] of the unit to format it to be more human readable. - * - * @return String like "1d 12h 12s". - */ - fun formatTime(context: Context, input: String, basicUnit: BigDecimal?): String { - if (basicUnit == null) return Token._0 - - try { - // Don't need magic if the input is zero - if (BigDecimal(input).compareTo(BigDecimal.ZERO) == 0) return Token._0 - } catch (e: NumberFormatException) { - // For case such as "10-" and "(" - return Token._0 - } - // Attoseconds don't need "magic" - if (basicUnit.compareTo(BigDecimal.ONE) == 0) return formatNumber(input) - - var result = if (input.startsWith(Token.minus)) Token.minus else "" - var remainingSeconds = BigDecimal(input) - .abs() - .multiply(basicUnit) - .setScale(0, RoundingMode.HALF_EVEN) - - if (remainingSeconds.compareTo(BigDecimal.ZERO) == 0) return Token._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())}${context.getString(timeStr)} " - } - } - return result.trimEnd() - } - - /** - * Format given [input]. - * - * Input must be a number with dot!!!. Will replace grouping separators and fractional part (dot) - * separators. - * - * @see grouping - * @see fractional - */ - private fun formatNumber(input: String): String { - if (input.any { it.isLetter() }) return input - - var firstPart = input.takeWhile { it != '.' } - val remainingPart = input.removePrefix(firstPart) - - // Number of empty symbols (spaces) we need to add to correctly split into chunks. - val offset = 3 - firstPart.length.mod(3) - val output = if (offset != 3) { - // We add some spaces at the beginning so that last chunk has 3 symbols - firstPart = " ".repeat(offset) + firstPart - firstPart.chunked(3).joinToString(grouping).drop(offset) - } else { - firstPart.chunked(3).joinToString(grouping) - } - - return (output + remainingPart.replace(".", fractional)) - } - - /** - * @receiver Must be a string with a dot (".") used as a fractional. - */ - private fun String.getOnlyNumbers(): List = - numbersRegex.findAll(this).map(MatchResult::value).toList() - - fun filterUnknownSymbols(input: String): String { - var clearStr = input.replace(" ", "") - var garbage = clearStr - - // String with unknown symbols - Token.knownSymbols.plus(fractional).forEach { - garbage = garbage.replace(it, " ") - } - - // Remove unknown symbols from input - garbage.split(" ").forEach { - clearStr = clearStr.replace(it, "") - } - - clearStr = clearStr - .replace(Token.divide, Token.divideDisplay) - .replace(Token.multiply, Token.multiplyDisplay) - .replace(Token.minus, Token.minusDisplay) - - return clearStr - } -} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt new file mode 100644 index 00000000..723cbb6b --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/CursorFixer.kt @@ -0,0 +1,73 @@ +/* + * 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.core.ui.common.textfield + +import com.sadellie.unitto.core.base.Token +import kotlin.math.abs + +fun String.fixCursor(pos: Int, grouping: String): Int { + + if (isEmpty()) return pos + + // Best position if we move cursor left + var leftCursor = pos + while (this.isPlacedIllegallyAt(leftCursor, grouping)) leftCursor-- + + // Best position if we move cursor right + var rightCursor = pos + while (this.isPlacedIllegallyAt(rightCursor, grouping)) rightCursor++ + + return listOf(leftCursor, rightCursor).minBy { abs(it - pos) } +} + +fun String.tokenLengthAhead(pos: Int): Int { + Token.Func.allWithOpeningBracket.forEach { + if (pos.isAfterToken(this, it)) return it.length + } + + return 1 +} + +private fun String.isPlacedIllegallyAt(pos: Int, grouping: String): Boolean { + // For things like "123,|456" - this is illegal + if (pos.isAfterToken(this, grouping)) return true + + // For things like "123,456+c|os(8)" - this is illegal + Token.Func.allWithOpeningBracket.forEach { + if (pos.isAtToken(this, it)) return true + } + + return false +} + +private fun Int.isAtToken(str: String, token: String): Boolean { + val checkBound = (token.length - 1).coerceAtLeast(1) + return str + .substring( + startIndex = (this - checkBound).coerceAtLeast(0), + endIndex = (this + checkBound).coerceAtMost(str.length) + ) + .contains(token) +} + +private fun Int.isAfterToken(str: String, token: String): Boolean { + return str + .substring((this - token.length).coerceAtLeast(0), this) + .contains(token) +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt new file mode 100644 index 00000000..eb59a8c6 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/ExpressionTransformer.kt @@ -0,0 +1,65 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.core.ui.common.textfield + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +class ExpressionTransformer(private val formatterSymbols: FormatterSymbols) : VisualTransformation { + + override fun filter(text: AnnotatedString): TransformedText { + val formatted = text.text.formatExpression(formatterSymbols) + return TransformedText( + text = AnnotatedString(formatted), + offsetMapping = ExpressionMapping(text.text, formatted) + ) + } + + inner class ExpressionMapping( + private val unformatted: String, + private val formatted: String + ) : OffsetMapping { + + // Called when entering text (on each text change) + // Basically moves cursor to the right position + // + // original input is "1000" and cursor is placed at the end "1000|" + // the formatted is "1,000" where cursor should be? - "1,000|" + override fun originalToTransformed(offset: Int): Int { + val fixedCursor = unformatted.fixCursor(offset, formatterSymbols.grouping) + return fixedCursor + countAddedSymbolsBeforeCursor(fixedCursor) + } + + // Called when clicking formatted text + // Snaps cursor to the right position + // + // the formatted is "1,000" and cursor is placed at the end "1,000|" + // original input is "1000" where cursor should be? - "1000|" + override fun transformedToOriginal(offset: Int): Int { + val fixedCursor = formatted.fixCursor(offset, formatterSymbols.grouping) + return fixedCursor - countAddedSymbolsBeforeCursor(fixedCursor) + } + + private fun countAddedSymbolsBeforeCursor(cursor: Int): Int { + return formatted.take(cursor).count { it.toString() == formatterSymbols.grouping } + } + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt new file mode 100644 index 00000000..c80d3529 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterExtensions.kt @@ -0,0 +1,193 @@ +/* + * 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.core.ui.common.textfield + +import android.content.Context +import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.R +import java.math.BigDecimal +import java.math.RoundingMode + +private val numbersRegex by lazy { Regex("[\\d.]+") } + +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"), + ) +} + +internal fun String.clearAndFilterExpression(formatterSymbols: FormatterSymbols): String { + var clean = this + .replace(formatterSymbols.grouping, "") + .replace(formatterSymbols.fractional, Token.Digit.dot) + .replace(" ", "") + + Token.sexyToUgly.forEach { (token, ugliness) -> + ugliness.forEach { + clean = clean.replace(it, token) + } + } + + return clean.cleanIt(Token.expressionTokens) +} + +internal fun String.clearAndFilterNumberBase(): String { + return uppercase().cleanIt(Token.numberBaseTokens) +} + +/** + * Format string time conversion result into a more readable format. + * + * @param basicUnit Basic unit of the unit we convert to + * @return String like "1d 12h 12s". + */ +fun String.formatTime( + context: Context, + basicUnit: BigDecimal?, + formatterSymbols: FormatterSymbols +): String { + // We get ugly version of input (non-fancy minus) + val input = this + + if (basicUnit == null) return Token.Digit._0 + + try { + // Don't need magic if the input is zero + if (BigDecimal(input).compareTo(BigDecimal.ZERO) == 0) return Token.Digit._0 + } catch (e: NumberFormatException) { + // For case such as "10-" and "(" + return Token.Digit._0 + } + // Attoseconds don't need "magic" + if (basicUnit.compareTo(BigDecimal.ONE) == 0) return input.formatExpression(formatterSymbols) + + var result = if (input.startsWith("-")) Token.Operator.minus else "" + var remainingSeconds = BigDecimal(input) + .abs() + .multiply(basicUnit) + .setScale(0, RoundingMode.HALF_EVEN) + + if (remainingSeconds.compareTo(BigDecimal.ZERO) == 0) return Token.Digit._0 + + timeDivisions.forEach { (timeStr, divider) -> + val division = remainingSeconds.divideAndRemainder(divider) + val time = division.component1() + remainingSeconds = division.component2() + if (time.compareTo(BigDecimal.ZERO) != 0) { + result += "${time.toPlainString().formatExpression(formatterSymbols)}${context.getString(timeStr)} " + } + } + return result.trimEnd() +} + +fun String.formatExpression( + formatterSymbols: FormatterSymbols +): String { + var input = this + // Don't do anything to engineering string. + if (input.contains(Token.DisplayOnly.engineeringE)) { + return input.replace(Token.Digit.dot, formatterSymbols.fractional) + } + + numbersRegex + .findAll(input) + .map(MatchResult::value) + .forEach { + input = input.replace(it, it.formatNumber(formatterSymbols)) + } + + Token.sexyToUgly.forEach { (token, ugliness) -> + ugliness.forEach { uglySymbol -> + input = input.replace(uglySymbol, token) + } + } + + return input +} + +private fun String.formatNumber( + formatterSymbols: FormatterSymbols +): String { + val input = this + + if (input.any { it.isLetter() }) return input + + var firstPart = input.takeWhile { it != '.' } + val remainingPart = input.removePrefix(firstPart) + + // Number of empty symbols (spaces) we need to add to correctly split into chunks. + val offset = 3 - firstPart.length.mod(3) + val output = if (offset != 3) { + // We add some spaces at the beginning so that last chunk has 3 symbols + firstPart = " ".repeat(offset) + firstPart + firstPart.chunked(3).joinToString(formatterSymbols.grouping).drop(offset) + } else { + firstPart.chunked(3).joinToString(formatterSymbols.grouping) + } + + return output.plus(remainingPart.replace(".", formatterSymbols.fractional)) +} + +private fun String.cleanIt(legalTokens: List): String { + val streamOfTokens = this + + fun peekTokenAfter(cursor: Int): String? { + legalTokens.forEach { token -> + val subs = streamOfTokens + .substring( + cursor, + (cursor + token.length).coerceAtMost(streamOfTokens.length) + ) + if (subs == token) { + // Got a digit, see if there are other digits coming after + if (token in Token.Digit.allWithDot) { + return streamOfTokens + .substring(cursor) + .takeWhile { Token.Digit.allWithDot.contains(it.toString()) } + } + return token + } + } + return null + } + + var cursor = 0 + var tokens = "" + + while (cursor != streamOfTokens.length) { + val nextToken = peekTokenAfter(cursor) + + if (nextToken != null) { + tokens += nextToken + cursor += nextToken.length + } else { + // Didn't find any token, move left slowly (by 1 symbol) + cursor++ + } + } + + return tokens +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt new file mode 100644 index 00000000..e8509dad --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/FormatterSymbols.kt @@ -0,0 +1,46 @@ +/* + * 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.core.ui.common.textfield + +import com.sadellie.unitto.core.base.Separator + +sealed class FormatterSymbols(val grouping: String, val fractional: String) { + object Spaces : FormatterSymbols(" ", ".") + object Period : FormatterSymbols(".", ",") + object Comma : FormatterSymbols(",", ".") +} + +object AllFormatterSymbols { + private val allFormatterSymbs by lazy { + hashMapOf( + Separator.SPACES to FormatterSymbols.Spaces, + Separator.PERIOD to FormatterSymbols.Period, + Separator.COMMA to FormatterSymbols.Comma + ) + } + + /** + * Defaults to [FormatterSymbols.Spaces] if not found. + * + * @see Separator + */ + fun getById(separator: Int): FormatterSymbols { + return allFormatterSymbs.getOrElse(separator) { FormatterSymbols.Spaces } + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt index 7ef702a6..c91470b7 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/InputTextField.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -54,105 +55,124 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.createFontFamilyResolver import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge import kotlin.math.ceil import kotlin.math.roundToInt @Composable -fun InputTextField( +fun ExpressionTextField( modifier: Modifier, value: TextFieldValue, - textStyle: TextStyle = NumbersTextStyleDisplayLarge, minRatio: Float = 1f, - cutCallback: () -> Unit, - pasteCallback: (String) -> Unit, - onCursorChange: (IntRange) -> Unit, + cutCallback: () -> Unit = {}, + pasteCallback: (String) -> Unit = {}, + onCursorChange: (TextRange) -> Unit, textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + formatterSymbols: FormatterSymbols, + readOnly: Boolean = false, + placeholder: String? = null, ) { - val clipboardManager = LocalClipboardManager.current - fun copyCallback() = clipboardManager.copyWithoutGrouping(value) - - val textToolbar = UnittoTextToolbar( - view = LocalView.current, - copyCallback = ::copyCallback, - pasteCallback = { - pasteCallback( - Formatter.toSeparator( - clipboardManager.getText()?.text ?: "", Separator.COMMA - ) - ) - }, - cutCallback = { - copyCallback() - cutCallback() - onCursorChange(value.selection.end..value.selection.end) - } - ) - - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides textToolbar - ) { - AutoSizableTextField( - modifier = modifier, - value = value, - textStyle = textStyle.copy(color = textColor), - minRatio = minRatio, - onValueChange = { - onCursorChange(it.selection.start..it.selection.end) - }, - showToolbar = textToolbar::showMenu, - hideToolbar = textToolbar::hide - ) - } -} - -@Composable -fun InputTextField( - modifier: Modifier = Modifier, - value: String, - textStyle: TextStyle = NumbersTextStyleDisplayLarge, - minRatio: Float = 1f, - textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - var textFieldValue by remember(value) { - mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) - } val clipboardManager = LocalClipboardManager.current fun copyCallback() { - clipboardManager.copyWithoutGrouping(textFieldValue) - textFieldValue = textFieldValue.copy(selection = TextRange(textFieldValue.selection.end)) + clipboardManager.copyWithoutGrouping(value, formatterSymbols) + onCursorChange(TextRange(value.selection.end)) } - CompositionLocalProvider( - LocalTextInputService provides null, - LocalTextToolbar provides UnittoTextToolbar( + val textToolbar: UnittoTextToolbar = if (readOnly) { + UnittoTextToolbar( view = LocalView.current, copyCallback = ::copyCallback, ) - ) { - AutoSizableTextField( - modifier = modifier, - value = textFieldValue, - onValueChange = { textFieldValue = it }, - textStyle = textStyle.copy(color = textColor), - minRatio = minRatio, - readOnly = true, - interactionSource = interactionSource + } else { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + pasteCallback = { + pasteCallback(clipboardManager.getText()?.text?.clearAndFilterExpression(formatterSymbols) ?: "") + }, + cutCallback = { + clipboardManager.copyWithoutGrouping(value, formatterSymbols) + cutCallback() + } ) } + + AutoSizableTextField( + modifier = modifier, + value = value, + formattedValue = value.text.formatExpression(formatterSymbols), + textStyle = NumbersTextStyleDisplayLarge.copy(color = textColor), + minRatio = minRatio, + onValueChange = { onCursorChange(it.selection) }, + readOnly = readOnly, + showToolbar = textToolbar::showMenu, + hideToolbar = textToolbar::hide, + visualTransformation = ExpressionTransformer(formatterSymbols), + placeholder = placeholder, + textToolbar = textToolbar + ) +} + +@Composable +fun UnformattedTextField( + modifier: Modifier, + value: TextFieldValue, + minRatio: Float = 1f, + cutCallback: () -> Unit = {}, + pasteCallback: (String) -> Unit = {}, + onCursorChange: (TextRange) -> Unit, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + readOnly: Boolean = false, + placeholder: String? = null, +) { + val clipboardManager = LocalClipboardManager.current + fun copyCallback() { + clipboardManager.copy(value) + onCursorChange(TextRange(value.selection.end)) + } + + val textToolbar: UnittoTextToolbar = if (readOnly) { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + ) + } else { + UnittoTextToolbar( + view = LocalView.current, + copyCallback = ::copyCallback, + pasteCallback = { + pasteCallback(clipboardManager.getText()?.text?.clearAndFilterNumberBase() ?: "") + }, + cutCallback = { + clipboardManager.copy(value) + cutCallback() + } + ) + } + + AutoSizableTextField( + modifier = modifier, + value = value, + textStyle = NumbersTextStyleDisplayLarge.copy(color = textColor), + minRatio = minRatio, + onValueChange = { onCursorChange(it.selection) }, + readOnly = readOnly, + showToolbar = textToolbar::showMenu, + hideToolbar = textToolbar::hide, + placeholder = placeholder, + textToolbar = textToolbar + ) } @Composable private fun AutoSizableTextField( modifier: Modifier = Modifier, value: TextFieldValue, + formattedValue: String = value.text, textStyle: TextStyle = TextStyle(), scaleFactor: Float = 0.95f, minRatio: Float = 1f, @@ -160,11 +180,14 @@ private fun AutoSizableTextField( readOnly: Boolean = false, showToolbar: (rect: Rect) -> Unit = {}, hideToolbar: () -> Unit = {}, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + visualTransformation: VisualTransformation = VisualTransformation.None, + placeholder: String? = null, + textToolbar: UnittoTextToolbar ) { val focusRequester = remember { FocusRequester() } val density = LocalDensity.current + val textValue = value.copy(value.text.take(2000)) var nFontSize: TextUnit by remember { mutableStateOf(0.sp) } var minFontSize: TextUnit @@ -181,7 +204,7 @@ private fun AutoSizableTextField( // Modified: https://blog.canopas.com/autosizing-textfield-in-jetpack-compose-7a80f0270853 val calculateParagraph = @Composable { Paragraph( - text = value.text, + text = formattedValue, style = textStyle.copy(fontSize = nFontSize), constraints = Constraints( maxWidth = ceil(with(density) { maxWidth.toPx() }).toInt() @@ -210,45 +233,70 @@ private fun AutoSizableTextField( ) var offset = Offset.Zero - BasicTextField( - value = value, - onValueChange = { - showToolbar(Rect(offset, 0f)) - hideToolbar() - onValueChange(it) - }, - modifier = Modifier - .focusRequester(focusRequester) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { - hideToolbar() - focusRequester.requestFocus() - onValueChange(value.copy(selection = TextRange.Zero)) - showToolbar(Rect(offset, 0f)) + CompositionLocalProvider( + LocalTextInputService provides null, + LocalTextToolbar provides textToolbar + ) { + BasicTextField( + value = textValue, + onValueChange = { + showToolbar(Rect(offset, 0f)) + hideToolbar() + onValueChange(it) + }, + modifier = Modifier + .focusRequester(focusRequester) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + hideToolbar() + focusRequester.requestFocus() + onValueChange(value.copy(selection = TextRange.Zero)) + showToolbar(Rect(offset, 0f)) + } + ) + .widthIn(max = with(density) { intrinsics.width.toDp() }) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + // TextField size is changed with a delay (text jumps). Here we correct it. + layout(placeable.width, placeable.height) { + placeable.place( + x = (intrinsics.width - intrinsics.maxIntrinsicWidth) + .coerceAtLeast(0f) + .roundToInt(), + y = (placeable.height - intrinsics.height).roundToInt() + ) + } } - ) - .widthIn(max = with(density) { intrinsics.width.toDp() }) - .layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - // TextField size is changed with a delay (text jumps). Here we correct it. - layout(placeable.width, placeable.height) { - placeable.place( - x = (intrinsics.width - intrinsics.maxIntrinsicWidth) - .coerceAtLeast(0f) - .roundToInt(), - y = (placeable.height - intrinsics.height).roundToInt() + .onGloballyPositioned { layoutCoords -> + offset = layoutCoords.positionInWindow() + }, + textStyle = nTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), + singleLine = true, + readOnly = readOnly, + visualTransformation = visualTransformation, + decorationBox = { innerTextField -> + if (textValue.text.isEmpty() and !placeholder.isNullOrEmpty()) { + Text( + text = placeholder!!, // It's not null, i swear + style = nTextStyle, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + layout(placeable.width, placeable.height) { + placeable.place(x = -placeable.width, y = 0) + } + } ) } + + innerTextField() } - .onGloballyPositioned { layoutCoords -> offset = layoutCoords.positionInWindow() }, - textStyle = nTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), - singleLine = true, - readOnly = readOnly, - interactionSource = interactionSource - ) + ) + } } } @@ -260,8 +308,22 @@ private fun AutoSizableTextField( * * @param value Formatted value that has grouping symbols. */ -fun ClipboardManager.copyWithoutGrouping(value: TextFieldValue) = this.setText( +fun ClipboardManager.copyWithoutGrouping( + value: TextFieldValue, + formatterSymbols: FormatterSymbols +) = this.setText( AnnotatedString( - Formatter.removeGrouping(value.annotatedString.subSequence(value.selection).text) + value.annotatedString + .subSequence(value.selection) + .text + .replace(formatterSymbols.grouping, "") + ) +) + +fun ClipboardManager.copy(value: TextFieldValue) = this.setText( + AnnotatedString( + value.annotatedString + .subSequence(value.selection) + .text ) ) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt new file mode 100644 index 00000000..5e48fd28 --- /dev/null +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/textfield/TextFieldValueExtensions.kt @@ -0,0 +1,59 @@ +/* + * 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.core.ui.common.textfield + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue + +fun TextFieldValue.addTokens(tokens: String): TextFieldValue { + return this.copy( + text = text.replaceRange(selection.start, selection.end, tokens), + selection = TextRange(selection.start + tokens.length) + ) +} + +fun TextFieldValue.deleteTokens(): TextFieldValue { + val distanceFromEnd = text.length - selection.end + + val deleteRangeStart = when (selection.end) { + // Don't delete if at the start of the text field + 0 -> return this + // We don't have anything selected (cursor in one position) + // like this 1234|56 => after deleting will be like this 123|56 + // Cursor moved one symbol left + selection.start -> { + // We default to 1 here. It means that cursor is not placed after illegal token + // Just a number or a binary operator or something else, can delete by one symbol + val symbolsToDelete = text.tokenLengthAhead(selection.end) + + selection.start - symbolsToDelete + } + // We have multiple symbols selected + // like this 123[45]6 => after deleting will be like this 123|6 + // Cursor will be placed where selection start was + else -> selection.start + } + + val newText = text.removeRange(deleteRangeStart, selection.end) + + return this.copy( + text = newText, + selection = TextRange((newText.length - distanceFromEnd).coerceAtLeast(0)) + ) +} 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 cf8cc458..28c87f7c 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 @@ -20,7 +20,9 @@ package com.sadellie.unitto.core.ui import android.content.Context import androidx.compose.ui.test.junit4.createComposeRule -import com.sadellie.unitto.core.base.Separator +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.core.ui.common.textfield.formatTime import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -29,16 +31,14 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import java.math.BigDecimal -private val formatter = Formatter - private const val ENG_VALUE = "123E+21" private const val ENG_VALUE_FRACTIONAL = "123.3E+21" private const val COMPLETE_VALUE = "123456.789" private const val INCOMPLETE_VALUE = "123456." private const val NO_FRACTIONAL_VALUE = "123456" -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 LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8–12+0-√9*4^9+2×(9+8×7)×sin(13sin123cos" +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 LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8-12+0-√9×4^9+2×(9+8×7)×sin(13sin123cos" private const val SOME_BRACKETS = "((((((((" @RunWith(RobolectricTestRunner::class) @@ -49,167 +49,143 @@ class FormatterTest { @Test fun setSeparatorSpaces() { - formatter.setSeparator(Separator.SPACES) - assertEquals(".", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123 456.789", formatter.format(COMPLETE_VALUE)) - assertEquals("123 456.", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123 456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123 456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123 456÷89 078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Spaces) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123 456.789", COMPLETE_VALUE.format()) + assertEquals("123 456.", INCOMPLETE_VALUE.format()) + assertEquals("123 456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123 456÷8×0.8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123 456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123 456÷89 078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun setSeparatorComma() { - formatter.setSeparator(Separator.COMMA) - assertEquals(".", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123,456.789", formatter.format(COMPLETE_VALUE)) - assertEquals("123,456.", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123,456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123,456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123,456÷89,078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Comma) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123,456.789", COMPLETE_VALUE.format()) + assertEquals("123,456.", INCOMPLETE_VALUE.format()) + assertEquals("123,456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123,456÷8×0.8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123,456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123,456÷89,078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun setSeparatorPeriod() { - formatter.setSeparator(Separator.PERIOD) - assertEquals(",", formatter.fractional) - assertEquals("123E+21", formatter.format(ENG_VALUE)) - assertEquals("123,3E+21", formatter.format(ENG_VALUE_FRACTIONAL)) - assertEquals("123.456,789", formatter.format(COMPLETE_VALUE)) - assertEquals("123.456,", formatter.format(INCOMPLETE_VALUE)) - assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE)) - assertEquals("50+123.456÷8×0,8–12+", formatter.format(INCOMPLETE_EXPR)) - assertEquals("50+123.456÷8×0,8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) - assertEquals("50+123.456÷89.078,,9×0,8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR)) - assertEquals("((((((((", formatter.format(SOME_BRACKETS)) + fun String.format(): String = formatExpression(FormatterSymbols.Period) + assertEquals("123E+21", ENG_VALUE.format()) + assertEquals("123,3E+21", ENG_VALUE_FRACTIONAL.format()) + assertEquals("123.456,789", COMPLETE_VALUE.format()) + assertEquals("123.456,", INCOMPLETE_VALUE.format()) + assertEquals("123.456", NO_FRACTIONAL_VALUE.format()) + assertEquals("50+123.456÷8×0,8−12+", INCOMPLETE_EXPR.format()) + assertEquals("50+123.456÷8×0,8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format()) + assertEquals("50+123.456÷89.078,,9×0,8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format()) + assertEquals("((((((((", SOME_BRACKETS.format()) } @Test fun formatTimeTest() { - formatter.setSeparator(Separator.SPACES) + val formatterSymbols = FormatterSymbols.Spaces var basicValue = BigDecimal.valueOf(1) val mContext: Context = RuntimeEnvironment.getApplication().applicationContext - assertEquals("-28", formatter.formatTime(mContext, "-28", basicValue)) - assertEquals("-0.05", formatter.formatTime(mContext, "-0.05", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "0", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "-0", basicValue)) + + fun String.formatTime() = this.formatTime(mContext, basicValue, formatterSymbols) + + assertEquals("−28", "-28".formatTime()) + assertEquals("−0.05", "-0.05".formatTime()) + assertEquals("0", "0".formatTime()) + assertEquals("0", "−0".formatTime()) basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) - assertEquals("-28d", formatter.formatTime(mContext, "-28", basicValue)) - assertEquals("-1h 12m", formatter.formatTime(mContext, "-0.05", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "0", basicValue)) - assertEquals("0", formatter.formatTime(mContext, "-0", basicValue)) + assertEquals("−28d", "-28".formatTime()) + assertEquals("−1h 12m", "-0.05".formatTime()) + assertEquals("0", "0".formatTime()) + assertEquals("0", "-0".formatTime()) // DAYS basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) - assertEquals("12h", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("1h 12m", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("7m 12s", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28d", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90d", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90d 12h", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90d 7m 12s", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("12h","0.5".formatTime()) + assertEquals("1h 12m","0.05".formatTime()) + assertEquals("7m 12s","0.005".formatTime()) + assertEquals("28d","28".formatTime()) + assertEquals("90d","90".formatTime()) + assertEquals("90d 12h","90.5".formatTime()) + assertEquals("90d 7m 12s","90.005".formatTime()) // HOURS basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0) - assertEquals("30m", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("3m", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("18s", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("1d 4h", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("3d 18h", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("3d 18h 30m", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("3d 18h 18s", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("30m", "0.5".formatTime()) + assertEquals("3m", "0.05".formatTime()) + assertEquals("18s", "0.005".formatTime()) + assertEquals("1d 4h", "28".formatTime()) + assertEquals("3d 18h", "90".formatTime()) + assertEquals("3d 18h 30m", "90.5".formatTime()) + assertEquals("3d 18h 18s", "90.005".formatTime()) // MINUTES basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0) - assertEquals("30s", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("3s", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("300ms", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28m", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("1h 30m", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("1h 30m 30s", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("1h 30m 300ms", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("30s", "0.5".formatTime()) + assertEquals("3s", "0.05".formatTime()) + assertEquals("300ms", "0.005".formatTime()) + assertEquals("28m", "28".formatTime()) + assertEquals("1h 30m", "90".formatTime()) + assertEquals("1h 30m 30s", "90.5".formatTime()) + assertEquals("1h 30m 300ms", "90.005".formatTime()) // SECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000) - assertEquals("500ms", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50ms", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5ms", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28s", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("1m 30s", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("1m 30s 500ms", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("1m 30s 5ms", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500ms", "0.5".formatTime()) + assertEquals("50ms", "0.05".formatTime()) + assertEquals("5ms", "0.005".formatTime()) + assertEquals("28s", "28".formatTime()) + assertEquals("1m 30s", "90".formatTime()) + assertEquals("1m 30s 500ms", "90.5".formatTime()) + assertEquals("1m 30s 5ms", "90.005".formatTime()) // MILLISECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000_000) - assertEquals("500µs", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50µs", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5µs", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28ms", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90ms", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90ms 500µs", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90ms 5µs", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500µs", "0.5".formatTime()) + assertEquals("50µs", "0.05".formatTime()) + assertEquals("5µs", "0.005".formatTime()) + assertEquals("28ms", "28".formatTime()) + assertEquals("90ms", "90".formatTime()) + assertEquals("90ms 500µs", "90.5".formatTime()) + assertEquals("90ms 5µs", "90.005".formatTime()) // MICROSECONDS basicValue = BigDecimal.valueOf(1_000_000_000_000) - assertEquals("500ns", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50ns", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5ns", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28µs", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90µs", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90µs 500ns", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90µs 5ns", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500ns", "0.5".formatTime()) + assertEquals("50ns", "0.05".formatTime()) + assertEquals("5ns", "0.005".formatTime()) + assertEquals("28µs", "28".formatTime()) + assertEquals("90µs", "90".formatTime()) + assertEquals("90µs 500ns", "90.5".formatTime()) + assertEquals("90µs 5ns", "90.005".formatTime()) // NANOSECONDS basicValue = BigDecimal.valueOf(1_000_000_000) - assertEquals("500 000 000as", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("50 000 000as", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("5 000 000as", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28ns", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90ns", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90ns 500 000 000as", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90ns 5 000 000as", formatter.formatTime(mContext, "90.005", basicValue)) + assertEquals("500 000 000as", "0.5".formatTime()) + assertEquals("50 000 000as", "0.05".formatTime()) + assertEquals("5 000 000as", "0.005".formatTime()) + assertEquals("28ns", "28".formatTime()) + assertEquals("90ns", "90".formatTime()) + assertEquals("90ns 500 000 000as", "90.5".formatTime()) + assertEquals("90ns 5 000 000as", "90.005".formatTime()) // ATTOSECONDS basicValue = BigDecimal.valueOf(1) - assertEquals("0.5", formatter.formatTime(mContext, "0.5", basicValue)) - assertEquals("0.05", formatter.formatTime(mContext, "0.05", basicValue)) - assertEquals("0.005", formatter.formatTime(mContext, "0.005", basicValue)) - assertEquals("28", formatter.formatTime(mContext, "28", basicValue)) - assertEquals("90", formatter.formatTime(mContext, "90", basicValue)) - assertEquals("90.5", formatter.formatTime(mContext, "90.5", basicValue)) - assertEquals("90.005", formatter.formatTime(mContext, "90.005", basicValue)) - } - - @Test - fun fromSeparatorToSpacesTest() { - formatter.setSeparator(Separator.SPACES) - assertEquals("123 456.789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123 456.789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123 456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) - } - - @Test - fun fromSeparatorToPeriodTest() { - formatter.setSeparator(Separator.PERIOD) - assertEquals("123.456,789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123.456,789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123.456,789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) - } - - @Test - fun fromSeparatorToCommaTest() { - formatter.setSeparator(Separator.COMMA) - assertEquals("123,456.789", formatter.fromSeparator("123,456.789", Separator.COMMA)) - assertEquals("123,456.789", formatter.fromSeparator("123 456.789", Separator.SPACES)) - assertEquals("123,456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD)) + assertEquals("0.5", "0.5".formatTime()) + assertEquals("0.05", "0.05".formatTime()) + assertEquals("0.005", "0.005".formatTime()) + assertEquals("28", "28".formatTime()) + assertEquals("90", "90".formatTime()) + assertEquals("90.5", "90.5".formatTime()) + assertEquals("90.005", "90.005".formatTime()) } } \ No newline at end of file diff --git a/data/common/build.gradle.kts b/data/common/build.gradle.kts index ae906d33..6c1f9cf4 100644 --- a/data/common/build.gradle.kts +++ b/data/common/build.gradle.kts @@ -26,4 +26,5 @@ android { dependencies { implementation(project(mapOf("path" to ":core:base"))) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt b/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt index 16843cf9..0400b469 100644 --- a/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt +++ b/data/common/src/main/java/com/sadellie/unitto/data/common/BigDecimalUtils.kt @@ -64,11 +64,7 @@ fun BigDecimal.setMinimumRequiredScale(prefScale: Int): BigDecimal { /** * Removes all trailing zeroes. - * - * @throws NumberFormatException if value is bigger than [Double.MAX_VALUE] to avoid memory overflow. */ fun BigDecimal.trimZeros(): BigDecimal { - if (this.abs() > BigDecimal.valueOf(Double.MAX_VALUE)) throw NumberFormatException() - return if (this.compareTo(BigDecimal.ZERO) == 0) BigDecimal.ZERO else this.stripTrailingZeros() } diff --git a/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt b/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt index 7e52fd33..dea0e339 100644 --- a/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt +++ b/data/common/src/main/java/com/sadellie/unitto/data/common/StringUtils.kt @@ -18,6 +18,8 @@ package com.sadellie.unitto.data.common +import com.sadellie.unitto.core.base.Token + /** * Compute Levenshtein Distance between this string and [secondString]. Doesn't matter which string is * first. @@ -58,3 +60,18 @@ fun String.lev(secondString: String): Int { return cost[this.length] } + +fun String.isExpression(): Boolean { + + if (isEmpty()) return false + + // Positive numbers and zero + if (all { it.toString() in Token.Digit.allWithDot }) return false + + // Negative numbers + // Needs to start with an negative + if (this.first().toString() != Token.Operator.minus) return true + + // Rest of the string must be just like positive + return this.drop(1).isExpression() +} diff --git a/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt new file mode 100644 index 00000000..2f581428 --- /dev/null +++ b/data/common/src/test/java/com/sadellie/unitto/data/common/IsExpressionText.kt @@ -0,0 +1,49 @@ +/* + * Unitto is a unit converter for Android + * Copyright (c) 2023 Elshan Agaev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.sadellie.unitto.data.common + +import org.junit.Assert.assertEquals +import org.junit.Test + + +class IsExpressionText { + @Test + fun `empty string`() = assertEquals(false, "".isExpression()) + + @Test + fun `positive real number`() = assertEquals(false, "123".isExpression()) + + @Test + fun `positive float`() = assertEquals(false, "123.123".isExpression()) + + @Test + fun `negative real`() = assertEquals(false, "−123".isExpression()) + + @Test + fun `negative float`() = assertEquals(false, "−123.123".isExpression()) + + @Test + fun `super negative float`() = assertEquals(false, "−−123.123".isExpression()) + + @Test + fun expression1() = assertEquals(true, "123.123+456".isExpression()) + + @Test + fun expression2() = assertEquals(true, "−123.123+456".isExpression()) +} diff --git a/data/evaluatto/.gitignore b/data/evaluatto/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/data/evaluatto/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/evaluatto/build.gradle.kts b/data/evaluatto/build.gradle.kts new file mode 100644 index 00000000..30a2557b --- /dev/null +++ b/data/evaluatto/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * 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 . + */ + +plugins { + id("unitto.library") +} + +android { + // Different namespace. Possible promotion to a separate project. + namespace = "io.github.sadellie.evaluatto" +} + +dependencies { + implementation(project(mapOf("path" to ":core:base"))) + testImplementation(libs.junit) +} diff --git a/data/evaluatto/consumer-rules.pro b/data/evaluatto/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/data/evaluatto/src/main/AndroidManifest.xml b/data/evaluatto/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7bdbce91 --- /dev/null +++ b/data/evaluatto/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file 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 new file mode 100644 index 00000000..316c07d2 --- /dev/null +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Expression.kt @@ -0,0 +1,311 @@ +/* + * 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 io.github.sadellie.evaluatto + +import com.sadellie.unitto.core.base.MAX_PRECISION +import com.sadellie.unitto.core.base.Token +import java.math.BigDecimal +import java.math.MathContext +import java.math.RoundingMode +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tan +import kotlin.math.acos +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.ln +import kotlin.math.log +import kotlin.math.exp +import kotlin.math.pow + +sealed class ExpressionException(override val message: String): Exception(message) { + class DivideByZero : ExpressionException("Can't divide by zero") + class FactorialCalculation : ExpressionException("Can calculate factorial of non-negative real numbers only") + class BadExpression : ExpressionException("Invalid expression. Probably some operator lacks argument") + class TooBig : ExpressionException("Value is too big") +} + +class Expression(input: String, private val radianMode: Boolean = true) { + private val tokens = Tokenizer(input).tokenize() + private var cursorPosition = 0 + + /** + * Expression := [ "-" ] Term { ("+" | "-") Term } + * + * Term := Factor { ( "*" | "/" ) Factor } + * + * Factor := RealNumber | "(" Expression ")" + * + * RealNumber := Digit{Digit} | [ Digit ] "." {Digit} + * + * Digit := "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" + */ + fun calculate(): BigDecimal { + try { + return parseExpression() + } catch (e: UninitializedPropertyAccessException) { + throw ExpressionException.BadExpression() + } + } + + // Null when at the end of expression + private fun peek() = tokens.getOrNull(cursorPosition) ?: "" + + private fun moveIfMatched(token: String): Boolean { + if (peek() == token) { + // Move cursor + cursorPosition++ + return true + } + return false + } + + // Expression := [ "-" ] Term { ("+" | "-") Term } + private fun parseExpression(): BigDecimal { + var expression = parseTerm() + + while (peek() in listOf(Token.Operator.plus, Token.Operator.minus)) { + when { + moveIfMatched(Token.Operator.plus) -> expression += parseTerm() + moveIfMatched(Token.Operator.minus) -> expression -= parseTerm() + } + } + return expression + } + + // Term := Factor { ( "*" | "/" ) Factor } + private fun parseTerm(): BigDecimal { + var expression = parseFactor() + + while (peek() in listOf(Token.Operator.multiply, Token.Operator.divide)) { + when { + moveIfMatched(Token.Operator.multiply) -> expression = + expression.multiply(parseFactor()) + + moveIfMatched(Token.Operator.divide) -> { + val divisor = parseFactor() + if (divisor.compareTo(BigDecimal.ZERO) == 0) throw ExpressionException.DivideByZero() + + expression = expression.divide(divisor, RoundingMode.HALF_EVEN) + } + } + } + return expression + } + + // Factor := RealNumber | "(" Expression ")" + private fun parseFactor(negative: Boolean = false): BigDecimal { + // This will throw Exception if some function lacks argument, for example: "cos()" or "600^" + lateinit var expr: BigDecimal + + fun parseFuncParentheses(): BigDecimal { + return if (moveIfMatched(Token.Operator.leftBracket)) { + // Parse in parentheses + val res = parseExpression() + + // Check if parentheses is closed + if (!moveIfMatched(Token.Operator.rightBracket)) throw Exception("Closing bracket is missing") + res + } else { + parseFactor() + } + } + + // Unary plus + if (moveIfMatched(Token.Operator.plus)) return parseFactor() + + // Unary minus + if (moveIfMatched(Token.Operator.minus)) { + return -parseFactor(true) + } + + // Parentheses + if (moveIfMatched(Token.Operator.leftBracket)) { + // Parse in parentheses + expr = parseExpression() + + // Check if parentheses is closed + if (!moveIfMatched(Token.Operator.rightBracket)) throw Exception("Closing bracket is missing") + } + + // Numbers + val possibleNumber = peek() + // We know that if next token starts with a digit or dot, it can be converted into BigDecimal + // Ugly + if (possibleNumber.isNotEmpty()) { + if (Token.Digit.allWithDot.contains(possibleNumber.first().toString())) { + expr = BigDecimal(possibleNumber).setScale(MAX_PRECISION) + cursorPosition++ + } + } + + // PI + if (moveIfMatched(Token.Const.pi)) { + expr = BigDecimal.valueOf(Math.PI) + } + + // e + if (moveIfMatched(Token.Const.e)) { + expr = BigDecimal.valueOf(Math.E) + } + + // sqrt + if (moveIfMatched(Token.Operator.sqrt)) { + expr = parseFuncParentheses().pow(BigDecimal(0.5)) + } + + // sin + if (moveIfMatched(Token.Func.sin)) { + expr = parseFuncParentheses().sin(radianMode) + } + + // cos + if (moveIfMatched(Token.Func.cos)) { + expr = parseFuncParentheses().cos(radianMode) + } + + // tan + if (moveIfMatched(Token.Func.tan)) { + expr = parseFuncParentheses().tan(radianMode) + } + + // arsin + if (moveIfMatched(Token.Func.arsin)) { + expr = parseFuncParentheses().arsin(radianMode) + } + + // arcos + if (moveIfMatched(Token.Func.arcos)) { + expr = parseFuncParentheses().arcos(radianMode) + } + + // actan + if (moveIfMatched(Token.Func.actan)) { + expr = parseFuncParentheses().artan(radianMode) + } + + // ln + if (moveIfMatched(Token.Func.ln)) { + expr = parseFuncParentheses().ln() + } + + // log + if (moveIfMatched(Token.Func.log)) { + expr = parseFuncParentheses().log() + } + + // exp + if (moveIfMatched(Token.Func.exp)) { + expr = parseFuncParentheses().exp() + } + + // Power + if (moveIfMatched(Token.Operator.power)) { + expr = expr.pow(parseFactor()) + } + + // Modulo + if (moveIfMatched(Token.Operator.modulo)) { + expr = expr.remainder(parseFactor()) + } + + // Factorial + if (moveIfMatched(Token.Operator.factorial)) { + if (negative) throw ExpressionException.FactorialCalculation() + expr = expr.factorial() + } + + return expr + } +} + +private fun BigDecimal.sin(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return sin(angle).toBigDecimal() +} + +private fun BigDecimal.arsin(radianMode: Boolean): BigDecimal { + val angle: Double = asin(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.cos(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return cos(angle).toBigDecimal() +} + +private fun BigDecimal.arcos(radianMode: Boolean): BigDecimal { + val angle: Double = acos(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.tan(radianMode: Boolean): BigDecimal { + val angle: Double = if (radianMode) this.toDouble() else Math.toRadians(this.toDouble()) + return tan(angle).toBigDecimal() +} + +private fun BigDecimal.artan(radianMode: Boolean): BigDecimal { + val angle: Double = atan(this.toDouble()) + return (if (radianMode) angle else Math.toDegrees(angle)).toBigDecimal() +} + +private fun BigDecimal.ln(): BigDecimal { + return ln(this.toDouble()).toBigDecimal() +} + +private fun BigDecimal.log(): BigDecimal { + return log(this.toDouble(), 10.0).toBigDecimal() +} + +private fun BigDecimal.exp(): BigDecimal { + return exp(this.toDouble()).toBigDecimal() +} + +private fun BigDecimal.pow(n: BigDecimal): BigDecimal { + val mathContext: MathContext = MathContext.DECIMAL64 + + var right = n + val signOfRight = right.signum() + right = right.multiply(signOfRight.toBigDecimal()) + val remainderOfRight = right.remainder(BigDecimal.ONE) + val n2IntPart = right.subtract(remainderOfRight) + val intPow = pow(n2IntPart.intValueExact(), mathContext) + val doublePow = BigDecimal( + toDouble().pow(remainderOfRight.toDouble()) + ) + + var result = intPow.multiply(doublePow, mathContext) + if (signOfRight == -1) result = + BigDecimal.ONE.divide(result, mathContext.precision, RoundingMode.HALF_UP) + + return result +} + +private fun BigDecimal.factorial(): BigDecimal { + if (this.remainder(BigDecimal.ONE).compareTo(BigDecimal.ZERO) != 0) throw ExpressionException.FactorialCalculation() + if (this < BigDecimal.ZERO) throw ExpressionException.FactorialCalculation() + + println("got $this") + + var expr = this + for (i in 1 until this.toInt()) { + expr *= BigDecimal(i) + } + return expr +} diff --git a/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt new file mode 100644 index 00000000..e3a74bc5 --- /dev/null +++ b/data/evaluatto/src/main/java/io/github/sadellie/evaluatto/Tokenizer.kt @@ -0,0 +1,238 @@ +/* + * 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 io.github.sadellie.evaluatto + +import com.sadellie.unitto.core.base.Token + +sealed class TokenizerException(override val message: String) : Exception(message) { + class BadNumber : TokenizerException("Number has multiple commas in it") +} + +class Tokenizer(private val streamOfTokens: String) { + // Do this in init? + fun tokenize(): List { + var cursor = 0 + val tokens: MutableList = mutableListOf() + + while (cursor != streamOfTokens.length) { + val nextToken = peekTokenAfter(cursor) + + if (nextToken != null) { + tokens.add(nextToken) + cursor += nextToken.length + } else { + // Didn't find any token, move left slowly (by 1 symbol) + cursor++ + } + } + + return tokens.repairLexicon() + } + + private fun peekTokenAfter(cursor: Int): String? { + Token.expressionTokens.forEach { token -> + val subs = streamOfTokens + .substring( + cursor, + (cursor + token.length).coerceAtMost(streamOfTokens.length) + ) + if (subs == token) { + // Got a digit, see if there are other digits coming after + if (token in Token.Digit.allWithDot) { + val number = streamOfTokens + .substring(cursor) + .takeWhile { Token.Digit.allWithDot.contains(it.toString()) } + + if (number.count { it.toString() == Token.Digit.dot } > 1) { + throw TokenizerException.BadNumber() + } + + return number + } + return token + } + } + return null + } + + private fun List.repairLexicon(): List { + return this + .missingClosingBrackets() + .missingMultiply() + .unpackAlPercents() + } + + private fun List.missingClosingBrackets(): List { + val leftBracket = this.count { it == Token.Operator.leftBracket } + val rightBrackets = this.count { it == Token.Operator.rightBracket } + val neededBrackets = leftBracket - rightBrackets + + if (neededBrackets <= 0) return this + + var fixed = this + repeat(neededBrackets) { + fixed = fixed + Token.Operator.rightBracket + } + return fixed + } + + private fun List.missingMultiply(): List { + val results = this.toMutableList() + val insertIndexes = mutableListOf() + + // Records the index if it needs a multiply symbol + fun needsMultiply(index: Int) { + val tokenInFront = results.getOrNull(index - 1) ?: return + + when { + tokenInFront.first().toString() in Token.Digit.allWithDot || + tokenInFront == Token.Operator.rightBracket || + tokenInFront in Token.Const.all -> { + // Can't add token now, it will modify tokens list (we are looping over it) + insertIndexes.add(index + insertIndexes.size) + } + } + } + + results.forEachIndexed { index, s -> + when (s) { + Token.Operator.leftBracket, + Token.Operator.sqrt, + in Token.Const.all, + in Token.Func.all -> needsMultiply(index) + } + } + + insertIndexes.forEach { + results.add(it, Token.Operator.multiply) + } + + return results + } + + private fun List.unpackAlPercents(): List { + var result = this + while (result.contains(Token.Operator.percent)) { + val percIndex = result.indexOf(Token.Operator.percent) + result = result.unpackPercentAt(percIndex) + } + return result + } + + private fun List.unpackPercentAt(percentIndex: Int): List { + var cursor = percentIndex + + // get whatever is the percentage + val percentage = this.getNumberOrExpressionBefore(percentIndex) + // Move cursor + cursor -= percentage.size + + // get the operator in front + cursor -= 1 + val operator = this.getOrNull(cursor) + + // Don't go further + if ((operator == null) or (operator !in listOf(Token.Operator.plus, Token.Operator.minus))) { + val mutList = this.toMutableList() + + // Remove percentage + mutList.removeAt(percentIndex) + + //Add opening bracket before percentage + mutList.add(percentIndex - percentage.size, Token.Operator.leftBracket) + + // Add "/ 100" and closing bracket + mutList.addAll(percentIndex + 1, listOf(Token.Operator.divide, "100", Token.Operator.rightBracket)) + + return mutList + } + // Get the base + val base = this.getBaseBefore(cursor) + val mutList = this.toMutableList() + + // Remove percentage + mutList.removeAt(percentIndex) + + //Add opening bracket before percentage + mutList.add(percentIndex - percentage.size, Token.Operator.leftBracket) + + // Add "/ 100" and other stuff + mutList.addAll( + percentIndex+1, + listOf( + Token.Operator.divide, + "100", + Token.Operator.multiply, + Token.Operator.leftBracket, + *base.toTypedArray(), + Token.Operator.rightBracket, + Token.Operator.rightBracket + ) + ) + + return mutList + } + + private fun List.getNumberOrExpressionBefore(pos: Int): List { + val digits = Token.Digit.all.map { it[0] } + + val tokenInFront = this[pos-1] + + // Just number + if (tokenInFront.all { it in digits }) return listOf(tokenInFront) + + // Not just a number. Probably expression in brackets. + if (tokenInFront != Token.Operator.rightBracket) throw Exception("Unexpected token before the percentage") + + // Start walking left until we get balanced brackets + var cursor = pos - 1 + var leftBrackets = 0 + var rightBrackets = 1 // We set 1 because we start with closing bracket + + while (leftBrackets != rightBrackets) { + cursor-- + val currentToken = this[cursor] + if (currentToken == Token.Operator.leftBracket) leftBrackets++ + if (currentToken == Token.Operator.rightBracket) rightBrackets++ + } + + return this.subList(cursor, pos) + } + + private fun List.getBaseBefore(pos: Int): List { + var cursor = pos + var leftBrackets = 0 + var rightBrackets = 0 + + while ((--cursor >= 0)) { + val currentToken = this[cursor] + + if (currentToken == Token.Operator.leftBracket) leftBrackets++ + if (currentToken == Token.Operator.rightBracket) rightBrackets++ + + if (leftBrackets > rightBrackets) break + } + + // Return cursor back to last token + cursor += 1 + + return this.subList(cursor, pos) + } + +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt new file mode 100644 index 00000000..d530ebd6 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionComplexTest.kt @@ -0,0 +1,60 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionComplexTest { + + @Test + fun expression1() = assertExpr("94×π×89×cos(0.5)−3!÷9^(2)×√8", "23064.9104578494") + + @Test + fun expression2() = assertExpr("√(25)×2+10÷2", "15") + + @Test + fun expression3() = assertExpr("(3+4)×(5−2)", "21") + + @Test + fun expression4() = assertExpr("8÷4+2×3", "8") + + @Test + fun expression5() = assertExpr("2^3+4^2−5×6", "-6") + + @Test + fun expression6() = assertExpr("(10−2)^2÷8+3×2", "14") + + @Test + fun expression7() = assertExpr("7!÷3!−5!÷2!", "780") + + @Test + fun expression8() = assertExpr("(2^2+3^3)÷5−√(16)×2", "-1.8") + + @Test + fun expression9() = assertExpr("10×log(100)+2^4−3^2", "27") + + @Test + fun expression10() = assertExpr("sin(π÷3)×cos(π÷6)+tan(π÷4)−√3", "0.017949192431123") + + @Test + fun expression11() = assertExpr("2^6−2^5+2^4−2^3+2^−2^1+2^0", "41.25") + + @Test + fun expression12() = assertExpr("2×(3+4)×(5−2)÷6", "7") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt new file mode 100644 index 00000000..262c5841 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionExceptionsTest.kt @@ -0,0 +1,42 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionExceptionsTest { + + @Test + fun `divide by zero`() = assertExprFail(ExpressionException.DivideByZero::class.java, "2÷0") + + @Test + fun `factorial of float`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "3.2!") + + @Test + fun `factorial of negative`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "−5!") + + @Test + fun `factorial of negative2`() = assertExprFail(ExpressionException.FactorialCalculation::class.java, "(−5)!") + + @Test + fun `ugly ahh expression`() = assertExprFail(ExpressionException.BadExpression::class.java, "100+cos()") + + @Test + fun `ugly ahh expression2`() = assertExprFail(TokenizerException.BadNumber::class.java, "...") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt new file mode 100644 index 00000000..24d0b75e --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/ExpressionSimpleTest.kt @@ -0,0 +1,111 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Test + +class ExpressionSimpleTest { + + @Test + fun expression1() = assertExpr("789", "789") + + @Test + fun expression2() = assertExpr("0.1+0.2", "0.3") + + @Test + fun expression3() = assertExpr(".1+.2", "0.3") + + @Test + fun expression4() = assertExpr("789+200", "989") + + @Test + fun expression5() = assertExpr("600×7.89", "4734") + + @Test + fun expression6() = assertExpr("600÷7", "85.7142857143") + + @Test + fun expression7() = assertExpr("(200+200)×200", "80000") + + @Test + fun expression8() = assertExpr("99^5", "9509900499") + + @Test + fun expression9() = assertExpr("12!", "479001600") + + @Test + fun expression10() = assertExpr("12#5", "2") + + @Test + fun `125 plus 9 percent`() = assertExpr("125+9%", "136.25") + + @Test + fun expression11() = assertExpr("12×√5", "26.8328157300") + + @Test + fun expression12() = assertExpr("sin(42)", "-0.9165215479") + + @Test + fun expression13() = assertExpr("sin(42)", "0.6691306064", radianMode = false) + + @Test + fun expression14() = assertExpr("cos(42)", "-0.3999853150") + + @Test + fun expression15() = assertExpr("cos(42)", "0.7431448255", radianMode = false) + + @Test + fun expression16() = assertExpr("tan(42)", "2.2913879924") + + @Test + fun expression17() = assertExpr("tan(42)", "0.9004040443", radianMode = false) + + @Test + fun expression18() = assertExpr("sin⁻¹(.69)", "0.7614890527") + + @Test + fun expression19() = assertExpr("sin⁻¹(.69)", "43.6301088679", radianMode = false) + + @Test + fun expression20() = assertExpr("cos⁻¹(.69)", "0.8093072740") + + @Test + fun expression21() = assertExpr("cos⁻¹(.69)", "46.3698911321", radianMode = false) + + @Test + fun expression22() = assertExpr("tan⁻¹(.69)", "0.6039829783") + + @Test + fun expression23() = assertExpr("tan⁻¹(.69)", "34.6056755516", radianMode = false) + + @Test + fun expression24() = assertExpr("ln(.69)", "-0.3710636814") + + @Test + fun expression25() = assertExpr("log(.69)", "-0.1611509093") + + @Test + fun expression26() = assertExpr("exp(3)", "20.0855369232") + + @Test + fun expression27() = assertExpr("π", "3.1415926536") + + @Test + fun expression28() = assertExpr("e", "2.7182818285") +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt new file mode 100644 index 00000000..d0d9d4fe --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/FixLexiconTest.kt @@ -0,0 +1,122 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Test + +class FixLexiconTest { + @Test + fun `missing multiply`() { + assertLex( + "2×(69−420)", "2(69−420)" + ) + + assertLex( + "0.×(69−420)", "0.(69−420)" + ) + + assertLex( + ".0×(69−420)", ".0(69−420)" + ) + + assertLex( + ".×(69−420)", ".(69−420)" + ) + + assertLex( + "2×(69−420)×(23−4)×cos(9)×tan((sin⁻¹(.9)))", + "2(69−420)(23−4)cos(9)tan((sin⁻¹(.9)))" + ) + + assertLex( + "e×e+π", "ee+π" + ) + } + + @Test + fun `balanced brackets`() { + assertLex( + "123×(12+4)", "123(12+4" + ) + + assertLex( + "12312+4", "12312+4" + ) + + assertLex( + "123)))12+4", "123)))12+4" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3)" + ) + + assertLex( + "sin(cos(tan(3)))", "sin(cos(tan(3))" + ) + } + + @Test + fun `unpack percentage`() { + // 132.5+14% −> 132.5+132.5*0.14 + assertLex( + "132.5+(14÷100×(132.5))", "132.5+14%" + ) + + // 132.5+(14)% −> 132.5+(14)/100*132.5 + assertLex( + "132.5+((14)÷100×(132.5))" , "132.5+(14)%" + ) + + // 132.5+(15+4)% −> 132.5+(15+4)*132.5/100 + assertLex( + "132.5+((15+4)÷100×(132.5))", "132.5+(15+4)%" + ) + + // (132.5+12%)+(15+4)% −> (132.5+12/100*132.5)+(15+4)/100*(132.5+12/100*132.5) + assertLex( + "(132.5+(12÷100×(132.5)))+((15+4)÷100×((132.5+(12÷100×(132.5)))))", "(132.5+12%)+(15+4)%" + ) + + // 2% −> 2/100 + assertLex( + "(2÷100)", "2%" + ) + + assertLex( + "((2)÷100)", "(2)%" + ) + + assertLex( + "(132.5+5)+(90÷100×((132.5+5)))", "(132.5+5)+90%" + ) + + assertLex( + "((90÷100)+(90÷100×((90÷100))))", "(90%+90%)" + ) + + assertLex( + "((90÷100)÷(90÷100))+((90÷100)−(90÷100×((90÷100))))", "(90%÷90%)+(90%−90%)" + ) + } +} diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt new file mode 100644 index 00000000..8cdc1468 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/Helpers.kt @@ -0,0 +1,46 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Assert +import java.math.BigDecimal +import java.math.RoundingMode + +fun assertExpr(expr: String, result: String, radianMode: Boolean = true) = + Assert.assertEquals( + BigDecimal(result).setScale(10, RoundingMode.HALF_EVEN), + Expression(expr, radianMode).calculate().setScale(10, RoundingMode.HALF_EVEN) + ) + +fun assertExprFail( + expectedThrowable: Class?, + expr: String, + radianMode: Boolean = true +) { + Assert.assertThrows(expectedThrowable) { + val calculated = Expression(expr, radianMode = radianMode).calculate() + println(calculated) + } +} + +fun assertLex(expected: List, actual: String) = + Assert.assertEquals(expected, Tokenizer(actual).tokenize()) + +fun assertLex(expected: String, actual: String) = + Assert.assertEquals(expected, Tokenizer(actual).tokenize().joinToString("")) diff --git a/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt new file mode 100644 index 00000000..12a23cc4 --- /dev/null +++ b/data/evaluatto/src/test/java/io/github/sadellie/evaluatto/TokenizerTest.kt @@ -0,0 +1,50 @@ +/* + * 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 io.github.sadellie.evaluatto + +import org.junit.Test + +class TokenizerTest { + @Test + fun tokens1() = assertLex(listOf("789"), "789") + + @Test + fun tokens2() = assertLex(listOf("789", "+", "200"), "789+200") + + @Test + fun tokens3() = assertLex(listOf("0.1", "+", "0.2"), "0.1+0.2") + + @Test + fun tokens4() = assertLex(listOf(".1", "+", ".2"), ".1+.2") + + @Test + fun tokens5() = assertLex(listOf(".1", "+", ".2"), ".1+.2") + + @Test + fun tokens6() = assertLex(listOf("789", "+", "200", "+", "cos", "(", "456", ")"), "789+200+cos(456)") + + @Test + fun tokens8() = assertLex(emptyList(), "") + + @Test + fun tokens9() = assertLex(listOf("e"), "something") // Tokenizer knows "e" + + @Test + fun tokens10() = assertLex(emptyList(), "funnyword") +} diff --git a/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt b/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt index 149bb58b..0bee1fca 100644 --- a/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt +++ b/data/licenses/src/main/java/com/sadellie/unitto/data/licenses/Library.kt @@ -28,23 +28,6 @@ data class AppLibrary( val ALL_LIBRARIES by lazy { listOf( - AppLibrary( - name = "MathParser.org-mXparser", - dev = "Mariusz Gromada", - website = "https://github.com/mariuszgromada/MathParser.org-mXparser/", - license = "Non-Commercial license", - description = "Math Parser Java Android C# .NET/MONO (.NET Framework, .NET Core, .NET " + - "Standard, .NET PCL, Xamarin.Android, Xamarin.iOS) CLS Library - a super easy, rich" + - " and flexible mathematical expression parser (expression evaluator, expression " + - "provided as plain text / strings) for JAVA and C#." - ), - AppLibrary( - name = "ExprK", - dev = "Keelar", - website = "https://github.com/Keelar/ExprK", - license = "MIT license", - description = "A simple mathematical expression evaluator for Kotlin and Java, written in Kotlin." - ), AppLibrary( name = "currency-api", dev = "Fawaz Ahmed (fawazahmed0)", diff --git a/feature/calculator/build.gradle.kts b/feature/calculator/build.gradle.kts index 1c7915d0..b69ba212 100644 --- a/feature/calculator/build.gradle.kts +++ b/feature/calculator/build.gradle.kts @@ -29,7 +29,6 @@ android { dependencies { testImplementation(libs.junit) - implementation(libs.org.mariuszgromada.math.mxparser) implementation(libs.com.github.sadellie.themmo) implementation(project(mapOf("path" to ":data:common"))) @@ -37,4 +36,5 @@ dependencies { implementation(project(mapOf("path" to ":data:database"))) implementation(project(mapOf("path" to ":data:calculator"))) implementation(project(mapOf("path" to ":data:model"))) + implementation(project(mapOf("path" to ":data:evaluatto"))) } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt index 7edc7036..e08c8636 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt @@ -57,23 +57,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalConfiguration 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 import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.MenuButton import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar -import com.sadellie.unitto.core.ui.common.textfield.InputTextField +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField +import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard import com.sadellie.unitto.feature.calculator.components.DragDownView import com.sadellie.unitto.feature.calculator.components.HistoryList import kotlinx.coroutines.launch import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt @@ -89,9 +89,9 @@ internal fun CalculatorRoute( uiState = uiState.value, navigateToMenu = navigateToMenu, navigateToSettings = navigateToSettings, - addSymbol = viewModel::addSymbol, - clearSymbols = viewModel::clearSymbols, - deleteSymbol = viewModel::deleteSymbol, + addSymbol = viewModel::addTokens, + clearSymbols = viewModel::clearInput, + deleteSymbol = viewModel::deleteTokens, onCursorChange = viewModel::onCursorChange, toggleAngleMode = viewModel::toggleCalculatorMode, evaluate = viewModel::evaluate, @@ -107,7 +107,7 @@ private fun CalculatorScreen( addSymbol: (String) -> Unit, clearSymbols: () -> Unit, deleteSymbol: () -> Unit, - onCursorChange: (IntRange) -> Unit, + onCursorChange: (TextRange) -> Unit, toggleAngleMode: () -> Unit, evaluate: () -> Unit, clearHistory: () -> Unit @@ -160,6 +160,8 @@ private fun CalculatorScreen( .fillMaxSize(), historyItems = uiState.history, historyItemHeightCallback = { historyItemHeight = it }, + formatterSymbols = uiState.formatterSymbols, + addTokens = addSymbol, ) }, textFields = { maxDragAmount -> @@ -202,28 +204,56 @@ private fun CalculatorScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - InputTextField( + ExpressionTextField( modifier = Modifier .weight(2f) .fillMaxWidth() .padding(horizontal = 8.dp), - value = uiState.input.copy( - Formatter.fromSeparator(uiState.input.text, Separator.COMMA) - ), + value = uiState.input, minRatio = 0.5f, cutCallback = deleteSymbol, pasteCallback = addSymbol, - onCursorChange = onCursorChange + onCursorChange = onCursorChange, + formatterSymbols = uiState.formatterSymbols ) if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { - InputTextField( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(horizontal = 8.dp), - value = Formatter.format(uiState.output), - textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f) - ) + when (uiState.output) { + is CalculationResult.Default -> { + var output by remember(uiState.output) { + mutableStateOf(TextFieldValue(uiState.output.text)) + } + + ExpressionTextField( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = output, + minRatio = 1f, + onCursorChange = { output = output.copy(selection = it) }, + formatterSymbols = uiState.formatterSymbols, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.6f), + readOnly = true, + ) + } + + else -> { + val label = uiState.output.label?.let { stringResource(it) } ?: "" + + UnformattedTextField( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 8.dp), + value = TextFieldValue(label), + minRatio = 1f, + onCursorChange = {}, + textColor = MaterialTheme.colorScheme.error, + readOnly = true, + ) + } + } + } // Handle Box( @@ -239,8 +269,11 @@ private fun CalculatorScreen( }, numPad = { CalculatorKeyboard( - modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), radianMode = uiState.radianMode, + fractional = uiState.formatterSymbols.fractional, allowVibration = uiState.allowVibration, addSymbol = addSymbol, clearSymbols = clearSymbols, @@ -315,7 +348,7 @@ private fun PreviewCalculatorScreen() { CalculatorScreen( uiState = CalculatorUIState( input = TextFieldValue("1.2345"), - output = "1234", + output = CalculationResult.Default("1234"), history = historyItems ), navigateToMenu = {}, 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 36f46269..4a1521ff 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 @@ -18,13 +18,22 @@ package com.sadellie.unitto.feature.calculator +import androidx.annotation.StringRes import androidx.compose.ui.text.input.TextFieldValue +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.HistoryItem -internal data class CalculatorUIState( +data class CalculatorUIState( val input: TextFieldValue = TextFieldValue(), - val output: String = "", + val output: CalculationResult = CalculationResult.Default(), val radianMode: Boolean = true, val history: List = emptyList(), - val allowVibration: Boolean = false + val allowVibration: Boolean = false, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces ) + +sealed class CalculationResult(@StringRes val label: Int? = null) { + data class Default(val text: String = "") : CalculationResult() + object DivideByZeroError : CalculationResult(R.string.divide_by_zero_error) + object Error : CalculationResult(R.string.error_label) +} 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 9c1b6081..7c12c7ad 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 @@ -18,36 +18,40 @@ package com.sadellie.unitto.feature.calculator +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.addTokens +import com.sadellie.unitto.core.ui.common.textfield.deleteTokens import com.sadellie.unitto.data.calculator.CalculatorHistoryRepository +import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros import com.sadellie.unitto.data.userprefs.UserPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository 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.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.mariuszgromada.math.mxparser.Expression import java.math.BigDecimal import javax.inject.Inject -import org.mariuszgromada.math.mxparser.License as MathParserLicense -import org.mariuszgromada.math.mxparser.mXparser as MathParser @HiltViewModel internal class CalculatorViewModel @Inject constructor( private val userPrefsRepository: UserPreferencesRepository, private val calculatorHistoryRepository: CalculatorHistoryRepository, - private val textFieldController: TextFieldController ) : ViewModel() { private val _userPrefs: StateFlow = userPrefsRepository.userPreferencesFlow.stateIn( @@ -56,131 +60,91 @@ internal class CalculatorViewModel @Inject constructor( UserPreferences() ) - private val _output: MutableStateFlow = MutableStateFlow("") + private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) + private val _output: MutableStateFlow = + MutableStateFlow(CalculationResult.Default()) private val _history = calculatorHistoryRepository.historyFlow val uiState = combine( - textFieldController.input, _output, _history, _userPrefs + _input, _output, _history, _userPrefs ) { input, output, history, userPrefs -> return@combine CalculatorUIState( input = input, output = output, radianMode = userPrefs.radianMode, history = history, - allowVibration = userPrefs.enableVibrations + allowVibration = userPrefs.enableVibrations, + formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState() ) - fun addSymbol(symbol: String) = textFieldController.addToInput(symbol) + fun addTokens(tokens: String) = _input.update { it.addTokens(tokens) } + fun deleteTokens() = _input.update { it.deleteTokens() } + fun clearInput() = _input.update { TextFieldValue() } + fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } - fun deleteSymbol() = textFieldController.delete() + // Called when user clicks "=" on a keyboard + fun evaluate() = viewModelScope.launch(Dispatchers.IO) { + when (val calculationResult = calculateInput()) { + is CalculationResult.Default -> { + if (calculationResult.text.isEmpty()) return@launch - fun clearSymbols() = textFieldController.clearInput() - - fun toggleCalculatorMode() { - viewModelScope.launch { - userPrefsRepository.updateRadianMode(!_userPrefs.value.radianMode) + calculatorHistoryRepository.add( + expression = _input.value.text, + result = calculationResult.text + ) + _input.update { + TextFieldValue(calculationResult.text, TextRange(calculationResult.text.length)) + } + _output.update { CalculationResult.Default() } + } + // Show the error + else -> _output.update { calculationResult } } } - // Called when user clicks "=" on a keyboard - fun evaluate() { - // Input and output can change while saving in history. This way we cache it here (i think) - val output = _output.value - - // Output can be empty when input and output are identical (for example when user entered - // just a number, not expression - if (output.isEmpty()) return - if (!Expression(textFieldController.inputTextWithoutFormatting().clean).checkSyntax()) return - - // Save to history - viewModelScope.launch(Dispatchers.IO) { - calculatorHistoryRepository.add( - expression = textFieldController.inputTextWithoutFormatting(), - result = output - ) - textFieldController.clearInput() - textFieldController.addToInput(output) - } - - _output.update { "" } + fun toggleCalculatorMode() = viewModelScope.launch { + userPrefsRepository.updateRadianMode(!_userPrefs.value.radianMode) } fun clearHistory() = viewModelScope.launch(Dispatchers.IO) { calculatorHistoryRepository.clear() } - fun onCursorChange(selection: IntRange) = textFieldController.moveCursor(selection) + private fun calculateInput(): CalculationResult { + val currentInput = _input.value.text + // Input is empty or not an expression, don't calculate + if (!currentInput.isExpression()) return CalculationResult.Default() - private fun calculateInput() { - val currentInput = textFieldController.inputTextWithoutFormatting() - // Input is empty, don't calculate - if (currentInput.isEmpty()) { - _output.update { "" } - return + return try { + CalculationResult.Default( + Expression(currentInput, radianMode = _userPrefs.value.radianMode) + .calculate() + .also { + if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() + } + .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) + .trimZeros() + .toStringWith(_userPrefs.value.outputFormat) + ) + } catch (e: ExpressionException.DivideByZero) { + CalculationResult.DivideByZeroError + } catch (e: Exception) { + CalculationResult.Error } - - val calculated = Expression(currentInput.clean).calculate() - - // Calculation error, return empty string - if (calculated.isNaN() or calculated.isInfinite()) { - _output.update { "" } - return - } - - val calculatedBigDecimal = calculated - .toBigDecimal() - .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) - .trimZeros() - - // Output will be empty if it's same as input - try { - val inputBigDecimal = BigDecimal(currentInput) - - // Input and output are identical values - if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) { - _output.update { "" } - return - } - } catch (e: NumberFormatException) { - // Cannot compare input and output - } - _output.update { - calculatedBigDecimal.toStringWith(_userPrefs.value.outputFormat) - } - return } - /** - * Clean input so that there are no syntax errors - */ - private val String.clean: String - get() { - val leftBrackets = count { it.toString() == Token.leftBracket } - val rightBrackets = count { it.toString() == Token.rightBracket } - val neededBrackets = leftBrackets - rightBrackets - return replace(Token.minusDisplay, Token.minus) - .plus(Token.rightBracket.repeat(neededBrackets.coerceAtLeast(0))) - } - init { - /** - * mxParser uses some unnecessary rounding for doubles. It causes expressions like 9999^9999 - * to load CPU very much. We use BigDecimal to achieve same result without CPU overload. - */ - MathParserLicense.iConfirmNonCommercialUse("Sad Ellie") - MathParser.setCanonicalRounding(false) - MathParser.removeBuiltinTokens("log") - MathParser.modifyBuiltinToken("lg", Token.log.dropLast(1)) - // Observe and invoke calculation without UI lag. viewModelScope.launch(Dispatchers.Default) { - combine(_userPrefs, textFieldController.input) { userPrefs, _ -> - if (userPrefs.radianMode) MathParser.setRadiansMode() else MathParser.setDegreesMode() - }.collectLatest { - calculateInput() + merge(_userPrefs, _input).collectLatest { + val calculated = calculateInput() + _output.update { + // Don't show error when simply entering stuff + if (calculated !is CalculationResult.Default) CalculationResult.Default() else calculated + } } } } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt deleted file mode 100644 index cca32725..00000000 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.calculator - -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.UnittoFormatter -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import javax.inject.Inject -import kotlin.math.abs - -class TextFieldController @Inject constructor() { - var input: MutableStateFlow = MutableStateFlow(TextFieldValue()) - - // Internally we don't care about user preference here, because during composition this - // symbols will be replaced to those that user wanted. - // We do this because it adds unnecessary logic: it requires additional logic to observe and - // react to formatting preferences at this level. - private val localFormatter: UnittoFormatter by lazy { - UnittoFormatter().also { - it.setSeparator(Separator.COMMA) - } - } - - private val cursorFixer by lazy { CursorFixer() } - - fun addToInput(symbols: String) { - - val text = input.value.text - val selection = input.value.selection - val lastToEndDistance = text.length - selection.end - - val newInput = if (text.isEmpty()) { - symbols - } else { - text.replaceRange(selection.start, selection.end, symbols) - } - - val inputFormatted = newInput.fixFormat() - val newSelectionStartEnd = inputFormatted.length - lastToEndDistance - val fixedCursor = fixCursor( - newPosition = newSelectionStartEnd..newSelectionStartEnd, - currentInput = inputFormatted - ) ?: newSelectionStartEnd..newSelectionStartEnd - - input.update { - it.copy( - text = inputFormatted, - selection = TextRange(fixedCursor) - ) - } - } - - /** - * Method to call when pasting from clipbaord. It filters input before calling [addToInput]. - */ - fun pasteSymbols(symbols: String) = addToInput(symbols.filterUnknownSymbols()) - - fun moveCursor(newPosition: IntRange) { - input.update { - it.copy( - selection = TextRange(fixCursor(newPosition) ?: return) - ) - } - } - - fun delete() { - val selection = input.value.selection - val distanceFromEnd = input.value.text.length - selection.end - - val deleteRangeStart = when (selection.end) { - // Don't delete if at the start of the text field - 0 -> return - // We don't have anything selected (cursor in one position) - // like this 1234|56 => after deleting will be like this 123|56 - // Cursor moved one symbol left - selection.start -> { - // We default to 1 here. It means that cursor is not placed after illegal token - // Just a number or a binary operator or something else, can delete by one symbol - val amountOfSymbolsToDelete: Int = - cursorFixer.tokenLengthInFront(input.value.text, selection.end) ?: 1 - selection.start - amountOfSymbolsToDelete - } - // We have multiple symbols selected - // like this 123[45]6 => after deleting will be like this 123|6 - // Cursor will be placed where selection start was - else -> selection.start - } - - input.update { - val newText = it.text - .removeRange(deleteRangeStart, it.selection.end) - .fixFormat() - it.copy( - text = newText, - selection = TextRange((newText.length - distanceFromEnd).coerceAtLeast(0)) - ) - } - } - - fun clearInput() = input.update { TextFieldValue() } - - fun inputTextWithoutFormatting() = input.value.text - .replace(localFormatter.grouping, "") - .replace(localFormatter.fractional, Token.dot) - - private fun fixCursor( - newPosition: IntRange, - currentInput: String = input.value.text - ): IntRange? { - if (newPosition.last > currentInput.length) return null - - val fixedLeftCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.first) - val fixedRightCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.last) - - return fixedLeftCursor..fixedRightCursor - } - - private fun String.fixFormat(): String = localFormatter.reFormat(this) - - private fun String.filterUnknownSymbols() = localFormatter.filterUnknownSymbols(this) - - private fun TextRange(range: IntRange): TextRange = TextRange(range.first, range.last) - - inner class CursorFixer { - private val illegalTokens by lazy { - listOf( - Token.arSin, - Token.arCos, - Token.acTan, - Token.cos, - Token.sin, - Token.exp, - Token.ln, - Token.log, - Token.tan - ) - } - - fun fixCursorIfNeeded(str: String, pos: Int): Int { - // Best position if we move cursor left - val bestLeft = bestPositionLeft(str, pos) - // Best position if we move cursor right - val bestRight = bestPositionRight(str, pos) - - return listOf(bestLeft, bestRight) - .minBy { abs(it - pos) } - } - - fun tokenLengthInFront(str: String, pos: Int): Int? { - illegalTokens.forEach { - if (pos.afterToken(str, it)) return it.length - } - - return null - } - - private fun bestPositionLeft(str: String, pos: Int): Int { - var cursorPosition = pos - while (placedIllegally(str, cursorPosition)) cursorPosition-- - return cursorPosition - } - - private fun bestPositionRight(str: String, pos: Int): Int { - var cursorPosition = pos - while (placedIllegally(str, cursorPosition)) cursorPosition++ - return cursorPosition - } - - private fun placedIllegally(str: String, pos: Int): Boolean { - // For things like "123,|456" - this is illegal - if (pos.afterToken(str, localFormatter.grouping)) return true - - // For things like "123,456+c|os(8)" - this is illegal - illegalTokens.forEach { - if (pos.atToken(str, it)) return true - } - - return false - } - - /** - * Don't use if token is 1 symbol long, it wouldn't make sense! Use [afterToken] instead. - * @see [afterToken] - */ - private fun Int.atToken(str: String, token: String): Boolean { - val checkBound = (token.length - 1).coerceAtLeast(1) - - val stringToScan = str.substring( - startIndex = (this - checkBound).coerceAtLeast(0), - endIndex = (this + checkBound).coerceAtMost(str.length) - ) - - return stringToScan.contains(token) - } - - private fun Int.afterToken(str: String, token: String): Boolean { - val stringToScan = str.substring( - startIndex = (this - token.length).coerceAtLeast(0), - endIndex = this - ) - - return stringToScan.contains(token) - } - } -} diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt index 16d1f1a6..36068f37 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.ColumnWithConstraints import com.sadellie.unitto.core.ui.common.KeyboardButtonAdditional import com.sadellie.unitto.core.ui.common.KeyboardButtonFilled @@ -103,6 +102,7 @@ import com.sadellie.unitto.core.ui.common.key.unittoicons.Tan internal fun CalculatorKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, clearSymbols: () -> Unit, @@ -114,6 +114,7 @@ internal fun CalculatorKeyboard( PortraitKeyboard( modifier = modifier, radianMode = radianMode, + fractional = fractional, allowVibration = allowVibration, addSymbol = addSymbol, toggleAngleMode = toggleAngleMode, @@ -125,6 +126,7 @@ internal fun CalculatorKeyboard( LandscapeKeyboard( modifier = modifier, radianMode = radianMode, + fractional = fractional, allowVibration = allowVibration, addSymbol = addSymbol, toggleAngleMode = toggleAngleMode, @@ -139,6 +141,7 @@ internal fun CalculatorKeyboard( private fun PortraitKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, toggleAngleMode: () -> Unit, @@ -146,7 +149,7 @@ private fun PortraitKeyboard( clearSymbols: () -> Unit, evaluate: () -> Unit ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } var showAdditional: Boolean by remember { mutableStateOf(false) } var invMode: Boolean by remember { mutableStateOf(false) } val expandRotation: Float by animateFloatAsState( @@ -217,32 +220,32 @@ private fun PortraitKeyboard( Spacer(modifier = Modifier.height(verticalFraction(0.025f))) Row(weightModifier) { - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.leftBracket) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.rightBracket) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.percent) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.divideDisplay) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.Operator.leftBracket) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.Operator.rightBracket) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.Operator.percent) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.Operator.divide) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token._7) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token._8) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token._9) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.multiplyDisplay) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token.Digit._7) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token.Digit._8) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token.Digit._9) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.Operator.multiply) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token._4) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token._5) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token._6) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.minusDisplay) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token.Digit._4) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token.Digit._5) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token.Digit._6) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.Operator.minus) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token._1) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token._2) } - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token._3) } - KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.plus) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token.Digit._1) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token.Digit._2) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token.Digit._3) } + KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.Operator.plus) } } Row(weightModifier) { - KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token._0) } - KeyboardButtonLight(mainButtonModifier, fractionalIcon, allowVibration) { addSymbol(Token.dot) } + KeyboardButtonLight(mainButtonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token.Digit._0) } + KeyboardButtonLight(mainButtonModifier, fractionalIcon, allowVibration) { addSymbol(Token.Digit.dot) } KeyboardButtonLight(mainButtonModifier, UnittoIcons.Backspace, allowVibration, clearSymbols) { deleteSymbol() } KeyboardButtonFilled(mainButtonModifier, UnittoIcons.Equal, allowVibration) { evaluate() } } @@ -263,24 +266,24 @@ private fun AdditionalButtonsPortrait( ) { Column { Row { - KeyboardButtonAdditional(modifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.sqrt) } - KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } + KeyboardButtonAdditional(modifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.Operator.sqrt) } + KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } } AnimatedVisibility(showAdditional) { Column { Row { KeyboardButtonAdditional(modifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.sin) } - KeyboardButtonAdditional(modifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.cos) } - KeyboardButtonAdditional(modifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.tan) } + KeyboardButtonAdditional(modifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.Func.sinBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.Func.cosBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.Func.tanBracket) } } Row { KeyboardButtonAdditional(modifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } - KeyboardButtonAdditional(modifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.ln) } - KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } + KeyboardButtonAdditional(modifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.Func.lnBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } } @@ -299,24 +302,24 @@ private fun AdditionalButtonsPortraitInverse( ) { Column { Row { - KeyboardButtonAdditional(modifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.modulo) } - KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } + KeyboardButtonAdditional(modifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.Operator.modulo) } + KeyboardButtonAdditional(modifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(modifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(modifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } } AnimatedVisibility(showAdditional) { Column { Row { KeyboardButtonAdditional(modifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.arSin) } - KeyboardButtonAdditional(modifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.arCos) } - KeyboardButtonAdditional(modifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.acTan) } + KeyboardButtonAdditional(modifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.Func.arsinBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.Func.arcosBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.Func.actanBracket) } } Row { KeyboardButtonAdditional(modifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } - KeyboardButtonAdditional(modifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.exp) } - KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(modifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } + KeyboardButtonAdditional(modifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.Func.expBracket) } + KeyboardButtonAdditional(modifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } } @@ -327,6 +330,7 @@ private fun AdditionalButtonsPortraitInverse( private fun LandscapeKeyboard( modifier: Modifier, radianMode: Boolean, + fractional: String, allowVibration: Boolean, addSymbol: (String) -> Unit, toggleAngleMode: () -> Unit, @@ -334,7 +338,7 @@ private fun LandscapeKeyboard( clearSymbols: () -> Unit, evaluate: () -> Unit ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } var invMode: Boolean by remember { mutableStateOf(false) } RowWithConstraints(modifier) { constraints -> @@ -370,34 +374,34 @@ private fun LandscapeKeyboard( } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token._7) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token._4) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token._1) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token._0) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key7, allowVibration) { addSymbol(Token.Digit._7) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key4, allowVibration) { addSymbol(Token.Digit._4) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key1, allowVibration) { addSymbol(Token.Digit._1) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key0, allowVibration) { addSymbol(Token.Digit._0) } } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token._8) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token._5) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token._2) } - KeyboardButtonLight(buttonModifier, fractionalIcon, allowVibration) { addSymbol(Token.dot) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key8, allowVibration) { addSymbol(Token.Digit._8) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key5, allowVibration) { addSymbol(Token.Digit._5) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key2, allowVibration) { addSymbol(Token.Digit._2) } + KeyboardButtonLight(buttonModifier, fractionalIcon, allowVibration) { addSymbol(Token.Digit.dot) } } Column(Modifier.weight(1f)) { - KeyboardButtonLight(buttonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token._9) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token._6) } - KeyboardButtonLight(buttonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token._3) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key9, allowVibration) { addSymbol(Token.Digit._9) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key6, allowVibration) { addSymbol(Token.Digit._6) } + KeyboardButtonLight(buttonModifier, UnittoIcons.Key3, allowVibration) { addSymbol(Token.Digit._3) } KeyboardButtonLight(buttonModifier, UnittoIcons.Backspace, allowVibration, clearSymbols) { deleteSymbol() } } Column(Modifier.weight(1f)) { - KeyboardButtonFilled(buttonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.leftBracket) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.multiplyDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.minusDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.plus) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.LeftBracket, allowVibration) { addSymbol(Token.Operator.leftBracket) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Multiply, allowVibration) { addSymbol(Token.Operator.multiply) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Minus, allowVibration) { addSymbol(Token.Operator.minus) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Plus, allowVibration) { addSymbol(Token.Operator.plus) } } Column(Modifier.weight(1f)) { - KeyboardButtonFilled(buttonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.rightBracket) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.divideDisplay) } - KeyboardButtonFilled(buttonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.percent) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.RightBracket, allowVibration) { addSymbol(Token.Operator.rightBracket) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Divide, allowVibration) { addSymbol(Token.Operator.divide) } + KeyboardButtonFilled(buttonModifier, UnittoIcons.Percent, allowVibration) { addSymbol(Token.Operator.percent) } KeyboardButtonFilled(buttonModifier, UnittoIcons.Equal, allowVibration) { evaluate() } } } @@ -416,22 +420,22 @@ private fun AdditionalButtonsLandscape( Column(modifier) { KeyboardButtonAdditional(buttonModifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } KeyboardButtonAdditional(buttonModifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.sin) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Sin, allowVibration) { addSymbol(Token.Func.sinBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.sqrt) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.cos) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.ln) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.SquareRootWide, allowVibration) { addSymbol(Token.Operator.sqrt) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Cos, allowVibration) { addSymbol(Token.Func.cosBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Ln, allowVibration) { addSymbol(Token.Func.lnBracket) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.tan) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Tan, allowVibration) { addSymbol(Token.Func.tanBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } @@ -448,22 +452,22 @@ private fun AdditionalButtonsLandscapeInverse( Column(modifier) { KeyboardButtonAdditional(buttonModifier, if (radianMode) UnittoIcons.Rad else UnittoIcons.Deg, allowVibration) { toggleAngleMode() } KeyboardButtonAdditional(buttonModifier, UnittoIcons.Inv, allowVibration) { toggleInvMode() } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.arSin) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.e) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArSin, allowVibration) { addSymbol(Token.Func.arsinBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.E, allowVibration) { addSymbol(Token.Const.e) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.modulo) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.exponent) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.arCos) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.exp) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Modulo, allowVibration) { addSymbol(Token.Operator.modulo) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ExponentWide, allowVibration) { addSymbol(Token.Operator.power) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.ArCos, allowVibration) { addSymbol(Token.Func.arcosBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Exp, allowVibration) { addSymbol(Token.Func.expBracket) } } Column(modifier) { - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.pi) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.factorial) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.acTan) } - KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.log) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Pi, allowVibration) { addSymbol(Token.Const.pi) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Factorial, allowVibration) { addSymbol(Token.Operator.factorial) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.AcTan, allowVibration) { addSymbol(Token.Func.actanBracket) } + KeyboardButtonAdditional(buttonModifier, UnittoIcons.Log, allowVibration) { addSymbol(Token.Func.logBracket) } } } @@ -473,6 +477,7 @@ private fun PreviewCalculatorKeyboard() { CalculatorKeyboard( modifier = Modifier, radianMode = true, + fractional = ".", addSymbol = {}, clearSymbols = {}, deleteSymbol = {}, diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt index 071cf31e..8173b03a 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt @@ -20,6 +20,8 @@ package com.sadellie.unitto.feature.calculator.components import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +38,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -54,20 +57,23 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTransformer +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.core.ui.common.textfield.UnittoTextToolbar import com.sadellie.unitto.core.ui.common.textfield.copyWithoutGrouping import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.feature.calculator.R import java.text.SimpleDateFormat -import java.util.* +import java.util.Locale @Composable internal fun HistoryList( modifier: Modifier, historyItems: List, historyItemHeightCallback: (Int) -> Unit, + formatterSymbols: FormatterSymbols, + addTokens: (String) -> Unit, ) { val verticalArrangement by remember(historyItems) { derivedStateOf { @@ -103,13 +109,17 @@ internal fun HistoryList( item { HistoryListItem( modifier = Modifier.onPlaced { historyItemHeightCallback(it.size.height) }, - historyItem = historyItems.first() + historyItem = historyItems.first(), + formatterSymbols = formatterSymbols, + addTokens = addTokens, ) } items(historyItems.drop(1)) { historyItem -> HistoryListItem( modifier = Modifier, - historyItem = historyItem + historyItem = historyItem, + formatterSymbols = formatterSymbols, + addTokens = addTokens, ) } } @@ -120,23 +130,42 @@ internal fun HistoryList( private fun HistoryListItem( modifier: Modifier = Modifier, historyItem: HistoryItem, + formatterSymbols: FormatterSymbols, + addTokens: (String) -> Unit, ) { val clipboardManager = LocalClipboardManager.current - val expression = Formatter.format(historyItem.expression) + val expression = historyItem.expression var expressionValue by remember(expression) { mutableStateOf(TextFieldValue(expression, TextRange(expression.length))) } - val result = Formatter.format(historyItem.result) + val result = historyItem.result var resultValue by remember(result) { mutableStateOf(TextFieldValue(result, TextRange(result.length))) } + val expressionInteractionSource = remember(expression) { MutableInteractionSource() } + LaunchedEffect(expressionInteractionSource) { + expressionInteractionSource.interactions.collect { + if (it is PressInteraction.Release) addTokens(expression) + } + } + + val resultInteractionSource = remember(result) { MutableInteractionSource() } + LaunchedEffect(resultInteractionSource) { + resultInteractionSource.interactions.collect { + if (it is PressInteraction.Release) addTokens(result) + } + } + Column(modifier = modifier) { CompositionLocalProvider( LocalTextInputService provides null, LocalTextToolbar provides UnittoTextToolbar( view = LocalView.current, - copyCallback = { clipboardManager.copyWithoutGrouping(expressionValue) } + copyCallback = { + clipboardManager.copyWithoutGrouping(expressionValue, formatterSymbols) + expressionValue = expressionValue.copy(selection = TextRange(expressionValue.selection.end)) + } ) ) { BasicTextField( @@ -148,7 +177,9 @@ private fun HistoryListItem( .padding(horizontal = 8.dp) .horizontalScroll(rememberScrollState(), reverseScrolling = true), textStyle = NumbersTextStyleDisplayMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End), - readOnly = true + readOnly = true, + visualTransformation = ExpressionTransformer(formatterSymbols), + interactionSource = expressionInteractionSource ) } @@ -156,7 +187,10 @@ private fun HistoryListItem( LocalTextInputService provides null, LocalTextToolbar provides UnittoTextToolbar( view = LocalView.current, - copyCallback = { clipboardManager.copyWithoutGrouping(resultValue) } + copyCallback = { + clipboardManager.copyWithoutGrouping(resultValue, formatterSymbols) + resultValue = resultValue.copy(selection = TextRange(resultValue.selection.end)) + } ) ) { BasicTextField( @@ -168,7 +202,9 @@ private fun HistoryListItem( .padding(horizontal = 8.dp) .horizontalScroll(rememberScrollState(), reverseScrolling = true), textStyle = NumbersTextStyleDisplayMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), textAlign = TextAlign.End), - readOnly = true + readOnly = true, + visualTransformation = ExpressionTransformer(formatterSymbols), + interactionSource = resultInteractionSource ) } } @@ -200,6 +236,9 @@ private fun PreviewHistoryList() { modifier = Modifier .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .fillMaxSize(), - historyItems = historyItems - ) {} + historyItems = historyItems, + formatterSymbols = FormatterSymbols.Spaces, + historyItemHeightCallback = {}, + addTokens = {} + ) } diff --git a/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt b/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt deleted file mode 100644 index ee4d727c..00000000 --- a/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2023 Elshan Agaev - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.sadellie.unitto.feature.calculator - -import com.sadellie.unitto.core.base.Separator -import com.sadellie.unitto.core.ui.Formatter -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test - -internal class TextFieldControllerTest { - private lateinit var textFieldController: TextFieldController - - private val TextFieldController.text: String - get() = this.input.value.text - - private val TextFieldController.selection: IntRange - get() = this.input.value.selection.start..this.input.value.selection.end - - @Before - fun setUp() { - textFieldController = TextFieldController() - Formatter.setSeparator(Separator.COMMA) - } - - @Test - fun `Add when empty`() { - // Add one symbol - textFieldController.addToInput("1") - assertEquals("1", textFieldController.text) - assertEquals(1..1, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("123") - assertEquals("123", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("1234") - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - textFieldController.clearInput() - - // Add multiple - textFieldController.addToInput("123456.789") - assertEquals("123,456.789", textFieldController.text) - assertEquals(11..11, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Add when not empty one symbol at a time (check formatting)`() { - // Should be 1| - textFieldController.addToInput("1") - assertEquals("1", textFieldController.text) - assertEquals(1..1, textFieldController.selection) - - // Should be 12| - textFieldController.addToInput("2") - assertEquals("12", textFieldController.text) - assertEquals(2..2, textFieldController.selection) - - // Should be 123| - textFieldController.addToInput("3") - assertEquals("123", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - - // Should be 1,234| - textFieldController.addToInput("4") - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - - // Should be 12,345| - textFieldController.addToInput("5") - assertEquals("12,345", textFieldController.text) - assertEquals(6..6, textFieldController.selection) - } - - @Test - fun `Delete on empty input`() { - // Delete on empty input - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete last remaining symbol`() { - textFieldController.addToInput("1") - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete by one symbol (check formatting)`() { - textFieldController.addToInput("123456") - // Input is formatted into 123,456 - textFieldController.delete() - assertEquals("12,345", textFieldController.text) - assertEquals(6..6, textFieldController.selection) - textFieldController.delete() - assertEquals("1,234", textFieldController.text) - assertEquals(5..5, textFieldController.selection) - textFieldController.delete() - assertEquals("123", textFieldController.text) - println("in 123: ${textFieldController.selection}") - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols, selected before separator`() { - textFieldController.addToInput("123789456") - // Input is formatted to 123,789,456 - textFieldController.moveCursor(3..7) - textFieldController.delete() - assertEquals("123,456", textFieldController.text) - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols, selected not near separator`() { - textFieldController.addToInput("123789456") - // Input is formatted to 123,789,456 - textFieldController.moveCursor(3..9) - textFieldController.delete() - assertEquals("12,356", textFieldController.text) - assertEquals(4..4, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `Delete multiple symbols in weird input`() { - textFieldController.addToInput("123...789456") - // Input is formatted to 123...789456 - textFieldController.moveCursor(3..9) - textFieldController.delete() - assertEquals(4..4, textFieldController.selection) - assertEquals("123,456", textFieldController.text) - textFieldController.clearInput() - } - - @Test - fun `Delete illegal token when cursor is placed after it`() { - textFieldController.addToInput("cos(sin(ln(log(tan(") - textFieldController.delete() - assertEquals("cos(sin(ln(log(", textFieldController.text) - assertEquals(15..15, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(sin(ln(", textFieldController.text) - assertEquals(11..11, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(sin(", textFieldController.text) - assertEquals(8..8, textFieldController.selection) - - textFieldController.delete() - assertEquals("cos(", textFieldController.text) - assertEquals(4..4, textFieldController.selection) - - textFieldController.delete() - assertEquals("", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - - textFieldController.addToInput("1234") - // Place cursor like 1|,234 - textFieldController.moveCursor(1..1) - textFieldController.delete() - assertEquals("234", textFieldController.text) - assertEquals(0..0, textFieldController.selection) - } - - @Test - fun `Place cursor illegally`() { - textFieldController.addToInput("123456.789") - // Input is 123,456.789 - textFieldController.moveCursor(4..4) - // Cursor should be placed like this 123|,456.789 - assertEquals(3..3, textFieldController.selection) - textFieldController.clearInput() - - textFieldController.addToInput("123456.789+cos(") - // Input is 123,456.789+cos( - textFieldController.moveCursor(13..13) - // Cursor should be placed like this 123,456.789+c|os( - assertEquals(12..12, textFieldController.selection) - textFieldController.clearInput() - } - - @Test - fun `get clear input text without formatting`() { - textFieldController.addToInput("123456.789+cos(..)") - // Input is 123,456.789 - assertEquals("123456.789+cos(..)", textFieldController.inputTextWithoutFormatting()) - } - - @Test - fun `Paste completely weird stuff`() { - textFieldController.pasteSymbols("crazy stuff from clipboard") - assertEquals("", textFieldController.text) - } - - @Test - fun `Paste partially weird stuff`() { - textFieldController.pasteSymbols("some crazy stuff cos(8+9)*7= that user may have in clipboard") - assertEquals("ecos(8+9)×7ee", textFieldController.text) - } - - @Test - fun `Paste acceptable stuff that needs symbol replacement`() { - textFieldController.pasteSymbols("cos(8+9)*7") - assertEquals("cos(8+9)×7", textFieldController.text) - } - - @Test - fun `Paste acceptable stuff that does not need replacement`() { - textFieldController.pasteSymbols("cos(8+9)×7") - assertEquals("cos(8+9)×7", textFieldController.text) - } - - @Test - fun `Paste nothing`() { - textFieldController.pasteSymbols("") - assertEquals("", textFieldController.text) - } -} diff --git a/feature/converter/build.gradle.kts b/feature/converter/build.gradle.kts index 3a94684b..22ab1a5b 100644 --- a/feature/converter/build.gradle.kts +++ b/feature/converter/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { kapt(libs.androidx.room.compiler) testImplementation(libs.androidx.datastore) - implementation(libs.com.github.sadellie.exprk) implementation(libs.com.github.sadellie.themmo) implementation(libs.com.squareup.moshi) implementation(libs.com.squareup.retrofit2) @@ -46,4 +45,5 @@ dependencies { implementation(project(mapOf("path" to ":data:model"))) implementation(project(mapOf("path" to ":data:userprefs"))) implementation(project(mapOf("path" to ":data:units"))) + implementation(project(mapOf("path" to ":data:evaluatto"))) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt index 6ec8c0be..cf195c17 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterScreen.kt @@ -30,9 +30,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.ui.R @@ -46,11 +46,11 @@ import com.sadellie.unitto.feature.converter.components.TopScreenPart internal fun ConverterRoute( viewModel: ConverterViewModel = hiltViewModel(), navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToMenu: () -> Unit, navigateToSettings: () -> Unit ) { - val uiState = viewModel.uiStateFlow.collectAsStateWithLifecycle() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() ConverterScreen( uiState = uiState.value, @@ -59,9 +59,11 @@ internal fun ConverterRoute( navigateToSettings = navigateToSettings, navigateToMenu = navigateToMenu, swapMeasurements = viewModel::swapUnits, - processInput = viewModel::processInput, - deleteDigit = viewModel::deleteDigit, + processInput = viewModel::addTokens, + deleteDigit = viewModel::deleteTokens, clearInput = viewModel::clearInput, + onCursorChange = viewModel::onCursorChange, + cutCallback = viewModel::deleteTokens, ) } @@ -69,13 +71,15 @@ internal fun ConverterRoute( private fun ConverterScreen( uiState: ConverterUIState, navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToSettings: () -> Unit, navigateToMenu: () -> Unit, swapMeasurements: () -> Unit, processInput: (String) -> Unit, deleteDigit: () -> Unit, clearInput: () -> Unit, + onCursorChange: (TextRange) -> Unit, + cutCallback: () -> Unit, ) { UnittoScreenWithTopBar( title = { Text(stringResource(R.string.unit_converter)) }, @@ -92,7 +96,9 @@ private fun ConverterScreen( .centerAlignedTopAppBarColors(containerColor = Color.Transparent), content = { padding -> PortraitLandscape( - modifier = Modifier.padding(padding).fillMaxSize(), + modifier = Modifier + .padding(padding) + .fillMaxSize(), content1 = { TopScreenPart( modifier = it, @@ -101,13 +107,14 @@ private fun ConverterScreen( outputValue = uiState.resultValue, unitFrom = uiState.unitFrom, unitTo = uiState.unitTo, - networkLoading = uiState.showLoading, - networkError = uiState.showError, navigateToLeftScreen = navigateToLeftScreen, navigateToRightScreen = navigateToRightScreen, swapUnits = swapMeasurements, converterMode = uiState.mode, - formatTime = uiState.formatTime + onCursorChange = onCursorChange, + cutCallback = cutCallback, + pasteCallback = processInput, + formatterSymbols = uiState.formatterSymbols, ) }, content2 = { @@ -117,7 +124,8 @@ private fun ConverterScreen( deleteDigit = deleteDigit, clearInput = clearInput, converterMode = uiState.mode, - allowVibration = uiState.allowVibration + allowVibration = uiState.allowVibration, + fractional = uiState.formatterSymbols.fractional, ) } ) @@ -125,14 +133,6 @@ private fun ConverterScreen( ) } -class PreviewUIState: PreviewParameterProvider { - override val values: Sequence - get() = listOf( - ConverterUIState(inputValue = "1234", calculatedValue = null, resultValue = "5678", showLoading = false), - ConverterUIState(inputValue = "1234", calculatedValue = "234", resultValue = "5678", showLoading = false), - ).asSequence() -} - @Preview(widthDp = 432, heightDp = 1008, device = "spec:parent=pixel_5,orientation=portrait") @Preview(widthDp = 432, heightDp = 864, device = "spec:parent=pixel_5,orientation=portrait") @Preview(widthDp = 597, heightDp = 1393, device = "spec:parent=pixel_5,orientation=portrait") @@ -140,11 +140,9 @@ class PreviewUIState: PreviewParameterProvider { @Preview(heightDp = 432, widthDp = 864, device = "spec:parent=pixel_5,orientation=landscape") @Preview(heightDp = 597, widthDp = 1393, device = "spec:parent=pixel_5,orientation=landscape") @Composable -private fun PreviewConverterScreen( - @PreviewParameter(PreviewUIState::class) uiState: ConverterUIState -) { +private fun PreviewConverterScreen() { ConverterScreen( - uiState = ConverterUIState(inputValue = "1234", calculatedValue = null, resultValue = "5678", showLoading = false), + uiState = ConverterUIState(inputValue = TextFieldValue("1234"), calculatedValue = null, resultValue = ConversionResult.Default("5678"), showLoading = false), navigateToLeftScreen = {}, navigateToRightScreen = {_, _, _ -> }, navigateToSettings = {}, @@ -153,5 +151,7 @@ private fun PreviewConverterScreen( processInput = {}, deleteDigit = {}, clearInput = {}, + onCursorChange = {}, + cutCallback = {} ) -} \ No newline at end of file +} diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt index 6ba34cd6..f225cd85 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterUIState.kt @@ -18,7 +18,9 @@ package com.sadellie.unitto.feature.converter +import androidx.compose.ui.text.input.TextFieldValue import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.AbstractUnit /** @@ -33,23 +35,30 @@ import com.sadellie.unitto.data.model.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. * @property allowVibration When true will vibrate on button clicks. */ data class ConverterUIState( - val inputValue: String = Token._0, + val inputValue: TextFieldValue = TextFieldValue(), val calculatedValue: String? = null, - val resultValue: String = Token._0, + val resultValue: ConversionResult = ConversionResult.Default(Token.Digit._0), val showLoading: Boolean = true, val showError: Boolean = false, val unitFrom: AbstractUnit? = null, val unitTo: AbstractUnit? = null, val mode: ConverterMode = ConverterMode.DEFAULT, - val formatTime: Boolean = true, - val allowVibration: Boolean = false + val allowVibration: Boolean = false, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, ) enum class ConverterMode { DEFAULT, BASE, } + +sealed class ConversionResult { + data class Default(val result: String) : ConversionResult() + data class Time(val result: String) : ConversionResult() + data class NumberBase(val result: String) : ConversionResult() + object Loading : ConversionResult() + object Error : ConversionResult() +} diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt index cd5b64b5..746e0596 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/ConverterViewModel.kt @@ -18,11 +18,14 @@ package com.sadellie.unitto.feature.converter +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.keelar.exprk.ExpressionException -import com.github.keelar.exprk.Expressions -import com.sadellie.unitto.core.base.Token +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.addTokens +import com.sadellie.unitto.core.ui.common.textfield.deleteTokens +import com.sadellie.unitto.data.common.isExpression import com.sadellie.unitto.data.common.setMinimumRequiredScale import com.sadellie.unitto.data.common.toStringWith import com.sadellie.unitto.data.common.trimZeros @@ -39,8 +42,9 @@ import com.sadellie.unitto.data.units.remote.CurrencyUnitResponse import com.sadellie.unitto.data.userprefs.UserPreferences import com.sadellie.unitto.data.userprefs.UserPreferencesRepository 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.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -50,11 +54,8 @@ import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.math.BigDecimal -import java.math.RoundingMode import javax.inject.Inject @HiltViewModel @@ -80,25 +81,17 @@ class ConverterViewModel @Inject constructor( */ private val _unitTo: MutableStateFlow = MutableStateFlow(null) - /** - * Current input. Used when converting units. - */ - private val _input: MutableStateFlow = MutableStateFlow(Token._0) + private val _input: MutableStateFlow = MutableStateFlow(TextFieldValue()) /** * Calculation result. Null when [_input] is not an expression. */ private val _calculated: MutableStateFlow = MutableStateFlow(null) - /** - * List of latest symbols that were entered. - */ - private val _latestInputStack: MutableList = mutableListOf(_input.value) - /** * Conversion result. */ - private val _result: MutableStateFlow = MutableStateFlow(Token._0) + private val _result: MutableStateFlow = MutableStateFlow(ConversionResult.Loading) /** * True when loading something from network. @@ -113,190 +106,38 @@ class ConverterViewModel @Inject constructor( /** * Current state of UI. */ - val uiStateFlow: StateFlow = combine( + val uiState: StateFlow = combine( _input, _unitFrom, _unitTo, _calculated, _result, - _showLoading, + _userPrefs, _showError, - _userPrefs - ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, showLoadingValue, showErrorValue, prefs -> + _showLoading + ) { inputValue, unitFromValue, unitToValue, calculatedValue, resultValue, prefs, showError, showLoading -> return@combine ConverterUIState( inputValue = inputValue, calculatedValue = calculatedValue, - resultValue = resultValue, - showLoading = showLoadingValue, - showError = showErrorValue, + resultValue = when { + showError -> ConversionResult.Error + showLoading -> ConversionResult.Loading + else -> resultValue + }, unitFrom = unitFromValue, unitTo = unitToValue, - /** - * 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, - formatTime = prefs.unitConverterFormatTime, - allowVibration = prefs.enableVibrations - ) - } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - ConverterUIState() + allowVibration = prefs.enableVibrations, + formatterSymbols = AllFormatterSymbols.getById(prefs.separator) ) + }.stateIn( + viewModelScope, SharingStarted.WhileSubscribed(5000), ConverterUIState() + ) - /** - * Process input with rules. Makes sure that user input is corrected when needed. - * - * @param symbolToAdd Use 'ugly' version of symbols. - */ - fun processInput(symbolToAdd: String) { - val lastTwoSymbols = _latestInputStack.takeLast(2) - val lastSymbol: String = lastTwoSymbols.getOrNull(1) ?: lastTwoSymbols[0] - val lastSecondSymbol: String? = lastTwoSymbols.getOrNull(0) - - when (symbolToAdd) { - Token.plus, Token.divide, Token.multiply, Token.exponent -> { - when { - // Don't need expressions that start with zero - (_input.value == Token._0) -> {} - (_input.value == Token.minus) -> {} - (lastSymbol == Token.leftBracket) -> {} - (lastSymbol == Token.sqrt) -> {} - /** - * For situations like "50+-", when user clicks "/" we delete "-" so it becomes - * "50+". We don't add "/' here. User will click "/" second time and the input - * will be "50/". - */ - (lastSecondSymbol in Token.operators) and (lastSymbol == Token.minus) -> { - deleteDigit() - } - // Don't allow multiple operators near each other - (lastSymbol in Token.operators) -> { - deleteDigit() - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token._0 -> { - when { - // Don't add zero if the input is already a zero - (_input.value == Token._0) -> {} - (lastSymbol == Token.rightBracket) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - // Prevents things like "-00" and "4+000" - ((lastSecondSymbol in Token.operators + Token.leftBracket) and (lastSymbol == Token._0)) -> {} - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token._1, Token._2, Token._3, Token._4, Token._5, - Token._6, Token._7, Token._8, Token._9 -> { - // Replace single zero (default input) if it's here - when { - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.minus -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - // Don't allow multiple minuses near each other - (lastSymbol.compareTo(Token.minus) == 0) -> {} - // Don't allow plus and minus be near each other - (lastSymbol == Token.plus) -> { - deleteDigit() - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.dot -> { - if (!_input.value - .takeLastWhile { it.toString() !in Token.operators.minus(Token.dot) } - .contains(Token.dot) - ) { - setInputSymbols(symbolToAdd) - } - } - Token.leftBracket -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) || (lastSymbol in Token.digits) || (lastSymbol == Token.dot) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.rightBracket -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> {} - (lastSymbol == Token.leftBracket) -> {} - ( - _latestInputStack.filter { it == Token.leftBracket }.size == - _latestInputStack.filter { it == Token.rightBracket }.size - ) -> { - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - Token.sqrt -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - (lastSymbol == Token.rightBracket) || (lastSymbol in Token.digits) || (lastSymbol == Token.dot) -> { - processInput(Token.multiply) - setInputSymbols(symbolToAdd) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - else -> { - when { - // Replace single zero with minus (to support negative numbers) - (_input.value == Token._0) -> { - setInputSymbols(symbolToAdd, false) - } - else -> { - setInputSymbols(symbolToAdd) - } - } - } - } - } + fun addTokens(tokens: String) = _input.update { it.addTokens(tokens) } + fun deleteTokens() = _input.update { it.deleteTokens() } + fun clearInput() = _input.update { TextFieldValue() } + fun onCursorChange(selection: TextRange) = _input.update { it.copy(selection = selection) } /** * Update [_unitFrom] and set [_unitTo] from pair. Also updates stats for this [unit]. @@ -346,133 +187,76 @@ class ConverterViewModel @Inject constructor( updateCurrenciesRatesIfNeeded() } - /** - * Delete last symbol from [_input]. - */ - fun deleteDigit() { - // Default input, don't delete - if (_input.value == Token._0) return - - val lastSymbol = _latestInputStack.removeLast() - - // If this value are same, it means that after deleting there will be no symbols left, set to default - if (lastSymbol == _input.value) { - setInputSymbols(Token._0, false) - } else { - _input.update { it.removeSuffix(lastSymbol) } + private fun convertInput() { + when (_unitFrom.value?.group) { + UnitGroup.NUMBER_BASE -> convertAsNumberBase() + else -> convertAsExpression() } } - /** - * Clear [_input]. - */ - fun clearInput() { - setInputSymbols(Token._0, false) - } - - private suspend fun convertInput() { - withContext(Dispatchers.Default) { - while (isActive) { - when (_unitFrom.value?.group) { - UnitGroup.NUMBER_BASE -> convertAsNumberBase() - else -> convertAsExpression() - } - cancel() - } - } - } - - private fun convertAsNumberBase() { + private fun convertAsNumberBase() = viewModelScope.launch(Dispatchers.Default) { // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return - val unitTo = _unitTo.value ?: return + val unitFrom = _unitFrom.value ?: return@launch + val unitTo = _unitTo.value ?: return@launch val conversionResult = try { (unitFrom as NumberBaseUnit).convertToBase( - input = _input.value, + input = _input.value.text.ifEmpty { "0" }, toBase = (unitTo as NumberBaseUnit).base ) } catch (e: Exception) { when (e) { - is ClassCastException -> return + is ClassCastException -> return@launch is NumberFormatException, is IllegalArgumentException -> "" else -> throw e } } - _result.update { conversionResult } + _result.update { ConversionResult.NumberBase(conversionResult) } } - private fun convertAsExpression() { - // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return - val unitTo = _unitTo.value ?: return + private fun convertAsExpression() = viewModelScope.launch(Dispatchers.Default) { + val unitFrom = _unitFrom.value ?: return@launch + val unitTo = _unitTo.value ?: return@launch + val input = _input.value.text.ifEmpty { "0" } - // First we clean the input from garbage at the end - var cleanInput = _input.value.dropLastWhile { !it.isDigit() } - - // Now we close open brackets that user didn't close - // AUTOCLOSE ALL BRACKETS - val leftBrackets = _input.value.count { it.toString() == Token.leftBracket } - val rightBrackets = _input.value.count { it.toString() == Token.rightBracket } - val neededBrackets = leftBrackets - rightBrackets - if (neededBrackets > 0) cleanInput += Token.rightBracket.repeat(neededBrackets) - - // Now we evaluate expression in input - val evaluationResult: BigDecimal = try { - Expressions().eval(cleanInput) - .setScale(_userPrefs.value.digitsPrecision, RoundingMode.HALF_EVEN) - .trimZeros() - } catch (e: Exception) { - when (e) { - is ExpressionException, - is ArrayIndexOutOfBoundsException, - is IndexOutOfBoundsException, - is NumberFormatException, - is ArithmeticException -> { - // Invalid expression, can't do anything further - return - } - else -> throw e - } - } - - // Evaluated. Hide calculated result if no expression entered. - // 123.456 will be true - // -123.456 will be true - // -123.456-123 will be false (first minus gets removed, ending with 123.456) - if (_input.value.removePrefix(Token.minus).all { it.toString() !in Token.operators }) { - // No operators + if (input.isEmpty()) { _calculated.update { null } - } else { - _calculated.update { - evaluationResult - .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) - .trimZeros() - .toStringWith(_userPrefs.value.outputFormat) - } + _result.update { ConversionResult.Default("") } + return@launch } - // Now we just convert. - // We can use evaluation result here, input is valid. - val conversionResult: BigDecimal = unitFrom.convert( - unitTo, - evaluationResult, - _userPrefs.value.digitsPrecision - ) - - // Converted - _result.update { conversionResult.toStringWith(_userPrefs.value.outputFormat) } - } - - private fun setInputSymbols(symbol: String, add: Boolean = true) { - if (add) { - _input.update { it + symbol } - } else { - // We don't need previous input, clear entirely - _latestInputStack.clear() - _input.update { symbol } + val evaluationResult = try { + Expression(input) + .calculate() + .also { + if (it > BigDecimal.valueOf(Double.MAX_VALUE)) throw ExpressionException.TooBig() + } + .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) + .trimZeros() + } catch (e: ExpressionException.DivideByZero) { + _calculated.update { null } + return@launch + } catch (e: Exception) { + return@launch + } + + _calculated.update { + if (input.isExpression()) evaluationResult.toStringWith(_userPrefs.value.outputFormat) + else null + } + + val conversionResult = unitFrom.convert( + unitTo = unitTo, + value = evaluationResult, + scale = _userPrefs.value.digitsPrecision + ).toStringWith(_userPrefs.value.outputFormat) + + _result.update { + if ((unitFrom.group == UnitGroup.TIME) and (_userPrefs.value.unitConverterFormatTime)) + ConversionResult.Time(conversionResult) + else + ConversionResult.Default(conversionResult) } - _latestInputStack.add(symbol) } private fun incrementCounter(unit: AbstractUnit) { @@ -506,9 +290,10 @@ class ConverterViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { _showError.update { false } _showLoading.update { false } + // Units are still loading, don't convert anything yet - val unitFrom = _unitFrom.value ?: return@launch if (_unitFrom.value?.group != UnitGroup.CURRENCY) return@launch + val unitFrom = _unitFrom.value ?: return@launch // Starting to load stuff _showLoading.update { true } @@ -516,6 +301,7 @@ class ConverterViewModel @Inject constructor( val pairs: CurrencyUnitResponse = CurrencyApi.retrofitService.getCurrencyPairs(unitFrom.unitId) allUnitsRepository.updateBasicUnitsForCurrencies(pairs.currency) + convertAsExpression() } catch (e: Exception) { // Dangerous and stupid, but who cares _showError.update { true } @@ -539,7 +325,7 @@ class ConverterViewModel @Inject constructor( } private fun startObserving() { - viewModelScope.launch(Dispatchers.Default) { + viewModelScope.launch { merge(_input, _unitFrom, _unitTo, _showLoading, _userPrefs).collectLatest { convertInput() } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt index e999fe20..9d54af01 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/Keyboard.kt @@ -27,7 +27,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.core.ui.common.ColumnWithConstraints import com.sadellie.unitto.core.ui.common.KeyboardButtonFilled import com.sadellie.unitto.core.ui.common.KeyboardButtonLight @@ -77,11 +76,12 @@ internal fun Keyboard( deleteDigit: () -> Unit = {}, clearInput: () -> Unit = {}, converterMode: ConverterMode, - allowVibration: Boolean + allowVibration: Boolean, + fractional: String, ) { Crossfade(converterMode, modifier = modifier) { when (it) { - ConverterMode.DEFAULT -> DefaultKeyboard(addDigit, clearInput, deleteDigit, allowVibration) + ConverterMode.DEFAULT -> DefaultKeyboard(addDigit, clearInput, deleteDigit, allowVibration, fractional) ConverterMode.BASE -> BaseKeyboard(addDigit, clearInput, deleteDigit, allowVibration) } } @@ -92,9 +92,10 @@ private fun DefaultKeyboard( addDigit: (String) -> Unit, clearInput: () -> Unit, deleteDigit: () -> Unit, - allowVibration: Boolean + allowVibration: Boolean, + fractional: String, ) { - val fractionalIcon = remember { if (Formatter.fractional == Token.dot) UnittoIcons.Dot else UnittoIcons.Comma } + val fractionalIcon = remember { if (fractional == Token.Digit.dot) UnittoIcons.Dot else UnittoIcons.Comma } ColumnWithConstraints { // Button modifier val bModifier = Modifier @@ -104,34 +105,34 @@ private fun DefaultKeyboard( val cModifier = Modifier.weight(1f).padding(vertical = it.maxHeight * 0.01f) val horizontalArrangement = Arrangement.spacedBy(it.maxWidth * 0.03f) Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.LeftBracket, allowVibration) { addDigit(Token.leftBracket) } - KeyboardButtonFilled(bModifier, UnittoIcons.RightBracket, allowVibration) { addDigit(Token.rightBracket) } - KeyboardButtonFilled(bModifier, UnittoIcons.Exponent, allowVibration) { addDigit(Token.exponent) } - KeyboardButtonFilled(bModifier, UnittoIcons.SquareRoot, allowVibration) { addDigit(Token.sqrt) } + KeyboardButtonFilled(bModifier, UnittoIcons.LeftBracket, allowVibration) { addDigit(Token.Operator.leftBracket) } + KeyboardButtonFilled(bModifier, UnittoIcons.RightBracket, allowVibration) { addDigit(Token.Operator.rightBracket) } + KeyboardButtonFilled(bModifier, UnittoIcons.Exponent, allowVibration) { addDigit(Token.Operator.power) } + KeyboardButtonFilled(bModifier, UnittoIcons.SquareRoot, allowVibration) { addDigit(Token.Operator.sqrt) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token._7) } - KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token._8) } - KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token._9) } - KeyboardButtonFilled(bModifier, UnittoIcons.Divide, allowVibration) { addDigit(Token.divide) } + KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token.Digit._7) } + KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token.Digit._8) } + KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token.Digit._9) } + KeyboardButtonFilled(bModifier, UnittoIcons.Divide, allowVibration) { addDigit(Token.Operator.divide) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token._4) } - KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token._5) } - KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token._6) } - KeyboardButtonFilled(bModifier, UnittoIcons.Multiply, allowVibration) { addDigit(Token.multiply) } + KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token.Digit._4) } + KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token.Digit._5) } + KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token.Digit._6) } + KeyboardButtonFilled(bModifier, UnittoIcons.Multiply, allowVibration) { addDigit(Token.Operator.multiply) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token._1) } - KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token._2) } - KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token._3) } - KeyboardButtonFilled(bModifier, UnittoIcons.Minus, allowVibration) { addDigit(Token.minus) } + KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token.Digit._1) } + KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token.Digit._2) } + KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token.Digit._3) } + KeyboardButtonFilled(bModifier, UnittoIcons.Minus, allowVibration) { addDigit(Token.Operator.minus) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token._0) } - KeyboardButtonLight(bModifier, fractionalIcon, allowVibration) { addDigit(Token.dot) } + KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token.Digit._0) } + KeyboardButtonLight(bModifier, fractionalIcon, allowVibration) { addDigit(Token.Digit.dot) } KeyboardButtonLight(bModifier, UnittoIcons.Backspace, allowVibration, clearInput) { deleteDigit() } - KeyboardButtonFilled(bModifier, UnittoIcons.Plus, allowVibration) { addDigit(Token.plus) } + KeyboardButtonFilled(bModifier, UnittoIcons.Plus, allowVibration) { addDigit(Token.Operator.plus) } } } } @@ -152,32 +153,32 @@ private fun BaseKeyboard( val cModifier = Modifier.weight(1f).padding(vertical = it.maxHeight * 0.01f) val horizontalArrangement = Arrangement.spacedBy(it.maxWidth * 0.03f) Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.KeyA, allowVibration) { addDigit(Token.baseA) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyB, allowVibration) { addDigit(Token.baseB) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyC, allowVibration) { addDigit(Token.baseC) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyA, allowVibration) { addDigit(Token.Letter._A) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyB, allowVibration) { addDigit(Token.Letter._B) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyC, allowVibration) { addDigit(Token.Letter._C) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonFilled(bModifier, UnittoIcons.KeyD, allowVibration) { addDigit(Token.baseD) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyE, allowVibration) { addDigit(Token.baseE) } - KeyboardButtonFilled(bModifier, UnittoIcons.KeyF, allowVibration) { addDigit(Token.baseF) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyD, allowVibration) { addDigit(Token.Letter._D) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyE, allowVibration) { addDigit(Token.Letter._E) } + KeyboardButtonFilled(bModifier, UnittoIcons.KeyF, allowVibration) { addDigit(Token.Letter._F) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token._7) } - KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token._8) } - KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token._9) } + KeyboardButtonLight(bModifier, UnittoIcons.Key7, allowVibration) { addDigit(Token.Digit._7) } + KeyboardButtonLight(bModifier, UnittoIcons.Key8, allowVibration) { addDigit(Token.Digit._8) } + KeyboardButtonLight(bModifier, UnittoIcons.Key9, allowVibration) { addDigit(Token.Digit._9) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token._4) } - KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token._5) } - KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token._6) } + KeyboardButtonLight(bModifier, UnittoIcons.Key4, allowVibration) { addDigit(Token.Digit._4) } + KeyboardButtonLight(bModifier, UnittoIcons.Key5, allowVibration) { addDigit(Token.Digit._5) } + KeyboardButtonLight(bModifier, UnittoIcons.Key6, allowVibration) { addDigit(Token.Digit._6) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token._1) } - KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token._2) } - KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token._3) } + KeyboardButtonLight(bModifier, UnittoIcons.Key1, allowVibration) { addDigit(Token.Digit._1) } + KeyboardButtonLight(bModifier, UnittoIcons.Key2, allowVibration) { addDigit(Token.Digit._2) } + KeyboardButtonLight(bModifier, UnittoIcons.Key3, allowVibration) { addDigit(Token.Digit._3) } } Row(cModifier, horizontalArrangement) { - KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token._0) } + KeyboardButtonLight(bModifier, UnittoIcons.Key0, allowVibration) { addDigit(Token.Digit._0) } KeyboardButtonLight( Modifier.fillMaxSize().weight(2f).padding(it.maxWidth * 0.015f, it.maxHeight * 0.008f), UnittoIcons.Backspace, allowVibration, clearInput) { deleteDigit() } } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt deleted file mode 100644 index e344d063..00000000 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-2022 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.converter.components - -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.SizeTransform -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.with -import androidx.compose.foundation.combinedClickable -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.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.material.ripple.rememberRipple -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.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.sadellie.unitto.core.ui.R -import com.sadellie.unitto.core.ui.common.textfield.InputTextField -import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge - -/** - * Component for input and output - * - * @param modifier Modifier that is applied to [LazyRow]. - * @param primaryText Primary text to show (input/output). - * @param secondaryText Secondary text to show (input, calculated result). - * @param helperText Helper text below current text (short unit name). - * @param textToCopy Text that will be copied to clipboard when long-clicking. - */ -@Composable -internal fun MyTextField( - modifier: Modifier, - primaryText: @Composable () -> String, - secondaryText: String?, - helperText: String, - textToCopy: String, - onClick: () -> Unit = {}, -) { - val clipboardManager = LocalClipboardManager.current - val mc = LocalContext.current - val textToShow: String = primaryText() - val copiedText: String = - stringResource(R.string.copied, textToCopy) - - Column( - modifier = Modifier - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(), - onClick = onClick, - onLongClick = { - clipboardManager.setText(AnnotatedString(secondaryText ?: textToShow)) - Toast - .makeText(mc, copiedText, Toast.LENGTH_SHORT) - .show() - } - ) - ) { - LazyRow( - modifier = modifier - .wrapContentHeight() - .weight(2f), - reverseLayout = true, - horizontalArrangement = Arrangement.End, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - item { - AnimatedContent( - targetState = textToShow, - transitionSpec = { - // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() - // Exit animation - with fadeOut()) - .using(SizeTransform(clip = false)) - } - ) { - InputTextField( - modifier = Modifier.fillMaxWidth(), - value = it.take(1000), - textStyle = NumbersTextStyleDisplayLarge.copy(textAlign = TextAlign.End) - ) - } - } - } - - AnimatedVisibility( - modifier = Modifier.weight(1f), - visible = !secondaryText.isNullOrEmpty(), - enter = expandVertically(), - exit = shrinkVertically() - ) { - LazyRow( - modifier = modifier - .wrapContentHeight(), - reverseLayout = true, - horizontalArrangement = Arrangement.End, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - item { - AnimatedContent( - targetState = secondaryText, - transitionSpec = { - // Enter animation - (expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn() - // Exit animation - with fadeOut()) - .using(SizeTransform(clip = false)) - } - ) { - InputTextField( - modifier = Modifier.fillMaxWidth(), - value = it?.take(1000) ?: "", - textStyle = NumbersTextStyleDisplayLarge.copy( - textAlign = TextAlign.End, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ), - minRatio = 0.7f - ) - } - } - } - } - - AnimatedContent( - modifier = Modifier - .align(Alignment.End) - .padding(horizontal = 8.dp) - .weight(1f), - targetState = helperText - ) { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - } -} 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 39f1af65..1a971bb9 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 @@ -20,6 +20,7 @@ package com.sadellie.unitto.feature.converter.components import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -50,13 +51,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.base.Token import com.sadellie.unitto.core.ui.R import com.sadellie.unitto.core.ui.common.ColumnWithConstraints -import com.sadellie.unitto.core.ui.common.textfield.InputTextField +import com.sadellie.unitto.core.ui.common.textfield.ExpressionTextField +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.UnformattedTextField +import com.sadellie.unitto.core.ui.common.textfield.formatExpression +import com.sadellie.unitto.core.ui.common.textfield.formatTime import com.sadellie.unitto.data.model.AbstractUnit -import com.sadellie.unitto.data.model.UnitGroup +import com.sadellie.unitto.feature.converter.ConversionResult import com.sadellie.unitto.feature.converter.ConverterMode /** @@ -70,29 +77,27 @@ import com.sadellie.unitto.feature.converter.ConverterMode * @param outputValue Current output value (like big decimal). * @param unitFrom [AbstractUnit] on the left. * @param unitTo [AbstractUnit] on the right. - * @param networkLoading Are we loading data from network? Shows loading text in TextFields. - * @param networkError Did we got errors while trying to get data from network. * @param navigateToLeftScreen Function that is called when clicking left unit selection button. * @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]. */ @Composable internal fun TopScreenPart( modifier: Modifier, - inputValue: String, + inputValue: TextFieldValue, calculatedValue: String?, - outputValue: String, + outputValue: ConversionResult, unitFrom: AbstractUnit?, unitTo: AbstractUnit?, - networkLoading: Boolean, - networkError: Boolean, navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, swapUnits: () -> Unit, converterMode: ConverterMode, - formatTime: Boolean, + onCursorChange: (TextRange) -> Unit, + cutCallback: () -> Unit, + pasteCallback: (String) -> Unit, + formatterSymbols: FormatterSymbols, ) { var swapped by remember { mutableStateOf(false) } val swapButtonRotation: Float by animateFloatAsState( @@ -104,24 +109,48 @@ internal fun TopScreenPart( ColumnWithConstraints( modifier = modifier, ) { - InputTextField( - modifier = Modifier.weight(2f), - value = when (converterMode) { - ConverterMode.BASE -> inputValue.uppercase() - else -> Formatter.format(inputValue) - }, - minRatio = 0.7f - ) + Crossfade(modifier = Modifier.weight(2f), targetState = converterMode) { mode -> + if (mode == ConverterMode.BASE) { + UnformattedTextField( + modifier = Modifier, + value = inputValue, + onCursorChange = onCursorChange, + minRatio = 0.7f, + cutCallback = cutCallback, + pasteCallback = pasteCallback, + placeholder = Token.Digit._0 + ) + } else { + ExpressionTextField( + modifier = Modifier, + value = inputValue, + onCursorChange = onCursorChange, + formatterSymbols = formatterSymbols, + minRatio = 0.7f, + cutCallback = cutCallback, + pasteCallback = pasteCallback, + placeholder = Token.Digit._0 + ) + } + } AnimatedVisibility( visible = !calculatedValue.isNullOrEmpty(), modifier = Modifier.weight(1f), enter = expandVertically(clip = false), exit = shrinkVertically(clip = false) ) { - InputTextField( - value = calculatedValue?.let { value -> Formatter.format(value) } ?: "", - minRatio = 0.7f, - textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + var calculatedTextFieldValue by remember(calculatedValue) { + mutableStateOf( + TextFieldValue(calculatedValue?.formatExpression(formatterSymbols) ?: "") + ) + } + ExpressionTextField( + modifier = Modifier, + value = calculatedTextFieldValue, + onCursorChange = { calculatedTextFieldValue = calculatedTextFieldValue.copy(selection = it) }, + formatterSymbols = formatterSymbols, + textColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + minRatio = 0.7f ) } AnimatedContent( @@ -141,24 +170,74 @@ internal fun TopScreenPart( ) } - InputTextField( - modifier = Modifier - .weight(2f), - value = 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( - context = mContext, - input = calculatedValue ?: inputValue, - basicUnit = unitFrom?.basicUnit + when (outputValue) { + is ConversionResult.Default -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf(TextFieldValue(outputValue.result)) + } + ExpressionTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + formatterSymbols = formatterSymbols, + readOnly = true, + minRatio = 0.7f + ) + } + + is ConversionResult.Time -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf( + TextFieldValue( + outputValue.result + .formatTime(mContext, unitTo?.basicUnit, formatterSymbols) + ) ) } - else -> Formatter.format(outputValue) - }, - minRatio = 0.7f, - ) + UnformattedTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.NumberBase -> { + var outputTextFieldValue: TextFieldValue by remember(outputValue) { + mutableStateOf(TextFieldValue(outputValue.result.uppercase())) + } + UnformattedTextField( + modifier = Modifier.weight(2f), + value = outputTextFieldValue, + onCursorChange = { outputTextFieldValue = outputTextFieldValue.copy(selection = it) }, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.Loading -> { + UnformattedTextField( + modifier = Modifier.weight(2f), + value = TextFieldValue(stringResource(R.string.loading_label)), + onCursorChange = {}, + minRatio = 0.7f, + readOnly = true + ) + } + + is ConversionResult.Error -> { + UnformattedTextField( + modifier = Modifier.weight(2f), + value = TextFieldValue(stringResource(R.string.error_label)), + onCursorChange = {}, + minRatio = 0.7f, + readOnly = true, + textColor = MaterialTheme.colorScheme.error + ) + } + } + AnimatedContent( modifier = Modifier.fillMaxWidth(), targetState = stringResource(unitTo?.shortName ?: R.string.loading_label), @@ -206,10 +285,16 @@ internal fun TopScreenPart( onClick = { if (unitTo == null) return@UnitSelectionButton if (unitFrom == null) return@UnitSelectionButton + + val input = when (outputValue) { + is ConversionResult.Error, ConversionResult.Loading -> null + else -> calculatedValue ?: inputValue.text + } + navigateToRightScreen( unitFrom.unitId, unitTo.unitId, - calculatedValue ?: inputValue + input ) }, label = unitTo?.displayName ?: R.string.loading_label, diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt index aed19e68..ca10a128 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/navigation/ConverterNavigation.kt @@ -28,7 +28,7 @@ private val converterRoute: String by lazy { TopLevelDestinations.Converter.rout fun NavGraphBuilder.converterScreen( navigateToLeftScreen: (String) -> Unit, - navigateToRightScreen: (unitFrom: String, unitTo: String, input: String) -> Unit, + navigateToRightScreen: (unitFrom: String, unitTo: String, input: String?) -> Unit, navigateToSettings: () -> Unit, navigateToMenu: () -> Unit, viewModel: ConverterViewModel diff --git a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt b/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt deleted file mode 100644 index 013d6ae4..00000000 --- a/feature/converter/src/test/java/com/sadellie/unitto/feature/converter/ConverterViewModelTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Unitto is a unit converter for Android - * Copyright (c) 2022-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.converter - -import androidx.room.Room -import com.sadellie.unitto.core.base.Token -import com.sadellie.unitto.data.database.UnitsRepository -import com.sadellie.unitto.data.database.UnittoDatabase -import com.sadellie.unitto.data.units.AllUnitsRepository -import com.sadellie.unitto.data.userprefs.DataStoreModule -import com.sadellie.unitto.data.userprefs.UserPreferencesRepository -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment -import org.robolectric.annotation.Config - -@OptIn(ExperimentalCoroutinesApi::class) -@Config(manifest = Config.NONE) -@RunWith(RobolectricTestRunner::class) -class ConverterViewModelTest { - - @ExperimentalCoroutinesApi - @get:Rule - val coroutineTestRule = CoroutineTestRule() - - private lateinit var viewModel: ConverterViewModel - private val allUnitsRepository = AllUnitsRepository() - private val database = Room.inMemoryDatabaseBuilder( - RuntimeEnvironment.getApplication(), - UnittoDatabase::class.java - ).build() - - @Before - fun setUp() { - viewModel = ConverterViewModel( - userPrefsRepository = UserPreferencesRepository( - DataStoreModule() - .provideUserPreferencesDataStore( - RuntimeEnvironment.getApplication() - ) - ), - unitRepository = UnitsRepository( - database.unitsDao() - ), - allUnitsRepository = allUnitsRepository - ) - } - - @After - fun tearDown() { - database.close() - } - - @Test - fun `test 0`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - inputOutputTest("0", "0") - inputOutputTest("123000", "123000") - inputOutputTest("123.000", "123.000") - inputOutputTest("-000", "-0") - inputOutputTest("12+000", "12+0") - inputOutputTest("√000", "√0") - inputOutputTest("(000", "(0") - inputOutputTest("(1+12)000", "(1+12)*0") - inputOutputTest("(1.002+120)000", "(1.002+120)*0") - - collectJob.cancel() - } - - @Test - fun `test digits from 1 to 9`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("123456789", "123456789") - inputOutputTest("(1+1)111", "(1+1)*111") - collectJob.cancel() - } - - @Test - fun `test plus, divide, multiply and exponent operators`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0+++", "0") - inputOutputTest("123+++", "123+") - inputOutputTest("1-***", "1*") - inputOutputTest("1/-+++", "1+") - inputOutputTest("0^^^", "0") - inputOutputTest("12^^^", "12^") - inputOutputTest("(^^^", "(") - inputOutputTest("(8+9)^^^", "(8+9)^") - collectJob.cancel() - } - - @Test - fun `test dot`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0...", "0.") - inputOutputTest("1...", "1.") - inputOutputTest("1+...", "1+.") - inputOutputTest("√...", "√.") - inputOutputTest("√21...", "√21.") - inputOutputTest("√21+1.01-.23...", "√21+1.01-.23") - collectJob.cancel() - } - - @Test - fun `test minus`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0---", "-") - inputOutputTest("12---", "12-") - inputOutputTest("12+---", "12-") - inputOutputTest("12/---", "12/-") - inputOutputTest("√---", "√-") - inputOutputTest("√///", "√") - inputOutputTest("12^----", "12^-") - collectJob.cancel() - } - - @Test - fun `test brackets`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0)))", "0") - inputOutputTest("0(((", "(((") - inputOutputTest("√(10+2)(", "√(10+2)*(") - inputOutputTest("√(10+2./(", "√(10+2./(") - inputOutputTest("0()()))((", "((((") - inputOutputTest("√(10+2)^(", "√(10+2)^(") - collectJob.cancel() - } - - @Test - fun `test square root`() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - inputOutputTest("0√√√", "√√√") - inputOutputTest("123√√√", "123*√√√") - collectJob.cancel() - } - - @Test - fun deleteSymbolTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - listOf( - Token._1, Token._2, Token._3, Token._4, Token._5, - Token._6, Token._7, Token._8, Token._9, Token._0, - Token.dot, Token.comma, Token.leftBracket, Token.rightBracket, - Token.plus, Token.minus, Token.divide, Token.multiply, - Token.exponent, Token.sqrt - ).forEach { - // We enter one symbol and delete it, should be default as a result - viewModel.processInput(it) - viewModel.deleteDigit() - assertEquals("0", viewModel.uiStateFlow.value.inputValue) - } - viewModel.clearInput() - - // This should not delete default input (0) - viewModel.deleteDigit() - - // Now we check that we can delete multiple values - viewModel.processInput(Token._3) - viewModel.processInput(Token.sqrt) - viewModel.processInput(Token._9) - viewModel.deleteDigit() - assertEquals("3*√", viewModel.uiStateFlow.value.inputValue) - - collectJob.cancel() - } - - @Test - fun clearInputTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - - viewModel.processInput(Token._3) - viewModel.clearInput() - assertEquals(null, viewModel.uiStateFlow.value.calculatedValue) - - viewModel.processInput(Token._3) - viewModel.processInput(Token.multiply) - viewModel.clearInput() - assertEquals(null, viewModel.uiStateFlow.value.calculatedValue) - - collectJob.cancel() - } - - @Test - fun swapUnitsTest() = runTest { - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.uiStateFlow.collect() - } - val initialFrom = viewModel.uiStateFlow.value.unitFrom?.unitId - val initialTo = viewModel.uiStateFlow.value.unitTo?.unitId - - viewModel.swapUnits() - assertEquals(initialTo, viewModel.uiStateFlow.value.unitFrom?.unitId) - assertEquals(initialFrom, viewModel.uiStateFlow.value.unitTo?.unitId) - - collectJob.cancel() - } - - /** - * Takes [input] sequence as a single string (e.g. "123-23") and compares it with [output]. - */ - private fun inputOutputTest(input: String, output: String) { - // Enter everything - input.forEach { - viewModel.processInput(it.toString()) - } - assertEquals(output, viewModel.uiStateFlow.value.inputValue) - viewModel.clearInput() - } -} diff --git a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt index 0b6a0598..488ab511 100644 --- a/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/sadellie/unitto/feature/settings/SettingsViewModel.kt @@ -21,7 +21,6 @@ package com.sadellie.unitto.feature.settings import androidx.compose.ui.graphics.Color import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sadellie.unitto.core.ui.Formatter import com.sadellie.unitto.data.model.UnitGroup import com.sadellie.unitto.data.model.UnitsListSorting import com.sadellie.unitto.data.unitgroups.UnitGroupsRepository @@ -32,7 +31,6 @@ import io.github.sadellie.themmo.MonetMode import io.github.sadellie.themmo.ThemingMode import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ItemPosition @@ -44,7 +42,6 @@ class SettingsViewModel @Inject constructor( private val unitGroupsRepository: UnitGroupsRepository, ) : ViewModel() { val userPrefs = userPrefsRepository.userPreferencesFlow - .onEach { Formatter.setSeparator(it.separator) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserPreferences() ) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt index 21f94644..083e0292 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt @@ -32,7 +32,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.ui.Formatter +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols +import com.sadellie.unitto.core.ui.common.textfield.formatExpression import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.NumberBaseUnit import com.sadellie.unitto.data.model.UnitGroup @@ -60,7 +61,7 @@ internal fun RightSideScreen( navigateUp: () -> Unit, navigateToSettingsAction: () -> Unit, selectAction: (AbstractUnit) -> Unit, - inputValue: String, + inputValue: String?, unitFrom: AbstractUnit ) { val uiState = viewModel.mainFlow.collectAsStateWithLifecycle() @@ -69,12 +70,21 @@ internal fun RightSideScreen( val convertMethod: (AbstractUnit) -> String = try { val inputAsBigDecimal = BigDecimal(inputValue) - if (unitFrom.group == UnitGroup.NUMBER_BASE) { - { (convertForSecondaryNumberBase(inputValue, unitFrom, it)) } - } else { - { convertForSecondary(inputAsBigDecimal, unitFrom, it) } + + when { + inputValue.isNullOrEmpty() -> { { "" } } + + unitFrom.group == UnitGroup.NUMBER_BASE -> { + { (convertForSecondaryNumberBase(inputValue, unitFrom, it)) } + } + + else -> { + { + convertForSecondary(inputAsBigDecimal, unitFrom, it, uiState.value.formatterSymbols) + } + } } - } catch(e: Exception) { + } catch (e: Exception) { { "" } } @@ -131,15 +141,26 @@ internal fun RightSideScreen( } } -private fun convertForSecondary(inputValue: BigDecimal, unitFrom: AbstractUnit, unitTo: AbstractUnit): String { - return Formatter.format( - unitFrom.convert(unitTo, inputValue, 3).toPlainString() - ) + " " +private fun convertForSecondary( + inputValue: BigDecimal, + unitFrom: AbstractUnit, + unitTo: AbstractUnit, + formatterSymbols: FormatterSymbols +): String { + return unitFrom.convert(unitTo, inputValue, 3).toPlainString() + .formatExpression(formatterSymbols) + " " } -private fun convertForSecondaryNumberBase(inputValue: String, unitFrom: AbstractUnit, unitTo: AbstractUnit): String { +private fun convertForSecondaryNumberBase( + inputValue: String, + unitFrom: AbstractUnit, + unitTo: AbstractUnit +): String { return try { - (unitFrom as NumberBaseUnit).convertToBase(inputValue, (unitTo as NumberBaseUnit).base) + " " + (unitFrom as NumberBaseUnit).convertToBase( + inputValue, + (unitTo as NumberBaseUnit).base + ) + " " } catch (e: NumberFormatException) { "" } catch (e: ClassCastException) { diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt index f012c8ea..57e15201 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/SecondScreenUIState.kt @@ -18,6 +18,7 @@ package com.sadellie.unitto.feature.unitslist +import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols import com.sadellie.unitto.data.model.AbstractUnit import com.sadellie.unitto.data.model.UnitGroup @@ -35,5 +36,6 @@ data class SecondScreenUIState( val unitsToShow: Map> = emptyMap(), val searchQuery: String = "", val shownUnitGroups: List = listOf(), - val chosenUnitGroup: UnitGroup? = null + val chosenUnitGroup: UnitGroup? = null, + val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces, ) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt index af2fea90..483a2351 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/UnitsListViewModel.kt @@ -21,6 +21,7 @@ package com.sadellie.unitto.feature.unitslist import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sadellie.unitto.core.ui.common.textfield.AllFormatterSymbols import com.sadellie.unitto.data.database.UnitsEntity import com.sadellie.unitto.data.database.UnitsRepository import com.sadellie.unitto.data.model.AbstractUnit @@ -74,6 +75,7 @@ class UnitsListViewModel @Inject constructor( searchQuery = searchQuery, chosenUnitGroup = chosenUnitGroup, shownUnitGroups = shownUnitGroups, + formatterSymbols = AllFormatterSymbols.getById(userPrefs.separator) ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SecondScreenUIState()) diff --git a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt index 8ad4d065..5692e92b 100644 --- a/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt +++ b/feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/navigation/UnitsListNavigation.kt @@ -37,7 +37,7 @@ fun NavController.navigateToLeftSide(unitFromId: String) { navigate("$leftSideRoute/$unitFromId") } -fun NavController.navigateToRightSide(unitFromId: String, unitToId: String, input: String) { +fun NavController.navigateToRightSide(unitFromId: String, unitToId: String, input: String?) { navigate("$rightSideRoute/$unitFromId/$unitToId/$input") } @@ -72,7 +72,7 @@ fun NavGraphBuilder.rightScreen( ) { val unitFromId = it.arguments?.getString(unitFromIdArg) ?: return@composable val unitToId = it.arguments?.getString(unitToIdArg) ?: return@composable - val input = it.arguments?.getString(inputArg) ?: return@composable + val input = it.arguments?.getString(inputArg) viewModel.setSelectedChip(unitFromId, false) RightSideScreen( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5ec2f09..8644bca8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,6 @@ comSquareupMoshi = "1.14.0" comSquareupRetrofit2 = "2.9.0" comGithubSadellieThemmo = "ed4063f70f" orgBurnoutcrewComposereorderable = "0.9.6" -comGithubSadellieExprk = "e55cba8f41" -mxParser = "5.2.1" junit = "4.13.2" androidxTest = "1.5.0" androidxTestExt = "1.1.4" @@ -62,9 +60,7 @@ com-squareup-moshi = { group = "com.squareup.moshi", name = "moshi-kotlin", vers com-squareup-retrofit2 = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "comSquareupRetrofit2" } com-github-sadellie-themmo = { group = "com.github.sadellie", name = "themmo", version.ref = "comGithubSadellieThemmo" } org-burnoutcrew-composereorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version.ref = "orgBurnoutcrewComposereorderable" } -com-github-sadellie-exprk = { group = "com.github.sadellie", name = "ExprK", version.ref = "comGithubSadellieExprk" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } -org-mariuszgromada-math-mxparser = { group = "org.mariuszgromada.math", name = "MathParser.org-mXparser", version.ref = "mxParser" } # classpath android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 320aa67e..7e9e80f9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,12 +24,13 @@ include(":feature:converter") include(":feature:unitslist") include(":feature:calculator") include(":feature:settings") -include(":feature:epoch") +// include(":feature:epoch") include(":data:userprefs") include(":data:unitgroups") include(":data:licenses") -include(":data:epoch") +// include(":data:epoch") include(":data:calculator") include(":data:database") include(":data:model") include(":data:common") +include(":data:evaluatto")