mirror of
https://github.com/Myzel394/NumberHub.git
synced 2025-06-18 16:25:27 +02:00
Merge branch 'light-goldenrod-yellow'
# Conflicts: # core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt # feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt # feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt # feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/MyTextField.kt # feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/TopScreen.kt # feature/unitslist/src/main/java/com/sadellie/unitto/feature/unitslist/RightSideScreen.kt
This commit is contained in:
commit
fe81601fa8
@ -120,7 +120,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")))
|
||||
|
@ -27,10 +27,8 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.sadellie.unitto.core.ui.Formatter
|
||||
import com.sadellie.unitto.data.userprefs.UserPreferencesRepository
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@ -43,7 +41,6 @@ internal class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val userPrefsFlow = userPrefsRepository.userPreferencesFlow
|
||||
.onEach { Formatter.setSeparator(it.separator) }
|
||||
|
||||
setContent {
|
||||
val userPrefs = userPrefsFlow
|
||||
|
@ -29,7 +29,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
|
||||
@ -101,6 +100,6 @@ internal fun UnittoNavigation(
|
||||
navigateToSettings = ::navigateToSettings
|
||||
)
|
||||
|
||||
epochScreen(navigateToMenu = openDrawer)
|
||||
// epochScreen(navigateToMenu = openDrawer)
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -1,252 +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.R
|
||||
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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
@ -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.R
|
||||
import com.sadellie.unitto.core.base.Token
|
||||
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
|
||||
}
|
@ -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 }
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
)
|
||||
|
@ -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))
|
||||
)
|
||||
}
|
@ -20,7 +20,9 @@ package com.sadellie.unitto.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.sadellie.unitto.core.base.Separator
|
||||
import com.sadellie.unitto.core.ui.common.textfield.FormatterSymbols
|
||||
import com.sadellie.unitto.core.ui.common.textfield.formatExpression
|
||||
import com.sadellie.unitto.core.ui.common.textfield.formatTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@ -29,16 +31,14 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.math.BigDecimal
|
||||
|
||||
private val formatter = Formatter
|
||||
|
||||
private const val ENG_VALUE = "123E+21"
|
||||
private const val ENG_VALUE_FRACTIONAL = "123.3E+21"
|
||||
private const val COMPLETE_VALUE = "123456.789"
|
||||
private const val INCOMPLETE_VALUE = "123456."
|
||||
private const val NO_FRACTIONAL_VALUE = "123456"
|
||||
private const val INCOMPLETE_EXPR = "50+123456÷8×0.8–12+"
|
||||
private const val COMPLETE_EXPR = "50+123456÷8×0.8–12+0-√9*4^9+2×(9+8×7)"
|
||||
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8–12+0-√9*4^9+2×(9+8×7)×sin(13sin123cos"
|
||||
private const val INCOMPLETE_EXPR = "50+123456÷8×0.8-12+"
|
||||
private const val COMPLETE_EXPR = "50+123456÷8×0.8-12+0-√9×4^9+2×(9+8×7)"
|
||||
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.8-12+0-√9×4^9+2×(9+8×7)×sin(13sin123cos"
|
||||
private const val SOME_BRACKETS = "(((((((("
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@ -49,167 +49,143 @@ class FormatterTest {
|
||||
|
||||
@Test
|
||||
fun setSeparatorSpaces() {
|
||||
formatter.setSeparator(Separator.SPACES)
|
||||
assertEquals(".", formatter.fractional)
|
||||
assertEquals("123E+21", formatter.format(ENG_VALUE))
|
||||
assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL))
|
||||
assertEquals("123 456.789", formatter.format(COMPLETE_VALUE))
|
||||
assertEquals("123 456.", formatter.format(INCOMPLETE_VALUE))
|
||||
assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE))
|
||||
assertEquals("50+123 456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR))
|
||||
assertEquals("50+123 456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
|
||||
assertEquals("50+123 456÷89 078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
|
||||
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
|
||||
fun String.format(): String = formatExpression(FormatterSymbols.Spaces)
|
||||
assertEquals("123E+21", ENG_VALUE.format())
|
||||
assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format())
|
||||
assertEquals("123 456.789", COMPLETE_VALUE.format())
|
||||
assertEquals("123 456.", INCOMPLETE_VALUE.format())
|
||||
assertEquals("123 456", NO_FRACTIONAL_VALUE.format())
|
||||
assertEquals("50+123 456÷8×0.8−12+", INCOMPLETE_EXPR.format())
|
||||
assertEquals("50+123 456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
|
||||
assertEquals("50+123 456÷89 078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
|
||||
assertEquals("((((((((", SOME_BRACKETS.format())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setSeparatorComma() {
|
||||
formatter.setSeparator(Separator.COMMA)
|
||||
assertEquals(".", formatter.fractional)
|
||||
assertEquals("123E+21", formatter.format(ENG_VALUE))
|
||||
assertEquals("123.3E+21", formatter.format(ENG_VALUE_FRACTIONAL))
|
||||
assertEquals("123,456.789", formatter.format(COMPLETE_VALUE))
|
||||
assertEquals("123,456.", formatter.format(INCOMPLETE_VALUE))
|
||||
assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE))
|
||||
assertEquals("50+123,456÷8×0.8–12+", formatter.format(INCOMPLETE_EXPR))
|
||||
assertEquals("50+123,456÷8×0.8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
|
||||
assertEquals("50+123,456÷89,078..9×0.8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
|
||||
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
|
||||
fun String.format(): String = formatExpression(FormatterSymbols.Comma)
|
||||
assertEquals("123E+21", ENG_VALUE.format())
|
||||
assertEquals("123.3E+21", ENG_VALUE_FRACTIONAL.format())
|
||||
assertEquals("123,456.789", COMPLETE_VALUE.format())
|
||||
assertEquals("123,456.", INCOMPLETE_VALUE.format())
|
||||
assertEquals("123,456", NO_FRACTIONAL_VALUE.format())
|
||||
assertEquals("50+123,456÷8×0.8−12+", INCOMPLETE_EXPR.format())
|
||||
assertEquals("50+123,456÷8×0.8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
|
||||
assertEquals("50+123,456÷89,078..9×0.8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
|
||||
assertEquals("((((((((", SOME_BRACKETS.format())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setSeparatorPeriod() {
|
||||
formatter.setSeparator(Separator.PERIOD)
|
||||
assertEquals(",", formatter.fractional)
|
||||
assertEquals("123E+21", formatter.format(ENG_VALUE))
|
||||
assertEquals("123,3E+21", formatter.format(ENG_VALUE_FRACTIONAL))
|
||||
assertEquals("123.456,789", formatter.format(COMPLETE_VALUE))
|
||||
assertEquals("123.456,", formatter.format(INCOMPLETE_VALUE))
|
||||
assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE))
|
||||
assertEquals("50+123.456÷8×0,8–12+", formatter.format(INCOMPLETE_EXPR))
|
||||
assertEquals("50+123.456÷8×0,8–12+0–√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
|
||||
assertEquals("50+123.456÷89.078,,9×0,8–12+0–√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
|
||||
assertEquals("((((((((", formatter.format(SOME_BRACKETS))
|
||||
fun String.format(): String = formatExpression(FormatterSymbols.Period)
|
||||
assertEquals("123E+21", ENG_VALUE.format())
|
||||
assertEquals("123,3E+21", ENG_VALUE_FRACTIONAL.format())
|
||||
assertEquals("123.456,789", COMPLETE_VALUE.format())
|
||||
assertEquals("123.456,", INCOMPLETE_VALUE.format())
|
||||
assertEquals("123.456", NO_FRACTIONAL_VALUE.format())
|
||||
assertEquals("50+123.456÷8×0,8−12+", INCOMPLETE_EXPR.format())
|
||||
assertEquals("50+123.456÷8×0,8−12+0−√9×4^9+2×(9+8×7)", COMPLETE_EXPR.format())
|
||||
assertEquals("50+123.456÷89.078,,9×0,8−12+0−√9×4^9+2×(9+8×7)×sin(13sin123cos", LONG_HALF_COMPLETE_EXPR.format())
|
||||
assertEquals("((((((((", SOME_BRACKETS.format())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatTimeTest() {
|
||||
formatter.setSeparator(Separator.SPACES)
|
||||
val formatterSymbols = FormatterSymbols.Spaces
|
||||
var basicValue = BigDecimal.valueOf(1)
|
||||
val mContext: Context = RuntimeEnvironment.getApplication().applicationContext
|
||||
assertEquals("-28", formatter.formatTime(mContext, "-28", basicValue))
|
||||
assertEquals("-0.05", formatter.formatTime(mContext, "-0.05", basicValue))
|
||||
assertEquals("0", formatter.formatTime(mContext, "0", basicValue))
|
||||
assertEquals("0", formatter.formatTime(mContext, "-0", basicValue))
|
||||
|
||||
fun String.formatTime() = this.formatTime(mContext, basicValue, formatterSymbols)
|
||||
|
||||
assertEquals("−28", "-28".formatTime())
|
||||
assertEquals("−0.05", "-0.05".formatTime())
|
||||
assertEquals("0", "0".formatTime())
|
||||
assertEquals("0", "−0".formatTime())
|
||||
|
||||
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
|
||||
assertEquals("-28d", formatter.formatTime(mContext, "-28", basicValue))
|
||||
assertEquals("-1h 12m", formatter.formatTime(mContext, "-0.05", basicValue))
|
||||
assertEquals("0", formatter.formatTime(mContext, "0", basicValue))
|
||||
assertEquals("0", formatter.formatTime(mContext, "-0", basicValue))
|
||||
assertEquals("−28d", "-28".formatTime())
|
||||
assertEquals("−1h 12m", "-0.05".formatTime())
|
||||
assertEquals("0", "0".formatTime())
|
||||
assertEquals("0", "-0".formatTime())
|
||||
|
||||
// DAYS
|
||||
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
|
||||
assertEquals("12h", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("1h 12m", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("7m 12s", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28d", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("90d", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("90d 12h", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("90d 7m 12s", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("12h","0.5".formatTime())
|
||||
assertEquals("1h 12m","0.05".formatTime())
|
||||
assertEquals("7m 12s","0.005".formatTime())
|
||||
assertEquals("28d","28".formatTime())
|
||||
assertEquals("90d","90".formatTime())
|
||||
assertEquals("90d 12h","90.5".formatTime())
|
||||
assertEquals("90d 7m 12s","90.005".formatTime())
|
||||
|
||||
// HOURS
|
||||
basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0)
|
||||
assertEquals("30m", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("3m", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("18s", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("1d 4h", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("3d 18h", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("3d 18h 30m", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("3d 18h 18s", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("30m", "0.5".formatTime())
|
||||
assertEquals("3m", "0.05".formatTime())
|
||||
assertEquals("18s", "0.005".formatTime())
|
||||
assertEquals("1d 4h", "28".formatTime())
|
||||
assertEquals("3d 18h", "90".formatTime())
|
||||
assertEquals("3d 18h 30m", "90.5".formatTime())
|
||||
assertEquals("3d 18h 18s", "90.005".formatTime())
|
||||
|
||||
// MINUTES
|
||||
basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0)
|
||||
assertEquals("30s", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("3s", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("300ms", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28m", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("1h 30m", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("1h 30m 30s", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("1h 30m 300ms", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("30s", "0.5".formatTime())
|
||||
assertEquals("3s", "0.05".formatTime())
|
||||
assertEquals("300ms", "0.005".formatTime())
|
||||
assertEquals("28m", "28".formatTime())
|
||||
assertEquals("1h 30m", "90".formatTime())
|
||||
assertEquals("1h 30m 30s", "90.5".formatTime())
|
||||
assertEquals("1h 30m 300ms", "90.005".formatTime())
|
||||
|
||||
// SECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000)
|
||||
assertEquals("500ms", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("50ms", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("5ms", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28s", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("1m 30s", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("1m 30s 500ms", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("1m 30s 5ms", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("500ms", "0.5".formatTime())
|
||||
assertEquals("50ms", "0.05".formatTime())
|
||||
assertEquals("5ms", "0.005".formatTime())
|
||||
assertEquals("28s", "28".formatTime())
|
||||
assertEquals("1m 30s", "90".formatTime())
|
||||
assertEquals("1m 30s 500ms", "90.5".formatTime())
|
||||
assertEquals("1m 30s 5ms", "90.005".formatTime())
|
||||
|
||||
// MILLISECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000_000)
|
||||
assertEquals("500µs", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("50µs", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("5µs", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28ms", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("90ms", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("90ms 500µs", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("90ms 5µs", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("500µs", "0.5".formatTime())
|
||||
assertEquals("50µs", "0.05".formatTime())
|
||||
assertEquals("5µs", "0.005".formatTime())
|
||||
assertEquals("28ms", "28".formatTime())
|
||||
assertEquals("90ms", "90".formatTime())
|
||||
assertEquals("90ms 500µs", "90.5".formatTime())
|
||||
assertEquals("90ms 5µs", "90.005".formatTime())
|
||||
|
||||
// MICROSECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000)
|
||||
assertEquals("500ns", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("50ns", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("5ns", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28µs", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("90µs", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("90µs 500ns", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("90µs 5ns", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("500ns", "0.5".formatTime())
|
||||
assertEquals("50ns", "0.05".formatTime())
|
||||
assertEquals("5ns", "0.005".formatTime())
|
||||
assertEquals("28µs", "28".formatTime())
|
||||
assertEquals("90µs", "90".formatTime())
|
||||
assertEquals("90µs 500ns", "90.5".formatTime())
|
||||
assertEquals("90µs 5ns", "90.005".formatTime())
|
||||
|
||||
// NANOSECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000)
|
||||
assertEquals("500 000 000as", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("50 000 000as", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("5 000 000as", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28ns", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("90ns", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("90ns 500 000 000as", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("90ns 5 000 000as", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
assertEquals("500 000 000as", "0.5".formatTime())
|
||||
assertEquals("50 000 000as", "0.05".formatTime())
|
||||
assertEquals("5 000 000as", "0.005".formatTime())
|
||||
assertEquals("28ns", "28".formatTime())
|
||||
assertEquals("90ns", "90".formatTime())
|
||||
assertEquals("90ns 500 000 000as", "90.5".formatTime())
|
||||
assertEquals("90ns 5 000 000as", "90.005".formatTime())
|
||||
|
||||
// ATTOSECONDS
|
||||
basicValue = BigDecimal.valueOf(1)
|
||||
assertEquals("0.5", formatter.formatTime(mContext, "0.5", basicValue))
|
||||
assertEquals("0.05", formatter.formatTime(mContext, "0.05", basicValue))
|
||||
assertEquals("0.005", formatter.formatTime(mContext, "0.005", basicValue))
|
||||
assertEquals("28", formatter.formatTime(mContext, "28", basicValue))
|
||||
assertEquals("90", formatter.formatTime(mContext, "90", basicValue))
|
||||
assertEquals("90.5", formatter.formatTime(mContext, "90.5", basicValue))
|
||||
assertEquals("90.005", formatter.formatTime(mContext, "90.005", basicValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromSeparatorToSpacesTest() {
|
||||
formatter.setSeparator(Separator.SPACES)
|
||||
assertEquals("123 456.789", formatter.fromSeparator("123,456.789", Separator.COMMA))
|
||||
assertEquals("123 456.789", formatter.fromSeparator("123 456.789", Separator.SPACES))
|
||||
assertEquals("123 456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromSeparatorToPeriodTest() {
|
||||
formatter.setSeparator(Separator.PERIOD)
|
||||
assertEquals("123.456,789", formatter.fromSeparator("123,456.789", Separator.COMMA))
|
||||
assertEquals("123.456,789", formatter.fromSeparator("123 456.789", Separator.SPACES))
|
||||
assertEquals("123.456,789", formatter.fromSeparator("123.456,789", Separator.PERIOD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromSeparatorToCommaTest() {
|
||||
formatter.setSeparator(Separator.COMMA)
|
||||
assertEquals("123,456.789", formatter.fromSeparator("123,456.789", Separator.COMMA))
|
||||
assertEquals("123,456.789", formatter.fromSeparator("123 456.789", Separator.SPACES))
|
||||
assertEquals("123,456.789", formatter.fromSeparator("123.456,789", Separator.PERIOD))
|
||||
assertEquals("0.5", "0.5".formatTime())
|
||||
assertEquals("0.05", "0.05".formatTime())
|
||||
assertEquals("0.005", "0.005".formatTime())
|
||||
assertEquals("28", "28".formatTime())
|
||||
assertEquals("90", "90".formatTime())
|
||||
assertEquals("90.5", "90.5".formatTime())
|
||||
assertEquals("90.005", "90.005".formatTime())
|
||||
}
|
||||
}
|
@ -26,4 +26,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(mapOf("path" to ":core:base")))
|
||||
testImplementation(libs.junit)
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
1
data/evaluatto/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
31
data/evaluatto/build.gradle.kts
Normal file
31
data/evaluatto/build.gradle.kts
Normal 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)
|
||||
}
|
0
data/evaluatto/consumer-rules.pro
Normal file
0
data/evaluatto/consumer-rules.pro
Normal file
22
data/evaluatto/src/main/AndroidManifest.xml
Normal file
22
data/evaluatto/src/main/AndroidManifest.xml
Normal 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>
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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)×(5−2)", "21")
|
||||
|
||||
@Test
|
||||
fun expression4() = assertExpr("8÷4+2×3", "8")
|
||||
|
||||
@Test
|
||||
fun expression5() = assertExpr("2^3+4^2−5×6", "-6")
|
||||
|
||||
@Test
|
||||
fun expression6() = assertExpr("(10−2)^2÷8+3×2", "14")
|
||||
|
||||
@Test
|
||||
fun expression7() = assertExpr("7!÷3!−5!÷2!", "780")
|
||||
|
||||
@Test
|
||||
fun expression8() = assertExpr("(2^2+3^3)÷5−√(16)×2", "-1.8")
|
||||
|
||||
@Test
|
||||
fun expression9() = assertExpr("10×log(100)+2^4−3^2", "27")
|
||||
|
||||
@Test
|
||||
fun expression10() = assertExpr("sin(π÷3)×cos(π÷6)+tan(π÷4)−√3", "0.017949192431123")
|
||||
|
||||
@Test
|
||||
fun expression11() = assertExpr("2^6−2^5+2^4−2^3+2^−2^1+2^0", "41.25")
|
||||
|
||||
@Test
|
||||
fun expression12() = assertExpr("2×(3+4)×(5−2)÷6", "7")
|
||||
}
|
@ -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, "...")
|
||||
}
|
@ -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")
|
||||
}
|
@ -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×(69−420)", "2(69−420)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"0.×(69−420)", "0.(69−420)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
".0×(69−420)", ".0(69−420)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
".×(69−420)", ".(69−420)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"2×(69−420)×(23−4)×cos(9)×tan((sin⁻¹(.9)))",
|
||||
"2(69−420)(23−4)cos(9)tan((sin⁻¹(.9)))"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"e×e+π", "ee+π"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `balanced brackets`() {
|
||||
assertLex(
|
||||
"123×(12+4)", "123(12+4"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"12312+4", "12312+4"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"123)))12+4", "123)))12+4"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"sin(cos(tan(3)))", "sin(cos(tan(3"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"sin(cos(tan(3)))", "sin(cos(tan(3)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"sin(cos(tan(3)))", "sin(cos(tan(3))"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpack percentage`() {
|
||||
// 132.5+14% −> 132.5+132.5*0.14
|
||||
assertLex(
|
||||
"132.5+(14÷100×(132.5))", "132.5+14%"
|
||||
)
|
||||
|
||||
// 132.5+(14)% −> 132.5+(14)/100*132.5
|
||||
assertLex(
|
||||
"132.5+((14)÷100×(132.5))" , "132.5+(14)%"
|
||||
)
|
||||
|
||||
// 132.5+(15+4)% −> 132.5+(15+4)*132.5/100
|
||||
assertLex(
|
||||
"132.5+((15+4)÷100×(132.5))", "132.5+(15+4)%"
|
||||
)
|
||||
|
||||
// (132.5+12%)+(15+4)% −> (132.5+12/100*132.5)+(15+4)/100*(132.5+12/100*132.5)
|
||||
assertLex(
|
||||
"(132.5+(12÷100×(132.5)))+((15+4)÷100×((132.5+(12÷100×(132.5)))))", "(132.5+12%)+(15+4)%"
|
||||
)
|
||||
|
||||
// 2% −> 2/100
|
||||
assertLex(
|
||||
"(2÷100)", "2%"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"((2)÷100)", "(2)%"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"(132.5+5)+(90÷100×((132.5+5)))", "(132.5+5)+90%"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"((90÷100)+(90÷100×((90÷100))))", "(90%+90%)"
|
||||
)
|
||||
|
||||
assertLex(
|
||||
"((90÷100)÷(90÷100))+((90÷100)−(90÷100×((90÷100))))", "(90%÷90%)+(90%−90%)"
|
||||
)
|
||||
}
|
||||
}
|
@ -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(""))
|
@ -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")
|
||||
}
|
@ -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)",
|
||||
|
@ -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")))
|
||||
}
|
||||
|
@ -57,17 +57,17 @@ 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.R
|
||||
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
|
||||
@ -90,9 +90,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,
|
||||
@ -108,7 +108,7 @@ private fun CalculatorScreen(
|
||||
addSymbol: (String) -> Unit,
|
||||
clearSymbols: () -> Unit,
|
||||
deleteSymbol: () -> Unit,
|
||||
onCursorChange: (IntRange) -> Unit,
|
||||
onCursorChange: (TextRange) -> Unit,
|
||||
toggleAngleMode: () -> Unit,
|
||||
evaluate: () -> Unit,
|
||||
clearHistory: () -> Unit
|
||||
@ -161,6 +161,8 @@ private fun CalculatorScreen(
|
||||
.fillMaxSize(),
|
||||
historyItems = uiState.history,
|
||||
historyItemHeightCallback = { historyItemHeight = it },
|
||||
formatterSymbols = uiState.formatterSymbols,
|
||||
addTokens = addSymbol,
|
||||
)
|
||||
},
|
||||
textFields = { maxDragAmount ->
|
||||
@ -203,28 +205,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(
|
||||
@ -240,8 +270,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,
|
||||
@ -316,7 +349,7 @@ private fun PreviewCalculatorScreen() {
|
||||
CalculatorScreen(
|
||||
uiState = CalculatorUIState(
|
||||
input = TextFieldValue("1.2345"),
|
||||
output = "1234",
|
||||
output = CalculationResult.Default("1234"),
|
||||
history = historyItems
|
||||
),
|
||||
navigateToMenu = {},
|
||||
|
@ -18,13 +18,23 @@
|
||||
|
||||
package com.sadellie.unitto.feature.calculator
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.sadellie.unitto.core.base.R
|
||||
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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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 = {},
|
||||
|
@ -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
|
||||
@ -55,7 +58,8 @@ 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.base.R
|
||||
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
|
||||
@ -68,6 +72,8 @@ 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 = {}
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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")))
|
||||
}
|
||||
|
@ -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.base.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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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() }
|
||||
}
|
||||
|
@ -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.togetherWith
|
||||
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.base.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
|
||||
).togetherWith(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
|
||||
).togetherWith(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
@ -29,7 +30,7 @@ import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -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.base.R
|
||||
import com.sadellie.unitto.core.ui.Formatter
|
||||
import com.sadellie.unitto.core.base.Token
|
||||
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(
|
||||
@ -129,9 +158,9 @@ internal fun TopScreenPart(
|
||||
targetState = stringResource(unitFrom?.shortName ?: R.string.loading_label),
|
||||
transitionSpec = {
|
||||
// Enter animation
|
||||
((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn()
|
||||
(expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn()
|
||||
// Exit animation
|
||||
).togetherWith(fadeOut()))
|
||||
with fadeOut())
|
||||
.using(SizeTransform(clip = false))
|
||||
}
|
||||
) { value ->
|
||||
@ -141,32 +170,82 @@ 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),
|
||||
transitionSpec = {
|
||||
// Enter animation
|
||||
((expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn()
|
||||
(expandHorizontally(clip = false, expandFrom = Alignment.Start) + fadeIn()
|
||||
// Exit animation
|
||||
).togetherWith(fadeOut()))
|
||||
with fadeOut())
|
||||
.using(SizeTransform(clip = false))
|
||||
}
|
||||
) { value ->
|
||||
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
)
|
||||
|
@ -33,7 +33,8 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.sadellie.unitto.core.base.R
|
||||
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
|
||||
@ -61,7 +62,7 @@ internal fun RightSideScreen(
|
||||
navigateUp: () -> Unit,
|
||||
navigateToSettingsAction: () -> Unit,
|
||||
selectAction: (AbstractUnit) -> Unit,
|
||||
inputValue: String,
|
||||
inputValue: String?,
|
||||
unitFrom: AbstractUnit
|
||||
) {
|
||||
val uiState = viewModel.mainFlow.collectAsStateWithLifecycle()
|
||||
@ -70,12 +71,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) {
|
||||
{ "" }
|
||||
}
|
||||
|
||||
@ -132,15 +142,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) {
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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())
|
||||
|
@ -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(
|
||||
|
@ -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" }
|
||||
|
@ -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")
|
||||
|
Loading…
x
Reference in New Issue
Block a user