Tiny fixes

* New Token-based System™ for input
* Unmathematical/Unethical/Cursed percentage support
* New Expression Evaluator™ that is not a dumpster fire
* Cursor in Converter input text field
* Removed garbage code (-200 KB)

proper fix for #12
closes #44
closes #52

this is a squashed commit
This commit is contained in:
Sad Ellie 2023-05-13 21:29:25 +03:00
parent fcebc14ffb
commit 8f1847618a
53 changed files with 2529 additions and 2156 deletions

View File

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

View File

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

View File

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

View File

@ -1270,6 +1270,7 @@
<string name="calculator_clear_history_title">Clear history</string>
<string name="calculator_clear_history_support">All expressions from history will be deleted forever. This action can\'t be undone!</string>
<string name="calculator_no_history">No history</string>
<string name="divide_by_zero_error">Can\'t divide by 0</string>
<!--Precision-->
<string name="precision_setting_support">Number of decimal places</string>

View File

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

View File

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

View File

@ -0,0 +1,65 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.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 }
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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.812+"
private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)"
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.812+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.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123 456÷89 078..9×0.812+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.812+", INCOMPLETE_EXPR.format())
assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123 456÷89 078..9×0.812+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.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123,456÷89,078..9×0.812+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.812+", INCOMPLETE_EXPR.format())
assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123,456÷89,078..9×0.812+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,812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123.456÷89.078,,9×0,812+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,812+", INCOMPLETE_EXPR.format())
assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
assertEquals("50+123.456÷89.078,,9×0,812+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())
}
}

View File

@ -26,4 +26,5 @@ android {
dependencies {
implementation(project(mapOf("path" to ":core:base")))
testImplementation(libs.junit)
}

View File

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

View File

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

View File

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

1
data/evaluatto/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

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

View File

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Unitto is a unit converter for Android
~ Copyright (c) 2023 Elshan Agaev
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest>
</manifest>

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String> {
var cursor = 0
val tokens: MutableList<String> = 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<String>.repairLexicon(): List<String> {
return this
.missingClosingBrackets()
.missingMultiply()
.unpackAlPercents()
}
private fun List<String>.missingClosingBrackets(): List<String> {
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<String>.missingMultiply(): List<String> {
val results = this.toMutableList()
val insertIndexes = mutableListOf<Int>()
// 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<String>.unpackAlPercents(): List<String> {
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<String>.unpackPercentAt(percentIndex: Int): List<String> {
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<String>.getNumberOrExpressionBefore(pos: Int): List<String> {
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<String>.getBaseBefore(pos: Int): List<String> {
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)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)×(52)", "21")
@Test
fun expression4() = assertExpr("8÷4+2×3", "8")
@Test
fun expression5() = assertExpr("2^3+4^25×6", "-6")
@Test
fun expression6() = assertExpr("(102)^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^43^2", "27")
@Test
fun expression10() = assertExpr("sin(π÷3)×cos(π÷6)+tan(π÷4)√3", "0.017949192431123")
@Test
fun expression11() = assertExpr("2^62^5+2^42^3+2^2^1+2^0", "41.25")
@Test
fun expression12() = assertExpr("2×(3+4)×(52)÷6", "7")
}

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package io.github.sadellie.evaluatto
import org.junit.Test
class FixLexiconTest {
@Test
fun `missing multiply`() {
assertLex(
"2×(69420)", "2(69420)"
)
assertLex(
"0.×(69420)", "0.(69420)"
)
assertLex(
".0×(69420)", ".0(69420)"
)
assertLex(
".×(69420)", ".(69420)"
)
assertLex(
"2×(69420)×(234)×cos(9)×tan((sin⁻¹(.9)))",
"2(69420)(234)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%)"
)
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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 <T : Throwable?> assertExprFail(
expectedThrowable: Class<T>?,
expr: String,
radianMode: Boolean = true
) {
Assert.assertThrows(expectedThrowable) {
val calculated = Expression(expr, radianMode = radianMode).calculate()
println(calculated)
}
}
fun assertLex(expected: List<String>, actual: String) =
Assert.assertEquals(expected, Tokenizer(actual).tokenize())
fun assertLex(expected: String, actual: String) =
Assert.assertEquals(expected, Tokenizer(actual).tokenize().joinToString(""))

View File

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

View File

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

View File

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

View File

@ -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 = {},

View File

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

View File

@ -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<UserPreferences> =
userPrefsRepository.userPreferencesFlow.stateIn(
@ -56,131 +60,91 @@ internal class CalculatorViewModel @Inject constructor(
UserPreferences()
)
private val _output: MutableStateFlow<String> = MutableStateFlow("")
private val _input: MutableStateFlow<TextFieldValue> = MutableStateFlow(TextFieldValue())
private val _output: MutableStateFlow<CalculationResult> =
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
}
}
}
}

View File

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

View File

@ -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 = {},

View File

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

View File

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

View File

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

View File

@ -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<ConverterUIState> {
override val values: Sequence<ConverterUIState>
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<ConverterUIState> {
@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 = {}
)
}

View File

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

View File

@ -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<AbstractUnit?> = MutableStateFlow(null)
/**
* Current input. Used when converting units.
*/
private val _input: MutableStateFlow<String> = MutableStateFlow(Token._0)
private val _input: MutableStateFlow<TextFieldValue> = MutableStateFlow(TextFieldValue())
/**
* Calculation result. Null when [_input] is not an expression.
*/
private val _calculated: MutableStateFlow<String?> = MutableStateFlow(null)
/**
* List of latest symbols that were entered.
*/
private val _latestInputStack: MutableList<String> = mutableListOf(_input.value)
/**
* Conversion result.
*/
private val _result: MutableStateFlow<String> = MutableStateFlow(Token._0)
private val _result: MutableStateFlow<ConversionResult> = MutableStateFlow(ConversionResult.Loading)
/**
* True when loading something from network.
@ -113,190 +106,38 @@ class ConverterViewModel @Inject constructor(
/**
* Current state of UI.
*/
val uiStateFlow: StateFlow<ConverterUIState> = combine(
val uiState: StateFlow<ConverterUIState> = 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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<UnitGroup, List<AbstractUnit>> = emptyMap(),
val searchQuery: String = "",
val shownUnitGroups: List<UnitGroup> = listOf(),
val chosenUnitGroup: UnitGroup? = null
val chosenUnitGroup: UnitGroup? = null,
val formatterSymbols: FormatterSymbols = FormatterSymbols.Spaces,
)

View File

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

View File

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

View File

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

View File

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