mirror of
https://github.com/Myzel394/NumberHub.git
synced 2025-06-20 01:05:26 +02:00
Expression formatting and cursor fixes
P.S. This commit is a mess, lots of hardcoded stuff and weird decisions.
This commit is contained in:
parent
1a6d6fdce4
commit
81f750402a
@ -25,23 +25,28 @@ import com.sadellie.unitto.core.base.KEY_0
|
||||
import com.sadellie.unitto.core.base.KEY_COMMA
|
||||
import com.sadellie.unitto.core.base.KEY_DOT
|
||||
import com.sadellie.unitto.core.base.KEY_E
|
||||
import com.sadellie.unitto.core.base.KEY_LEFT_BRACKET
|
||||
import com.sadellie.unitto.core.base.KEY_MINUS
|
||||
import com.sadellie.unitto.core.base.KEY_RIGHT_BRACKET
|
||||
import com.sadellie.unitto.core.base.OPERATORS
|
||||
import com.sadellie.unitto.core.base.Separator
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
object Formatter {
|
||||
private const val SPACE = " "
|
||||
private const val PERIOD = "."
|
||||
private const val COMMA = ","
|
||||
// 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.
|
||||
*/
|
||||
private var grouping: String = SPACE
|
||||
var grouping: String = SPACE
|
||||
|
||||
/**
|
||||
* Fractional part separator.
|
||||
@ -91,11 +96,8 @@ object Formatter {
|
||||
|
||||
var output = input
|
||||
|
||||
// We may receive expressions
|
||||
// Find all numbers in that expression
|
||||
val allNumbers: List<String> = input.split(
|
||||
*OPERATORS.toTypedArray(), KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET
|
||||
)
|
||||
// We may receive expressions. Find all numbers in this expression
|
||||
val allNumbers: List<String> = input.getOnlyNumbers()
|
||||
|
||||
allNumbers.forEach {
|
||||
output = output.replace(it, formatNumber(it))
|
||||
@ -108,34 +110,50 @@ object Formatter {
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove formatting. Reverses [format]
|
||||
*/
|
||||
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
|
||||
|
||||
val cleanString = input
|
||||
.replace(grouping, "")
|
||||
.replace(fractional, KEY_DOT)
|
||||
return format(cleanString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format given [input].
|
||||
*
|
||||
* Input must be a number. Will replace grouping separators and fractional part separators.
|
||||
* 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 {
|
||||
val splitInput = input.split(".")
|
||||
var firstPart = splitInput[0]
|
||||
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)
|
||||
var output = if (offset != 3) {
|
||||
// We add some spaces at the begging so that last chunk has 3 symbols
|
||||
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)
|
||||
}
|
||||
|
||||
// Handling fractional part
|
||||
if (input.contains(".")) {
|
||||
output = output + fractional + splitInput.getOrElse(1) { "" }
|
||||
}
|
||||
|
||||
return output
|
||||
return (output + remainingPart.replace(".", fractional))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,4 +193,10 @@ object Formatter {
|
||||
}
|
||||
return result.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* @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()
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ 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 SOME_BRACKETS = "(((((((("
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@ -55,6 +56,7 @@ class FormatterTest {
|
||||
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))
|
||||
}
|
||||
|
||||
@ -69,6 +71,7 @@ class FormatterTest {
|
||||
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))
|
||||
}
|
||||
|
||||
@ -83,6 +86,7 @@ class FormatterTest {
|
||||
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))
|
||||
}
|
||||
|
||||
@ -91,96 +95,96 @@ class FormatterTest {
|
||||
formatter.setSeparator(Separator.SPACES)
|
||||
composeTestRule.setContent {
|
||||
var basicValue = BigDecimal.valueOf(1)
|
||||
assertEquals("-28", Formatter.formatTime("-28", basicValue))
|
||||
assertEquals("-0.05", Formatter.formatTime("-0.05", basicValue))
|
||||
assertEquals("0", Formatter.formatTime("0", basicValue))
|
||||
assertEquals("0", Formatter.formatTime("-0", basicValue))
|
||||
assertEquals("-28", formatter.formatTime("-28", basicValue))
|
||||
assertEquals("-0.05", formatter.formatTime("-0.05", basicValue))
|
||||
assertEquals("0", formatter.formatTime("0", basicValue))
|
||||
assertEquals("0", formatter.formatTime("-0", basicValue))
|
||||
|
||||
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
|
||||
assertEquals("-28d", Formatter.formatTime("-28", basicValue))
|
||||
assertEquals("-1h 12m", Formatter.formatTime("-0.05", basicValue))
|
||||
assertEquals("0", Formatter.formatTime("0", basicValue))
|
||||
assertEquals("0", Formatter.formatTime("-0", basicValue))
|
||||
assertEquals("-28d", formatter.formatTime("-28", basicValue))
|
||||
assertEquals("-1h 12m", formatter.formatTime("-0.05", basicValue))
|
||||
assertEquals("0", formatter.formatTime("0", basicValue))
|
||||
assertEquals("0", formatter.formatTime("-0", basicValue))
|
||||
|
||||
// DAYS
|
||||
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
|
||||
assertEquals("12h", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("1h 12m", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("7m 12s", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28d", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("90d", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("90d 12h", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90d 7m 12s", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("12h", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("1h 12m", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("7m 12s", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28d", formatter.formatTime("28", basicValue))
|
||||
assertEquals("90d", formatter.formatTime("90", basicValue))
|
||||
assertEquals("90d 12h", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90d 7m 12s", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// HOURS
|
||||
basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0)
|
||||
assertEquals("30m", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("3m", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("18s", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("1d 4h", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("3d 18h", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("3d 18h 30m", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("3d 18h 18s", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("30m", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("3m", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("18s", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("1d 4h", formatter.formatTime("28", basicValue))
|
||||
assertEquals("3d 18h", formatter.formatTime("90", basicValue))
|
||||
assertEquals("3d 18h 30m", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("3d 18h 18s", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// MINUTES
|
||||
basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0)
|
||||
assertEquals("30s", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("3s", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("300ms", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28m", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("1h 30m", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("1h 30m 30s", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("1h 30m 300ms", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("30s", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("3s", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("300ms", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28m", formatter.formatTime("28", basicValue))
|
||||
assertEquals("1h 30m", formatter.formatTime("90", basicValue))
|
||||
assertEquals("1h 30m 30s", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("1h 30m 300ms", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// SECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000)
|
||||
assertEquals("500ms", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50ms", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5ms", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28s", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("1m 30s", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("1m 30s 500ms", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("1m 30s 5ms", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("500ms", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50ms", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5ms", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28s", formatter.formatTime("28", basicValue))
|
||||
assertEquals("1m 30s", formatter.formatTime("90", basicValue))
|
||||
assertEquals("1m 30s 500ms", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("1m 30s 5ms", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// MILLISECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000_000)
|
||||
assertEquals("500µs", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50µs", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5µs", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28ms", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("90ms", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("90ms 500µs", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90ms 5µs", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("500µs", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50µs", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5µs", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28ms", formatter.formatTime("28", basicValue))
|
||||
assertEquals("90ms", formatter.formatTime("90", basicValue))
|
||||
assertEquals("90ms 500µs", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90ms 5µs", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// MICROSECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000_000)
|
||||
assertEquals("500ns", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50ns", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5ns", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28µs", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("90µs", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("90µs 500ns", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90µs 5ns", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("500ns", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50ns", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5ns", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28µs", formatter.formatTime("28", basicValue))
|
||||
assertEquals("90µs", formatter.formatTime("90", basicValue))
|
||||
assertEquals("90µs 500ns", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90µs 5ns", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// NANOSECONDS
|
||||
basicValue = BigDecimal.valueOf(1_000_000_000)
|
||||
assertEquals("500 000 000as", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50 000 000as", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5 000 000as", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28ns", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("90ns", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("90ns 500 000 000as", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90ns 5 000 000as", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("500 000 000as", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("50 000 000as", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("5 000 000as", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28ns", formatter.formatTime("28", basicValue))
|
||||
assertEquals("90ns", formatter.formatTime("90", basicValue))
|
||||
assertEquals("90ns 500 000 000as", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90ns 5 000 000as", formatter.formatTime("90.005", basicValue))
|
||||
|
||||
// ATTOSECONDS
|
||||
basicValue = BigDecimal.valueOf(1)
|
||||
assertEquals("0.5", Formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("0.05", Formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("0.005", Formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28", Formatter.formatTime("28", basicValue))
|
||||
assertEquals("90", Formatter.formatTime("90", basicValue))
|
||||
assertEquals("90.5", Formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90.005", Formatter.formatTime("90.005", basicValue))
|
||||
assertEquals("0.5", formatter.formatTime("0.5", basicValue))
|
||||
assertEquals("0.05", formatter.formatTime("0.05", basicValue))
|
||||
assertEquals("0.005", formatter.formatTime("0.005", basicValue))
|
||||
assertEquals("28", formatter.formatTime("28", basicValue))
|
||||
assertEquals("90", formatter.formatTime("90", basicValue))
|
||||
assertEquals("90.5", formatter.formatTime("90.5", basicValue))
|
||||
assertEquals("90.005", formatter.formatTime("90.005", basicValue))
|
||||
}
|
||||
}
|
||||
}
|
@ -54,16 +54,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
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 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.ui.Formatter
|
||||
import com.sadellie.unitto.core.ui.common.MenuButton
|
||||
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar
|
||||
import com.sadellie.unitto.core.ui.Formatter
|
||||
import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium
|
||||
import com.sadellie.unitto.data.model.HistoryItem
|
||||
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
|
||||
@ -205,10 +204,7 @@ private fun CalculatorScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
value = TextFieldValue(
|
||||
text = uiState.input,
|
||||
selection = TextRange(uiState.selection.first, uiState.selection.last)
|
||||
),
|
||||
value = uiState.input,
|
||||
onCursorChange = onCursorChange,
|
||||
pasteCallback = addSymbol,
|
||||
cutCallback = deleteSymbol
|
||||
@ -306,7 +302,7 @@ private fun PreviewCalculatorScreen() {
|
||||
|
||||
CalculatorScreen(
|
||||
uiState = CalculatorUIState(
|
||||
input = "12345",
|
||||
input = TextFieldValue("12345"),
|
||||
output = "12345",
|
||||
history = historyItems
|
||||
),
|
||||
|
@ -18,12 +18,12 @@
|
||||
|
||||
package com.sadellie.unitto.feature.calculator
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.sadellie.unitto.data.model.HistoryItem
|
||||
|
||||
internal data class CalculatorUIState(
|
||||
val input: String = "",
|
||||
val input: TextFieldValue = TextFieldValue(),
|
||||
val output: String = "",
|
||||
val selection: IntRange = 0..0,
|
||||
val angleMode: AngleMode = AngleMode.RAD,
|
||||
val history: List<HistoryItem> = emptyList()
|
||||
)
|
||||
|
@ -45,11 +45,13 @@ import org.mariuszgromada.math.mxparser.Expression
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
import org.mariuszgromada.math.mxparser.mXparser as MathParser
|
||||
import org.mariuszgromada.math.mxparser.License as MathParserLicense
|
||||
|
||||
@HiltViewModel
|
||||
internal class CalculatorViewModel @Inject constructor(
|
||||
userPrefsRepository: UserPreferencesRepository,
|
||||
private val calculatorHistoryRepository: CalculatorHistoryRepository
|
||||
private val calculatorHistoryRepository: CalculatorHistoryRepository,
|
||||
private val textFieldController: TextFieldController
|
||||
) : ViewModel() {
|
||||
private val _userPrefs: StateFlow<UserPreferences> =
|
||||
userPrefsRepository.userPreferencesFlow.stateIn(
|
||||
@ -58,19 +60,16 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
UserPreferences()
|
||||
)
|
||||
|
||||
private val _input: MutableStateFlow<String> = MutableStateFlow("")
|
||||
private val _output: MutableStateFlow<String> = MutableStateFlow("")
|
||||
private val _selection: MutableStateFlow<IntRange> = MutableStateFlow(IntRange(0, 0))
|
||||
private val _angleMode: MutableStateFlow<AngleMode> = MutableStateFlow(AngleMode.RAD)
|
||||
private val _history = calculatorHistoryRepository.historyFlow
|
||||
|
||||
val uiState = combine(
|
||||
_input, _output, _selection, _angleMode, _history
|
||||
) { input, output, selection, angleMode, history ->
|
||||
textFieldController.input, _output, _angleMode, _history, _userPrefs
|
||||
) { input, output, angleMode, history, _ ->
|
||||
return@combine CalculatorUIState(
|
||||
input = input,
|
||||
output = output,
|
||||
selection = selection,
|
||||
angleMode = angleMode,
|
||||
history = history
|
||||
)
|
||||
@ -78,30 +77,11 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState()
|
||||
)
|
||||
|
||||
fun addSymbol(symbol: String) {
|
||||
val selection = _selection.value
|
||||
_input.update {
|
||||
if (it.isEmpty()) symbol else it.replaceRange(selection.first, selection.last, symbol)
|
||||
}
|
||||
_selection.update { it.first + symbol.length..it.first + symbol.length }
|
||||
}
|
||||
fun addSymbol(symbol: String) = textFieldController.addToInput(symbol)
|
||||
|
||||
fun deleteSymbol() {
|
||||
val selection = _selection.value
|
||||
val newSelectionStart = when (selection.last) {
|
||||
0 -> return
|
||||
selection.first -> _selection.value.first - 1
|
||||
else -> _selection.value.first
|
||||
}
|
||||
fun deleteSymbol() = textFieldController.delete()
|
||||
|
||||
_selection.update { newSelectionStart..newSelectionStart }
|
||||
_input.update { it.removeRange(newSelectionStart, selection.last) }
|
||||
}
|
||||
|
||||
fun clearSymbols() {
|
||||
_selection.update { 0..0 }
|
||||
_input.update { "" }
|
||||
}
|
||||
fun clearSymbols() = textFieldController.clearInput()
|
||||
|
||||
fun toggleCalculatorMode() {
|
||||
_angleMode.update {
|
||||
@ -117,44 +97,37 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
|
||||
// Called when user clicks "=" on a keyboard
|
||||
fun evaluate() {
|
||||
if (!Expression(_input.value.clean).checkSyntax()) return
|
||||
|
||||
// Input and output can change while saving in history. This way we cache it here (i think)
|
||||
val input = _input.value
|
||||
val currentInput = textFieldController.input.value.text
|
||||
val output = _output.value
|
||||
if (!Expression(currentInput.clean).checkSyntax()) return
|
||||
|
||||
// Save to history
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
calculatorHistoryRepository.add(
|
||||
expression = input,
|
||||
expression = textFieldController.inputTextWithoutFormatting(),
|
||||
result = output
|
||||
)
|
||||
}
|
||||
|
||||
_input.update { _output.value }
|
||||
_selection.update { _input.value.length.._input.value.length }
|
||||
_output.update { "" }
|
||||
}
|
||||
|
||||
fun clearHistory() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
fun clearHistory() = viewModelScope.launch(Dispatchers.IO) {
|
||||
calculatorHistoryRepository.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun onCursorChange(selection: IntRange) {
|
||||
// When we paste, selection is set to the length of the pasted text (start and end)
|
||||
if (selection.first > _input.value.length) return
|
||||
_selection.update { selection }
|
||||
}
|
||||
fun onCursorChange(selection: IntRange) = textFieldController.moveCursor(selection)
|
||||
|
||||
private fun calculateInput() {
|
||||
val currentInput = textFieldController.input.value.text
|
||||
// Input is empty, don't calculate
|
||||
if (_input.value.isEmpty()) {
|
||||
if (currentInput.isEmpty()) {
|
||||
_output.update { "" }
|
||||
return
|
||||
}
|
||||
|
||||
val calculated = Expression(_input.value.clean).calculate()
|
||||
val calculated = Expression(currentInput.clean).calculate()
|
||||
|
||||
// Calculation error, return NaN
|
||||
if (calculated.isNaN() or calculated.isInfinite()) {
|
||||
@ -168,7 +141,7 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
.trimZeros()
|
||||
|
||||
try {
|
||||
val inputBigDecimal = BigDecimal(_input.value)
|
||||
val inputBigDecimal = BigDecimal(currentInput)
|
||||
|
||||
// Input and output are identical values
|
||||
if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) {
|
||||
@ -192,8 +165,7 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
val leftBrackets = count { it.toString() == KEY_LEFT_BRACKET }
|
||||
val rightBrackets = count { it.toString() == KEY_RIGHT_BRACKET }
|
||||
val neededBrackets = leftBrackets - rightBrackets
|
||||
return this
|
||||
.replace(KEY_MINUS_DISPLAY, KEY_MINUS)
|
||||
return replace(KEY_MINUS_DISPLAY, KEY_MINUS)
|
||||
.plus(KEY_RIGHT_BRACKET.repeat(neededBrackets.coerceAtLeast(0)))
|
||||
}
|
||||
|
||||
@ -203,10 +175,11 @@ internal class CalculatorViewModel @Inject constructor(
|
||||
* to load CPU very much. We use BigDecimal to achieve same result without CPU overload.
|
||||
*/
|
||||
MathParser.setCanonicalRounding(false)
|
||||
MathParserLicense.iConfirmNonCommercialUse("Sad Ellie")
|
||||
|
||||
// Observe and invoke calculation without UI lag.
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
merge(_userPrefs, _input, _angleMode).collectLatest {
|
||||
merge(_userPrefs, textFieldController.input, _angleMode).collectLatest {
|
||||
calculateInput()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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.KEY_COS
|
||||
import com.sadellie.unitto.core.base.KEY_DOT
|
||||
import com.sadellie.unitto.core.base.KEY_LN
|
||||
import com.sadellie.unitto.core.base.KEY_LOG
|
||||
import com.sadellie.unitto.core.base.KEY_SIN
|
||||
import com.sadellie.unitto.core.base.KEY_TAN
|
||||
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() {
|
||||
// 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.grouping = "`"
|
||||
it.fractional = "|"
|
||||
}
|
||||
}
|
||||
|
||||
var input: MutableStateFlow<TextFieldValue> = MutableStateFlow(TextFieldValue())
|
||||
|
||||
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
|
||||
|
||||
input.update {
|
||||
it.copy(
|
||||
text = inputFormatted,
|
||||
selection = TextRange(newSelectionStartEnd, newSelectionStartEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun moveCursor(newPosition: IntRange) {
|
||||
val cursorFixer = CursorFixer(grouping = localFormatter.grouping)
|
||||
val currentInput = input.value.text
|
||||
val fixedLeftCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.first)
|
||||
val fixedRightCursor = cursorFixer.fixCursorIfNeeded(currentInput, newPosition.last)
|
||||
|
||||
// Will modify
|
||||
input.update {
|
||||
it.copy(
|
||||
selection = TextRange(fixedLeftCursor, fixedRightCursor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
val selection = input.value.selection
|
||||
val distanceFromEnd = input.value.text.length - selection.end
|
||||
|
||||
val newSelectionStart = 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 -> selection.start - 1
|
||||
// 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(newSelectionStart, it.selection.end)
|
||||
.fixFormat()
|
||||
it.copy(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length - distanceFromEnd, newText.length - distanceFromEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInput() = input.update { TextFieldValue() }
|
||||
|
||||
fun inputTextWithoutFormatting() = input.value.text
|
||||
.replace(localFormatter.grouping, "")
|
||||
.replace(localFormatter.fractional, KEY_DOT)
|
||||
|
||||
private fun String.fixFormat(): String = localFormatter.reFormat(this)
|
||||
|
||||
inner class CursorFixer(private val grouping: String) {
|
||||
fun fixCursorIfNeeded(str: String, pos: Int): Int {
|
||||
// First we check if try to place cursors at illegal position
|
||||
// If yes,
|
||||
// we go left until cursor is position legally. Remember the distance
|
||||
val bestLeft = bestPositionLeft(str, pos)
|
||||
// we go right until cursor is position legally. Remember the distance
|
||||
val bestRight = bestPositionRight(str, pos)
|
||||
// Now we compare left and right distance
|
||||
val bestPosition = listOf(bestLeft, bestRight)
|
||||
// We move to the that's smaller
|
||||
.minBy { abs(it - pos) }
|
||||
|
||||
return bestPosition
|
||||
}
|
||||
|
||||
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, grouping)) return true
|
||||
|
||||
// For things like "123,456+c|os(8)" - this is illegal
|
||||
val illegalTokens = listOf(
|
||||
KEY_COS, KEY_SIN, KEY_LN, KEY_LOG, KEY_TAN
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -120,7 +120,7 @@ private fun HistoryListItem(
|
||||
Modifier.clickable { onTextClick(historyItem.expression) }
|
||||
) {
|
||||
Text(
|
||||
text = historyItem.expression,
|
||||
text = Formatter.format(historyItem.expression),
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
@ -22,6 +22,9 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalTextInputService
|
||||
@ -29,6 +32,7 @@ import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import com.sadellie.unitto.core.ui.Formatter
|
||||
import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge
|
||||
|
||||
@Composable
|
||||
@ -40,6 +44,18 @@ internal fun InputTextField(
|
||||
cutCallback: () -> Unit
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val formattedInput: TextFieldValue by remember(value) {
|
||||
derivedStateOf {
|
||||
value.copy(
|
||||
// We replace this because internally input value is already formatted, but uses
|
||||
// "|" as grouping and "-" as fractional.
|
||||
value.text
|
||||
.replace("`", Formatter.grouping)
|
||||
.replace("|", Formatter.fractional)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalTextInputService provides null,
|
||||
LocalTextToolbar provides UnittoTextToolbar(
|
||||
@ -51,7 +67,7 @@ internal fun InputTextField(
|
||||
BasicTextField(
|
||||
modifier = modifier,
|
||||
singleLine = true,
|
||||
value = value,
|
||||
value = formattedInput,
|
||||
onValueChange = {
|
||||
onCursorChange(it.selection.start..it.selection.end)
|
||||
},
|
||||
|
@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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
|
||||
.replace("`", ",")
|
||||
.replace("|", ".")
|
||||
|
||||
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 `placed 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())
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user