Expression formatting and cursor fixes

P.S. This commit is a mess, lots of hardcoded stuff and weird decisions.
This commit is contained in:
Sad Ellie 2023-02-24 19:05:06 +04:00
parent 1a6d6fdce4
commit 81f750402a
9 changed files with 543 additions and 149 deletions

View File

@ -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_COMMA
import com.sadellie.unitto.core.base.KEY_DOT import com.sadellie.unitto.core.base.KEY_DOT
import com.sadellie.unitto.core.base.KEY_E 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_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 com.sadellie.unitto.core.base.Separator
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
object Formatter { // Legacy, LOL. Will change later
private const val SPACE = " " object Formatter : UnittoFormatter()
private const val PERIOD = "."
private const val COMMA = "," 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. * Grouping separator.
*/ */
private var grouping: String = SPACE var grouping: String = SPACE
/** /**
* Fractional part separator. * Fractional part separator.
@ -91,11 +96,8 @@ object Formatter {
var output = input var output = input
// We may receive expressions // We may receive expressions. Find all numbers in this expression
// Find all numbers in that expression val allNumbers: List<String> = input.getOnlyNumbers()
val allNumbers: List<String> = input.split(
*OPERATORS.toTypedArray(), KEY_LEFT_BRACKET, KEY_RIGHT_BRACKET
)
allNumbers.forEach { allNumbers.forEach {
output = output.replace(it, formatNumber(it)) output = output.replace(it, formatNumber(it))
@ -108,34 +110,50 @@ object Formatter {
return output 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]. * 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 grouping
* @see fractional * @see fractional
*/ */
private fun formatNumber(input: String): String { private fun formatNumber(input: String): String {
val splitInput = input.split(".") if (input.any { it.isLetter() }) return input
var firstPart = splitInput[0]
var firstPart = input.takeWhile { it != '.' }
val remainingPart = input.removePrefix(firstPart)
// Number of empty symbols (spaces) we need to add to correctly split into chunks. // Number of empty symbols (spaces) we need to add to correctly split into chunks.
val offset = 3 - firstPart.length.mod(3) val offset = 3 - firstPart.length.mod(3)
var output = if (offset != 3) { val output = if (offset != 3) {
// We add some spaces at the begging so that last chunk has 3 symbols // We add some spaces at the beginning so that last chunk has 3 symbols
firstPart = " ".repeat(offset) + firstPart firstPart = " ".repeat(offset) + firstPart
firstPart.chunked(3).joinToString(grouping).drop(offset) firstPart.chunked(3).joinToString(grouping).drop(offset)
} else { } else {
firstPart.chunked(3).joinToString(grouping) firstPart.chunked(3).joinToString(grouping)
} }
// Handling fractional part return (output + remainingPart.replace(".", fractional))
if (input.contains(".")) {
output = output + fractional + splitInput.getOrElse(1) { "" }
}
return output
} }
/** /**
@ -175,4 +193,10 @@ object Formatter {
} }
return result.trimEnd() 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()
} }

View File

@ -36,6 +36,7 @@ private const val INCOMPLETE_VALUE = "123456."
private const val NO_FRACTIONAL_VALUE = "123456" private const val NO_FRACTIONAL_VALUE = "123456"
private const val INCOMPLETE_EXPR = "50+123456÷8×0.812+" private const val INCOMPLETE_EXPR = "50+123456÷8×0.812+"
private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)" private const val COMPLETE_EXPR = "50+123456÷8×0.812+0-√9*4^9+2×(9+8×7)"
private const val LONG_HALF_COMPLETE_EXPR = "50+123456÷89078..9×0.812+0-√9*4^9+2×(9+8×7)×sin(13sin123cos"
private const val SOME_BRACKETS = "((((((((" private const val SOME_BRACKETS = "(((((((("
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -55,6 +56,7 @@ class FormatterTest {
assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123 456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123 456÷8×0.812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123 456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123 456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123 456÷89 078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }
@ -69,6 +71,7 @@ class FormatterTest {
assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123,456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123,456÷8×0.812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123,456÷8×0.812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123,456÷8×0.812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123,456÷89,078..9×0.812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }
@ -83,6 +86,7 @@ class FormatterTest {
assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE)) assertEquals("123.456", formatter.format(NO_FRACTIONAL_VALUE))
assertEquals("50+123.456÷8×0,812+", formatter.format(INCOMPLETE_EXPR)) assertEquals("50+123.456÷8×0,812+", formatter.format(INCOMPLETE_EXPR))
assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR)) assertEquals("50+123.456÷8×0,812+0√9×4^9+2×(9+8×7)", formatter.format(COMPLETE_EXPR))
assertEquals("50+123.456÷89.078,,9×0,812+0√9×4^9+2×(9+8×7)×sin(13sin123cos", formatter.format(LONG_HALF_COMPLETE_EXPR))
assertEquals("((((((((", formatter.format(SOME_BRACKETS)) assertEquals("((((((((", formatter.format(SOME_BRACKETS))
} }
@ -91,96 +95,96 @@ class FormatterTest {
formatter.setSeparator(Separator.SPACES) formatter.setSeparator(Separator.SPACES)
composeTestRule.setContent { composeTestRule.setContent {
var basicValue = BigDecimal.valueOf(1) var basicValue = BigDecimal.valueOf(1)
assertEquals("-28", Formatter.formatTime("-28", basicValue)) assertEquals("-28", formatter.formatTime("-28", basicValue))
assertEquals("-0.05", Formatter.formatTime("-0.05", basicValue)) assertEquals("-0.05", formatter.formatTime("-0.05", basicValue))
assertEquals("0", Formatter.formatTime("0", basicValue)) assertEquals("0", formatter.formatTime("0", 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) basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
assertEquals("-28d", Formatter.formatTime("-28", basicValue)) assertEquals("-28d", formatter.formatTime("-28", basicValue))
assertEquals("-1h 12m", Formatter.formatTime("-0.05", basicValue)) assertEquals("-1h 12m", formatter.formatTime("-0.05", basicValue))
assertEquals("0", Formatter.formatTime("0", basicValue)) assertEquals("0", formatter.formatTime("0", basicValue))
assertEquals("0", Formatter.formatTime("-0", basicValue)) assertEquals("0", formatter.formatTime("-0", basicValue))
// DAYS // DAYS
basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0) basicValue = BigDecimal.valueOf(86_400_000_000_000_000_000_000.0)
assertEquals("12h", Formatter.formatTime("0.5", basicValue)) assertEquals("12h", formatter.formatTime("0.5", basicValue))
assertEquals("1h 12m", Formatter.formatTime("0.05", basicValue)) assertEquals("1h 12m", formatter.formatTime("0.05", basicValue))
assertEquals("7m 12s", Formatter.formatTime("0.005", basicValue)) assertEquals("7m 12s", formatter.formatTime("0.005", basicValue))
assertEquals("28d", Formatter.formatTime("28", basicValue)) assertEquals("28d", formatter.formatTime("28", basicValue))
assertEquals("90d", Formatter.formatTime("90", basicValue)) assertEquals("90d", formatter.formatTime("90", basicValue))
assertEquals("90d 12h", Formatter.formatTime("90.5", basicValue)) assertEquals("90d 12h", formatter.formatTime("90.5", basicValue))
assertEquals("90d 7m 12s", Formatter.formatTime("90.005", basicValue)) assertEquals("90d 7m 12s", formatter.formatTime("90.005", basicValue))
// HOURS // HOURS
basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0) basicValue = BigDecimal.valueOf(3_600_000_000_000_000_000_000.0)
assertEquals("30m", Formatter.formatTime("0.5", basicValue)) assertEquals("30m", formatter.formatTime("0.5", basicValue))
assertEquals("3m", Formatter.formatTime("0.05", basicValue)) assertEquals("3m", formatter.formatTime("0.05", basicValue))
assertEquals("18s", Formatter.formatTime("0.005", basicValue)) assertEquals("18s", formatter.formatTime("0.005", basicValue))
assertEquals("1d 4h", Formatter.formatTime("28", basicValue)) assertEquals("1d 4h", formatter.formatTime("28", basicValue))
assertEquals("3d 18h", Formatter.formatTime("90", basicValue)) assertEquals("3d 18h", formatter.formatTime("90", basicValue))
assertEquals("3d 18h 30m", Formatter.formatTime("90.5", basicValue)) assertEquals("3d 18h 30m", formatter.formatTime("90.5", basicValue))
assertEquals("3d 18h 18s", Formatter.formatTime("90.005", basicValue)) assertEquals("3d 18h 18s", formatter.formatTime("90.005", basicValue))
// MINUTES // MINUTES
basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0) basicValue = BigDecimal.valueOf(60_000_000_000_000_000_000.0)
assertEquals("30s", Formatter.formatTime("0.5", basicValue)) assertEquals("30s", formatter.formatTime("0.5", basicValue))
assertEquals("3s", Formatter.formatTime("0.05", basicValue)) assertEquals("3s", formatter.formatTime("0.05", basicValue))
assertEquals("300ms", Formatter.formatTime("0.005", basicValue)) assertEquals("300ms", formatter.formatTime("0.005", basicValue))
assertEquals("28m", Formatter.formatTime("28", basicValue)) assertEquals("28m", formatter.formatTime("28", basicValue))
assertEquals("1h 30m", Formatter.formatTime("90", basicValue)) assertEquals("1h 30m", formatter.formatTime("90", basicValue))
assertEquals("1h 30m 30s", Formatter.formatTime("90.5", basicValue)) assertEquals("1h 30m 30s", formatter.formatTime("90.5", basicValue))
assertEquals("1h 30m 300ms", Formatter.formatTime("90.005", basicValue)) assertEquals("1h 30m 300ms", formatter.formatTime("90.005", basicValue))
// SECONDS // SECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000) basicValue = BigDecimal.valueOf(1_000_000_000_000_000_000)
assertEquals("500ms", Formatter.formatTime("0.5", basicValue)) assertEquals("500ms", formatter.formatTime("0.5", basicValue))
assertEquals("50ms", Formatter.formatTime("0.05", basicValue)) assertEquals("50ms", formatter.formatTime("0.05", basicValue))
assertEquals("5ms", Formatter.formatTime("0.005", basicValue)) assertEquals("5ms", formatter.formatTime("0.005", basicValue))
assertEquals("28s", Formatter.formatTime("28", basicValue)) assertEquals("28s", formatter.formatTime("28", basicValue))
assertEquals("1m 30s", Formatter.formatTime("90", basicValue)) assertEquals("1m 30s", formatter.formatTime("90", basicValue))
assertEquals("1m 30s 500ms", Formatter.formatTime("90.5", basicValue)) assertEquals("1m 30s 500ms", formatter.formatTime("90.5", basicValue))
assertEquals("1m 30s 5ms", Formatter.formatTime("90.005", basicValue)) assertEquals("1m 30s 5ms", formatter.formatTime("90.005", basicValue))
// MILLISECONDS // MILLISECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000_000) basicValue = BigDecimal.valueOf(1_000_000_000_000_000)
assertEquals("500µs", Formatter.formatTime("0.5", basicValue)) assertEquals("500µs", formatter.formatTime("0.5", basicValue))
assertEquals("50µs", Formatter.formatTime("0.05", basicValue)) assertEquals("50µs", formatter.formatTime("0.05", basicValue))
assertEquals("5µs", Formatter.formatTime("0.005", basicValue)) assertEquals("5µs", formatter.formatTime("0.005", basicValue))
assertEquals("28ms", Formatter.formatTime("28", basicValue)) assertEquals("28ms", formatter.formatTime("28", basicValue))
assertEquals("90ms", Formatter.formatTime("90", basicValue)) assertEquals("90ms", formatter.formatTime("90", basicValue))
assertEquals("90ms 500µs", Formatter.formatTime("90.5", basicValue)) assertEquals("90ms 500µs", formatter.formatTime("90.5", basicValue))
assertEquals("90ms 5µs", Formatter.formatTime("90.005", basicValue)) assertEquals("90ms 5µs", formatter.formatTime("90.005", basicValue))
// MICROSECONDS // MICROSECONDS
basicValue = BigDecimal.valueOf(1_000_000_000_000) basicValue = BigDecimal.valueOf(1_000_000_000_000)
assertEquals("500ns", Formatter.formatTime("0.5", basicValue)) assertEquals("500ns", formatter.formatTime("0.5", basicValue))
assertEquals("50ns", Formatter.formatTime("0.05", basicValue)) assertEquals("50ns", formatter.formatTime("0.05", basicValue))
assertEquals("5ns", Formatter.formatTime("0.005", basicValue)) assertEquals("5ns", formatter.formatTime("0.005", basicValue))
assertEquals("28µs", Formatter.formatTime("28", basicValue)) assertEquals("28µs", formatter.formatTime("28", basicValue))
assertEquals("90µs", Formatter.formatTime("90", basicValue)) assertEquals("90µs", formatter.formatTime("90", basicValue))
assertEquals("90µs 500ns", Formatter.formatTime("90.5", basicValue)) assertEquals("90µs 500ns", formatter.formatTime("90.5", basicValue))
assertEquals("90µs 5ns", Formatter.formatTime("90.005", basicValue)) assertEquals("90µs 5ns", formatter.formatTime("90.005", basicValue))
// NANOSECONDS // NANOSECONDS
basicValue = BigDecimal.valueOf(1_000_000_000) basicValue = BigDecimal.valueOf(1_000_000_000)
assertEquals("500 000 000as", Formatter.formatTime("0.5", basicValue)) assertEquals("500 000 000as", formatter.formatTime("0.5", basicValue))
assertEquals("50 000 000as", Formatter.formatTime("0.05", basicValue)) assertEquals("50 000 000as", formatter.formatTime("0.05", basicValue))
assertEquals("5 000 000as", Formatter.formatTime("0.005", basicValue)) assertEquals("5 000 000as", formatter.formatTime("0.005", basicValue))
assertEquals("28ns", Formatter.formatTime("28", basicValue)) assertEquals("28ns", formatter.formatTime("28", basicValue))
assertEquals("90ns", Formatter.formatTime("90", basicValue)) assertEquals("90ns", formatter.formatTime("90", basicValue))
assertEquals("90ns 500 000 000as", Formatter.formatTime("90.5", basicValue)) assertEquals("90ns 500 000 000as", formatter.formatTime("90.5", basicValue))
assertEquals("90ns 5 000 000as", Formatter.formatTime("90.005", basicValue)) assertEquals("90ns 5 000 000as", formatter.formatTime("90.005", basicValue))
// ATTOSECONDS // ATTOSECONDS
basicValue = BigDecimal.valueOf(1) basicValue = BigDecimal.valueOf(1)
assertEquals("0.5", Formatter.formatTime("0.5", basicValue)) assertEquals("0.5", formatter.formatTime("0.5", basicValue))
assertEquals("0.05", Formatter.formatTime("0.05", basicValue)) assertEquals("0.05", formatter.formatTime("0.05", basicValue))
assertEquals("0.005", Formatter.formatTime("0.005", basicValue)) assertEquals("0.005", formatter.formatTime("0.005", basicValue))
assertEquals("28", Formatter.formatTime("28", basicValue)) assertEquals("28", formatter.formatTime("28", basicValue))
assertEquals("90", Formatter.formatTime("90", basicValue)) assertEquals("90", formatter.formatTime("90", basicValue))
assertEquals("90.5", Formatter.formatTime("90.5", basicValue)) assertEquals("90.5", formatter.formatTime("90.5", basicValue))
assertEquals("90.005", Formatter.formatTime("90.005", basicValue)) assertEquals("90.005", formatter.formatTime("90.005", basicValue))
} }
} }
} }

View File

@ -54,16 +54,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.MenuButton
import com.sadellie.unitto.core.ui.common.UnittoScreenWithTopBar 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.core.ui.theme.NumbersTextStyleDisplayMedium
import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.data.model.HistoryItem
import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard
@ -205,10 +204,7 @@ private fun CalculatorScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
value = TextFieldValue( value = uiState.input,
text = uiState.input,
selection = TextRange(uiState.selection.first, uiState.selection.last)
),
onCursorChange = onCursorChange, onCursorChange = onCursorChange,
pasteCallback = addSymbol, pasteCallback = addSymbol,
cutCallback = deleteSymbol cutCallback = deleteSymbol
@ -306,7 +302,7 @@ private fun PreviewCalculatorScreen() {
CalculatorScreen( CalculatorScreen(
uiState = CalculatorUIState( uiState = CalculatorUIState(
input = "12345", input = TextFieldValue("12345"),
output = "12345", output = "12345",
history = historyItems history = historyItems
), ),

View File

@ -18,12 +18,12 @@
package com.sadellie.unitto.feature.calculator package com.sadellie.unitto.feature.calculator
import androidx.compose.ui.text.input.TextFieldValue
import com.sadellie.unitto.data.model.HistoryItem import com.sadellie.unitto.data.model.HistoryItem
internal data class CalculatorUIState( internal data class CalculatorUIState(
val input: String = "", val input: TextFieldValue = TextFieldValue(),
val output: String = "", val output: String = "",
val selection: IntRange = 0..0,
val angleMode: AngleMode = AngleMode.RAD, val angleMode: AngleMode = AngleMode.RAD,
val history: List<HistoryItem> = emptyList() val history: List<HistoryItem> = emptyList()
) )

View File

@ -45,11 +45,13 @@ import org.mariuszgromada.math.mxparser.Expression
import java.math.BigDecimal import java.math.BigDecimal
import javax.inject.Inject import javax.inject.Inject
import org.mariuszgromada.math.mxparser.mXparser as MathParser import org.mariuszgromada.math.mxparser.mXparser as MathParser
import org.mariuszgromada.math.mxparser.License as MathParserLicense
@HiltViewModel @HiltViewModel
internal class CalculatorViewModel @Inject constructor( internal class CalculatorViewModel @Inject constructor(
userPrefsRepository: UserPreferencesRepository, userPrefsRepository: UserPreferencesRepository,
private val calculatorHistoryRepository: CalculatorHistoryRepository private val calculatorHistoryRepository: CalculatorHistoryRepository,
private val textFieldController: TextFieldController
) : ViewModel() { ) : ViewModel() {
private val _userPrefs: StateFlow<UserPreferences> = private val _userPrefs: StateFlow<UserPreferences> =
userPrefsRepository.userPreferencesFlow.stateIn( userPrefsRepository.userPreferencesFlow.stateIn(
@ -58,19 +60,16 @@ internal class CalculatorViewModel @Inject constructor(
UserPreferences() UserPreferences()
) )
private val _input: MutableStateFlow<String> = MutableStateFlow("")
private val _output: 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 _angleMode: MutableStateFlow<AngleMode> = MutableStateFlow(AngleMode.RAD)
private val _history = calculatorHistoryRepository.historyFlow private val _history = calculatorHistoryRepository.historyFlow
val uiState = combine( val uiState = combine(
_input, _output, _selection, _angleMode, _history textFieldController.input, _output, _angleMode, _history, _userPrefs
) { input, output, selection, angleMode, history -> ) { input, output, angleMode, history, _ ->
return@combine CalculatorUIState( return@combine CalculatorUIState(
input = input, input = input,
output = output, output = output,
selection = selection,
angleMode = angleMode, angleMode = angleMode,
history = history history = history
) )
@ -78,30 +77,11 @@ internal class CalculatorViewModel @Inject constructor(
viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState() viewModelScope, SharingStarted.WhileSubscribed(5000L), CalculatorUIState()
) )
fun addSymbol(symbol: String) { fun addSymbol(symbol: String) = textFieldController.addToInput(symbol)
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 deleteSymbol() { fun deleteSymbol() = textFieldController.delete()
val selection = _selection.value
val newSelectionStart = when (selection.last) {
0 -> return
selection.first -> _selection.value.first - 1
else -> _selection.value.first
}
_selection.update { newSelectionStart..newSelectionStart } fun clearSymbols() = textFieldController.clearInput()
_input.update { it.removeRange(newSelectionStart, selection.last) }
}
fun clearSymbols() {
_selection.update { 0..0 }
_input.update { "" }
}
fun toggleCalculatorMode() { fun toggleCalculatorMode() {
_angleMode.update { _angleMode.update {
@ -117,44 +97,37 @@ internal class CalculatorViewModel @Inject constructor(
// Called when user clicks "=" on a keyboard // Called when user clicks "=" on a keyboard
fun evaluate() { 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) // 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 val output = _output.value
if (!Expression(currentInput.clean).checkSyntax()) return
// Save to history
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.add( calculatorHistoryRepository.add(
expression = input, expression = textFieldController.inputTextWithoutFormatting(),
result = output result = output
) )
} }
_input.update { _output.value }
_selection.update { _input.value.length.._input.value.length }
_output.update { "" } _output.update { "" }
} }
fun clearHistory() { fun clearHistory() = viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.IO) {
calculatorHistoryRepository.clear() calculatorHistoryRepository.clear()
} }
}
fun onCursorChange(selection: IntRange) { fun onCursorChange(selection: IntRange) = textFieldController.moveCursor(selection)
// 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 }
}
private fun calculateInput() { private fun calculateInput() {
val currentInput = textFieldController.input.value.text
// Input is empty, don't calculate // Input is empty, don't calculate
if (_input.value.isEmpty()) { if (currentInput.isEmpty()) {
_output.update { "" } _output.update { "" }
return return
} }
val calculated = Expression(_input.value.clean).calculate() val calculated = Expression(currentInput.clean).calculate()
// Calculation error, return NaN // Calculation error, return NaN
if (calculated.isNaN() or calculated.isInfinite()) { if (calculated.isNaN() or calculated.isInfinite()) {
@ -168,7 +141,7 @@ internal class CalculatorViewModel @Inject constructor(
.trimZeros() .trimZeros()
try { try {
val inputBigDecimal = BigDecimal(_input.value) val inputBigDecimal = BigDecimal(currentInput)
// Input and output are identical values // Input and output are identical values
if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) { if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) {
@ -192,8 +165,7 @@ internal class CalculatorViewModel @Inject constructor(
val leftBrackets = count { it.toString() == KEY_LEFT_BRACKET } val leftBrackets = count { it.toString() == KEY_LEFT_BRACKET }
val rightBrackets = count { it.toString() == KEY_RIGHT_BRACKET } val rightBrackets = count { it.toString() == KEY_RIGHT_BRACKET }
val neededBrackets = leftBrackets - rightBrackets val neededBrackets = leftBrackets - rightBrackets
return this return replace(KEY_MINUS_DISPLAY, KEY_MINUS)
.replace(KEY_MINUS_DISPLAY, KEY_MINUS)
.plus(KEY_RIGHT_BRACKET.repeat(neededBrackets.coerceAtLeast(0))) .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. * to load CPU very much. We use BigDecimal to achieve same result without CPU overload.
*/ */
MathParser.setCanonicalRounding(false) MathParser.setCanonicalRounding(false)
MathParserLicense.iConfirmNonCommercialUse("Sad Ellie")
// Observe and invoke calculation without UI lag. // Observe and invoke calculation without UI lag.
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
merge(_userPrefs, _input, _angleMode).collectLatest { merge(_userPrefs, textFieldController.input, _angleMode).collectLatest {
calculateInput() calculateInput()
} }
} }

View File

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

View File

@ -120,7 +120,7 @@ private fun HistoryListItem(
Modifier.clickable { onTextClick(historyItem.expression) } Modifier.clickable { onTextClick(historyItem.expression) }
) { ) {
Text( Text(
text = historyItem.expression, text = Formatter.format(historyItem.expression),
maxLines = 1, maxLines = 1,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@ -22,6 +22,9 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalTextInputService 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.platform.LocalView
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import com.sadellie.unitto.core.ui.Formatter
import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge
@Composable @Composable
@ -40,6 +44,18 @@ internal fun InputTextField(
cutCallback: () -> Unit cutCallback: () -> Unit
) { ) {
val clipboardManager = LocalClipboardManager.current 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( CompositionLocalProvider(
LocalTextInputService provides null, LocalTextInputService provides null,
LocalTextToolbar provides UnittoTextToolbar( LocalTextToolbar provides UnittoTextToolbar(
@ -51,7 +67,7 @@ internal fun InputTextField(
BasicTextField( BasicTextField(
modifier = modifier, modifier = modifier,
singleLine = true, singleLine = true,
value = value, value = formattedInput,
onValueChange = { onValueChange = {
onCursorChange(it.selection.start..it.selection.end) onCursorChange(it.selection.start..it.selection.end)
}, },

View File

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