From 81f750402ab78f8db984998b3dacf72df17d4fff Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 24 Feb 2023 19:05:06 +0400 Subject: [PATCH] Expression formatting and cursor fixes P.S. This commit is a mess, lots of hardcoded stuff and weird decisions. --- .../com/sadellie/unitto/core/ui/Formatter.kt | 74 ++++--- .../sadellie/unitto/core/ui/FormatterTest.kt | 132 ++++++------ .../feature/calculator/CalculatorScreen.kt | 10 +- .../feature/calculator/CalculatorUIState.kt | 4 +- .../feature/calculator/CalculatorViewModel.kt | 71 ++----- .../feature/calculator/TextFieldController.kt | 190 +++++++++++++++++ .../calculator/components/HistoryList.kt | 2 +- .../calculator/components/InputTextField.kt | 18 +- .../calculator/TextFieldControllerTest.kt | 191 ++++++++++++++++++ 9 files changed, 543 insertions(+), 149 deletions(-) create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt create mode 100644 feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt index 70cdab7e..4c1a0141 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/Formatter.kt @@ -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 = input.split( - *OPERATORS.toTypedArray(), KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET - ) + // We may receive expressions. Find all numbers in this expression + val allNumbers: List = 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 - // Number of empty symbols (spaces) we need to add to correctly split into chunks. + 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 = + numbersRegex.findAll(this).map(MatchResult::value).toList() } diff --git a/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt b/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt index 237111ba..746c57df 100644 --- a/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt +++ b/core/ui/src/test/java/com/sadellie/unitto/core/ui/FormatterTest.kt @@ -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)) } } } \ No newline at end of file diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt index a171fc9e..14b29da2 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt @@ -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 ), diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt index 20b75ecf..add3d94c 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt @@ -18,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 = emptyList() ) diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt index b582a72a..32539c93 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt @@ -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 = userPrefsRepository.userPreferencesFlow.stateIn( @@ -58,19 +60,16 @@ internal class CalculatorViewModel @Inject constructor( UserPreferences() ) - private val _input: MutableStateFlow = MutableStateFlow("") private val _output: MutableStateFlow = MutableStateFlow("") - private val _selection: MutableStateFlow = MutableStateFlow(IntRange(0, 0)) private val _angleMode: MutableStateFlow = 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) { - calculatorHistoryRepository.clear() - } + 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() } } diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt new file mode 100644 index 00000000..61f00551 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/TextFieldController.kt @@ -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 . + */ + +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 = 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) + } + } +} diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt index 3031c0bf..1d2d93a6 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/HistoryList.kt @@ -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() diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt index 797dc77f..81512956 100644 --- a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt @@ -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) }, diff --git a/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt b/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt new file mode 100644 index 00000000..8499e954 --- /dev/null +++ b/feature/calculator/src/test/java/com/sadellie/unitto/feature/calculator/TextFieldControllerTest.kt @@ -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 . + */ + +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()) + } +}