From 5ab779d136df319e6d501b9b0eff3a23049a8317 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Fri, 10 Feb 2023 12:01:29 +0400 Subject: [PATCH] Added Calculator (#2) Note: First iteration of this feature, will change the navigation, cursor and copy/paste/cut. --- app/build.gradle.kts | 1 + .../com/sadellie/unitto/UnittoNavigation.kt | 5 + .../unitto/core/base/KeypadSymbols.kt | 11 + core/base/src/main/res/values/strings.xml | 4 + .../unitto/core/ui/common/KeyboardButton.kt | 106 ++++++++++ .../com/sadellie/unitto/core/ui/theme/Type.kt | 9 + feature/calculator/.gitignore | 1 + feature/calculator/build.gradle.kts | 38 ++++ feature/calculator/consumer-rules.pro | 0 .../calculator/src/main/AndroidManifest.xml | 22 ++ .../unitto/feature/calculator/AngleMode.kt | 21 ++ .../feature/calculator/CalculatorScreen.kt | 121 +++++++++++ .../feature/calculator/CalculatorUIState.kt | 26 +++ .../feature/calculator/CalculatorViewModel.kt | 191 ++++++++++++++++++ .../components/CalculatorKeyboard.kt | 182 +++++++++++++++++ .../calculator/components/InputTextField.kt | 53 +++++ .../navigation/CalculatorNavigation.kt | 43 ++++ .../unitto/feature/tools/ToolsScreen.kt | 18 +- .../tools/navigation/ToolsNavigation.kt | 2 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 1 + 21 files changed, 857 insertions(+), 2 deletions(-) create mode 100644 feature/calculator/.gitignore create mode 100644 feature/calculator/build.gradle.kts create mode 100644 feature/calculator/consumer-rules.pro create mode 100644 feature/calculator/src/main/AndroidManifest.xml create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/AngleMode.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt create mode 100644 feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/navigation/CalculatorNavigation.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d7115c4a..71ad5d90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { implementation(libs.com.google.accompanist.systemuicontroller) implementation(project(mapOf("path" to ":feature:converter"))) + implementation(project(mapOf("path" to ":feature:calculator"))) implementation(project(mapOf("path" to ":feature:settings"))) implementation(project(mapOf("path" to ":feature:unitslist"))) implementation(project(mapOf("path" to ":feature:tools"))) diff --git a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt index 30633b17..f9599b0d 100644 --- a/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt +++ b/app/src/main/java/com/sadellie/unitto/UnittoNavigation.kt @@ -21,6 +21,8 @@ package com.sadellie.unitto import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import com.sadellie.unitto.feature.calculator.navigation.calculatorScreen +import com.sadellie.unitto.feature.calculator.navigation.navigateToCalculator import com.sadellie.unitto.feature.converter.MainViewModel import com.sadellie.unitto.feature.converter.navigation.converterRoute import com.sadellie.unitto.feature.converter.navigation.converterScreen @@ -83,9 +85,12 @@ fun UnittoNavigation( toolsScreen( navigateUpAction = navController::navigateUp, + navigateToCalculator = navController::navigateToCalculator, navigateToEpoch = navController::navigateToEpoch ) + calculatorScreen(navigateUpAction = navController::navigateUp) + epochScreen( navigateUpAction = navController::navigateUp ) diff --git a/core/base/src/main/java/com/sadellie/unitto/core/base/KeypadSymbols.kt b/core/base/src/main/java/com/sadellie/unitto/core/base/KeypadSymbols.kt index 692d0c4e..f1794c31 100644 --- a/core/base/src/main/java/com/sadellie/unitto/core/base/KeypadSymbols.kt +++ b/core/base/src/main/java/com/sadellie/unitto/core/base/KeypadSymbols.kt @@ -57,6 +57,17 @@ const val KEY_RIGHT_BRACKET = ")" const val KEY_EXPONENT = "^" const val KEY_SQRT = "√" +const val KEY_PI = "π" +const val KEY_FACTORIAL = "!" +const val KEY_SIN = "sin(" +const val KEY_COS = "cos(" +const val KEY_TAN = "tan(" +const val KEY_E_SMALL = "e" +const val KEY_MODULO = "#" +const val KEY_LN = "ln(" +const val KEY_LOG = "log(" +const val KEY_PERCENT = "%" +const val KEY_EVALUATE = "=" val OPERATORS by lazy { listOf( diff --git a/core/base/src/main/res/values/strings.xml b/core/base/src/main/res/values/strings.xml index 8b12ba6f..5d1b021f 100644 --- a/core/base/src/main/res/values/strings.xml +++ b/core/base/src/main/res/values/strings.xml @@ -1022,6 +1022,10 @@ This screen is part of an experiment. It may change in the future. Click to read more! + + Calculator + Calculate, but don\'t convert + Number of decimal places Converted values may have a precision higher than the preferred one. diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/KeyboardButton.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/KeyboardButton.kt index 801eebc9..18599db6 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/KeyboardButton.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/KeyboardButton.kt @@ -29,14 +29,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import com.sadellie.unitto.core.ui.theme.NumbersTextStyleTitleLarge +import com.sadellie.unitto.core.ui.theme.NumbersTextStyleTitleSmall /** * Button for keyboard @@ -87,3 +91,105 @@ fun KeyboardButton( if (isPressed and allowVibration) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) } } + +@Composable +fun BasicKeyboardButton( + modifier: Modifier, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + containerColor: Color, + contentColor: Color, + text: String, + textColor: Color, + fontSize: TextUnit, + allowVibration: Boolean +) { + val view = LocalView.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val cornerRadius: Int by animateIntAsState( + targetValue = if (isPressed) 30 else 50, + animationSpec = tween(easing = FastOutSlowInEasing), + ) + + UnittoButton( + modifier = modifier, + onClick = onClick, + onLongClick = onLongClick, + shape = RoundedCornerShape(cornerRadius), + containerColor = containerColor, + contentColor = contentColor, + interactionSource = interactionSource + ) { + Text( + text = text, + style = NumbersTextStyleTitleLarge, + color = textColor, + fontSize = fontSize + ) + } + + LaunchedEffect(key1 = isPressed) { + if (isPressed and allowVibration) view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) + } +} + +@Composable +fun KeyboardButtonLight( + modifier: Modifier, + symbol: String, + onClick: (String) -> Unit, + onLongClick: (() -> Unit)? = null, + allowVibration: Boolean = false +) { + BasicKeyboardButton( + modifier = modifier, + onClick = { onClick(symbol) }, + onLongClick = onLongClick, + containerColor = MaterialTheme.colorScheme.inverseOnSurface, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + text = symbol, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = TextUnit.Unspecified, + allowVibration = allowVibration, + ) +} + +@Composable +fun KeyboardButtonFilled( + modifier: Modifier, + symbol: String, + onClick: (String) -> Unit, + onLongClick: (() -> Unit)? = null, + allowVibration: Boolean = false +) { + BasicKeyboardButton( + modifier = modifier, + onClick = { onClick(symbol) }, + onLongClick = onLongClick, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + text = symbol, + textColor = MaterialTheme.colorScheme.onSecondaryContainer, + fontSize = TextUnit.Unspecified, + allowVibration = allowVibration + ) +} + +@Composable +fun KeyboardButtonAdditional( + modifier: Modifier, + symbol: String, + onClick: (String) -> Unit +) { + TextButton( + onClick = { onClick(symbol) }, + modifier = modifier + ) { + Text( + text = symbol, + color = MaterialTheme.colorScheme.onSecondaryContainer, + style = NumbersTextStyleTitleSmall + ) + } +} diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt index 79950164..5fa08f65 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/theme/Type.kt @@ -64,6 +64,15 @@ val NumbersTextStyleTitleLarge = TextStyle( letterSpacing = 0.sp, ) +// This text style is used for secondary keyboard button +val NumbersTextStyleTitleSmall = TextStyle( + fontFamily = Lato, + fontWeight = FontWeight.W500, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.sp, +) + val AppTypography = Typography( displayLarge = TextStyle( fontFamily = Montserrat, diff --git a/feature/calculator/.gitignore b/feature/calculator/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/calculator/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/calculator/build.gradle.kts b/feature/calculator/build.gradle.kts new file mode 100644 index 00000000..68815800 --- /dev/null +++ b/feature/calculator/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * 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 . + */ + +plugins { + id("unitto.library") + id("unitto.library.compose") + id("unitto.library.feature") + id("unitto.android.hilt") +} + +android { + namespace = "com.sadellie.unitto.feature.calculator" +} + +dependencies { + testImplementation(libs.junit) + implementation(libs.org.mariuszgromada.math.mxparser) + implementation(libs.com.github.sadellie.themmo) + + implementation(project(mapOf("path" to ":data:userprefs"))) + implementation(project(mapOf("path" to ":data:unitgroups"))) + implementation(project(mapOf("path" to ":data:units"))) +} diff --git a/feature/calculator/consumer-rules.pro b/feature/calculator/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/calculator/src/main/AndroidManifest.xml b/feature/calculator/src/main/AndroidManifest.xml new file mode 100644 index 00000000..232257bf --- /dev/null +++ b/feature/calculator/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/AngleMode.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/AngleMode.kt new file mode 100644 index 00000000..67318a58 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/AngleMode.kt @@ -0,0 +1,21 @@ +/* + * 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 + +internal enum class AngleMode { DEG, RAD } 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 new file mode 100644 index 00000000..514f2786 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorScreen.kt @@ -0,0 +1,121 @@ +/* + * 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.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sadellie.unitto.core.ui.common.UnittoTopAppBar +import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayMedium +import com.sadellie.unitto.feature.calculator.components.CalculatorKeyboard +import com.sadellie.unitto.feature.calculator.components.InputTextField + +@Composable +internal fun CalculatorRoute( + navigateUpAction: () -> Unit, + viewModel: CalculatorViewModel = hiltViewModel() +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + CalculatorScreen( + uiState = uiState.value, + navigateUpAction = navigateUpAction, + addSymbol = viewModel::addSymbol, + clearSymbols = viewModel::clearSymbols, + deleteSymbol = viewModel::deleteSymbol, + onCursorChange = viewModel::onCursorChange, + toggleAngleMode = viewModel::toggleCalculatorMode, + evaluate = viewModel::evaluate + ) +} + +@Composable +private fun CalculatorScreen( + uiState: CalculatorUIState, + navigateUpAction: () -> Unit, + addSymbol: (String) -> Unit, + clearSymbols: () -> Unit, + deleteSymbol: () -> Unit, + onCursorChange: (IntRange) -> Unit, + toggleAngleMode: () -> Unit, + evaluate: () -> Unit +) { + UnittoTopAppBar( + title = stringResource(R.string.calculator), + navigateUpAction = navigateUpAction, + ) { + Column(Modifier.padding(it)) { + InputTextField( + modifier = Modifier.fillMaxWidth(), + value = TextFieldValue( + text = uiState.input, + selection = TextRange(uiState.selection.first, uiState.selection.last) + ), + onCursorChange = onCursorChange + ) + AnimatedVisibility(visible = uiState.output.isNotEmpty()) { + Text( + modifier = Modifier.fillMaxWidth(), + // Quick fix to prevent the UI from crashing + text = uiState.output, + textAlign = TextAlign.End, + softWrap = false, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + style = NumbersTextStyleDisplayMedium, + ) + } + CalculatorKeyboard( + modifier = Modifier, + addSymbol = addSymbol, + clearSymbols = clearSymbols, + deleteSymbol = deleteSymbol, + toggleAngleMode = toggleAngleMode, + angleMode = uiState.angleMode, + evaluate = evaluate + ) + } + } +} + +@Preview +@Composable +private fun PreviewCalculatorScreen() { + CalculatorScreen( + uiState = CalculatorUIState(), + navigateUpAction = {}, + addSymbol = {}, + clearSymbols = {}, + deleteSymbol = {}, + onCursorChange = {}, + toggleAngleMode = {}, + evaluate = {} + ) +} \ No newline at end of file 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 new file mode 100644 index 00000000..c572c0c6 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorUIState.kt @@ -0,0 +1,26 @@ +/* + * 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 + +internal data class CalculatorUIState( + val input: String = "", + val output: String = "", + val selection: IntRange = 0..0, + val angleMode: AngleMode = AngleMode.RAD +) 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 new file mode 100644 index 00000000..1f1daf40 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/CalculatorViewModel.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 androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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_DISPLAY +import com.sadellie.unitto.core.base.KEY_RIGHT_BRACKET +import com.sadellie.unitto.data.setMinimumRequiredScale +import com.sadellie.unitto.data.toStringWith +import com.sadellie.unitto.data.trimZeros +import com.sadellie.unitto.data.userprefs.UserPreferences +import com.sadellie.unitto.data.userprefs.UserPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.mariuszgromada.math.mxparser.Expression +import java.math.BigDecimal +import javax.inject.Inject +import org.mariuszgromada.math.mxparser.mXparser as MathParser + +@HiltViewModel +internal class CalculatorViewModel @Inject constructor( + userPrefsRepository: UserPreferencesRepository +) : ViewModel() { + private val _userPrefs: StateFlow = + userPrefsRepository.userPreferencesFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + 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) + + val uiState = combine( + _input, _output, _selection, _angleMode + ) { input, output, selection, angleMode -> + return@combine CalculatorUIState( + input = input, + output = output, + selection = selection, + angleMode = angleMode + ) + }.stateIn( + 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 deleteSymbol() { + 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 } + _input.update { it.removeRange(newSelectionStart, selection.last) } + } + + fun clearSymbols() { + _selection.update { 0..0 } + _input.update { "" } + } + + fun toggleCalculatorMode() { + _angleMode.update { + if (it == AngleMode.DEG) { + MathParser.setRadiansMode() + AngleMode.RAD + } else { + MathParser.setDegreesMode() + AngleMode.DEG + } + } + } + + // Called when user clicks "=" on a keyboard + fun evaluate() { + if (!Expression(_input.value.clean).checkSyntax()) return + + _input.update { _output.value } + _selection.update { _input.value.length.._input.value.length } + _output.update { "" } + } + + fun onCursorChange(selection: IntRange) { + _selection.update { selection } + } + + private fun calculateInput() { + // Input is empty, don't calculate + if (_input.value.isEmpty()) { + _output.update { "" } + return + } + + val calculated = Expression(_input.value.clean).calculate() + + // Calculation error, return NaN + if (calculated.isNaN() or calculated.isInfinite()) { + _output.update { calculated.toString() } + return + } + + val calculatedBigDecimal = calculated + .toBigDecimal() + .setMinimumRequiredScale(_userPrefs.value.digitsPrecision) + .trimZeros() + + try { + val inputBigDecimal = BigDecimal(_input.value) + + // Input and output are identical values + if (inputBigDecimal.compareTo(calculatedBigDecimal) == 0) { + _output.update { "" } + return + } + } catch (e: NumberFormatException) { + // Cannot compare input and output + } + _output.update { + calculatedBigDecimal.toStringWith(_userPrefs.value.outputFormat) + } + return + } + + /** + * Clean input so that there are no syntax errors + */ + private val String.clean: String + get() { + val leftBrackets = count { it.toString() == KEY_LEFT_BRACKET } + val rightBrackets = count { it.toString() == KEY_RIGHT_BRACKET } + val neededBrackets = leftBrackets - rightBrackets + return this + .replace(KEY_MINUS_DISPLAY, KEY_MINUS) + .plus(KEY_RIGHT_BRACKET.repeat(neededBrackets.coerceAtLeast(0))) + } + + init { + /** + * mxParser uses some unnecessary rounding for doubles. It causes expressions like 9999^9999 + * to load CPU very much. We use BigDecimal to achieve same result without CPU overload. + */ + MathParser.setCanonicalRounding(false) + + // Observe and invoke calculation without UI lag. + viewModelScope.launch(Dispatchers.Default) { + merge(_userPrefs, _input, _angleMode).collectLatest { + calculateInput() + } + } + } +} diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt new file mode 100644 index 00000000..69ec035b --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/CalculatorKeyboard.kt @@ -0,0 +1,182 @@ +/* + * 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.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sadellie.unitto.core.base.KEY_0 +import com.sadellie.unitto.core.base.KEY_1 +import com.sadellie.unitto.core.base.KEY_2 +import com.sadellie.unitto.core.base.KEY_3 +import com.sadellie.unitto.core.base.KEY_4 +import com.sadellie.unitto.core.base.KEY_5 +import com.sadellie.unitto.core.base.KEY_6 +import com.sadellie.unitto.core.base.KEY_7 +import com.sadellie.unitto.core.base.KEY_8 +import com.sadellie.unitto.core.base.KEY_9 +import com.sadellie.unitto.core.base.KEY_CLEAR +import com.sadellie.unitto.core.base.KEY_COS +import com.sadellie.unitto.core.base.KEY_DIVIDE_DISPLAY +import com.sadellie.unitto.core.base.KEY_DOT +import com.sadellie.unitto.core.base.KEY_EVALUATE +import com.sadellie.unitto.core.base.KEY_EXPONENT +import com.sadellie.unitto.core.base.KEY_E_SMALL +import com.sadellie.unitto.core.base.KEY_FACTORIAL +import com.sadellie.unitto.core.base.KEY_LEFT_BRACKET +import com.sadellie.unitto.core.base.KEY_LN +import com.sadellie.unitto.core.base.KEY_LOG +import com.sadellie.unitto.core.base.KEY_MINUS_DISPLAY +import com.sadellie.unitto.core.base.KEY_MODULO +import com.sadellie.unitto.core.base.KEY_MULTIPLY_DISPLAY +import com.sadellie.unitto.core.base.KEY_PERCENT +import com.sadellie.unitto.core.base.KEY_PI +import com.sadellie.unitto.core.base.KEY_PLUS +import com.sadellie.unitto.core.base.KEY_RIGHT_BRACKET +import com.sadellie.unitto.core.base.KEY_SIN +import com.sadellie.unitto.core.base.KEY_SQRT +import com.sadellie.unitto.core.base.KEY_TAN +import com.sadellie.unitto.core.ui.common.KeyboardButtonAdditional +import com.sadellie.unitto.core.ui.common.KeyboardButtonFilled +import com.sadellie.unitto.core.ui.common.KeyboardButtonLight +import com.sadellie.unitto.feature.calculator.AngleMode + +@Composable +internal fun CalculatorKeyboard( + modifier: Modifier, + addSymbol: (String) -> Unit, + clearSymbols: () -> Unit, + deleteSymbol: () -> Unit, + toggleAngleMode: () -> Unit, + angleMode: AngleMode, + evaluate: () -> Unit +) { + var showAdditional: Boolean by remember { mutableStateOf(false) } + val expandRotation: Float by animateFloatAsState( + targetValue = if (showAdditional) 0f else 180f, + animationSpec = tween(easing = FastOutSlowInEasing) + ) + + Column( + modifier = modifier + ) { + val weightModifier = Modifier.weight(1f) + val mainButtonModifier = Modifier + .fillMaxSize() + .weight(1f) + .padding(4.dp) + + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + // Additional buttons + Column(modifier = weightModifier) { + Row(Modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + KeyboardButtonAdditional(weightModifier, KEY_SQRT, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_PI, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_EXPONENT, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_FACTORIAL, onClick = addSymbol) + } + AnimatedVisibility(visible = showAdditional) { + Column { + Row(Modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + KeyboardButtonAdditional(weightModifier, angleMode.name, onClick = { toggleAngleMode() }) + KeyboardButtonAdditional(weightModifier, KEY_SIN, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_COS, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_TAN, onClick = addSymbol) + } + Row(Modifier, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + KeyboardButtonAdditional(weightModifier, KEY_MODULO, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_E_SMALL, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_LN, onClick = addSymbol) + KeyboardButtonAdditional(weightModifier, KEY_LOG, onClick = addSymbol) + } + } + } + } + // Expand/Collapse + IconButton({ showAdditional = !showAdditional }) { + Icon(Icons.Default.ExpandLess, null, Modifier.rotate(expandRotation)) + } + } + + Row(weightModifier) { + KeyboardButtonFilled(mainButtonModifier, KEY_LEFT_BRACKET, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_RIGHT_BRACKET, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_PERCENT, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_DIVIDE_DISPLAY, addSymbol) + } + + Row(weightModifier) { + KeyboardButtonLight(mainButtonModifier, KEY_7, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_8, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_9, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_MULTIPLY_DISPLAY, addSymbol) + } + Row(weightModifier) { + KeyboardButtonLight(mainButtonModifier, KEY_4, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_5, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_6, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_MINUS_DISPLAY, addSymbol) + } + Row(weightModifier) { + KeyboardButtonLight(mainButtonModifier, KEY_1, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_2, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_3, addSymbol) + KeyboardButtonFilled(mainButtonModifier, KEY_PLUS, addSymbol) + } + Row(weightModifier) { + KeyboardButtonLight(mainButtonModifier, KEY_0, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_DOT, addSymbol) + KeyboardButtonLight(mainButtonModifier, KEY_CLEAR, { deleteSymbol() }, onLongClick = clearSymbols) + KeyboardButtonFilled(mainButtonModifier, KEY_EVALUATE, { evaluate() }) + } + } +} + +@Preview +@Composable +private fun PreviewCalculatorKeyboard() { + CalculatorKeyboard( + modifier = Modifier, + addSymbol = {}, + clearSymbols = {}, + deleteSymbol = {}, + toggleAngleMode = {}, + angleMode = AngleMode.DEG, + evaluate = {} + ) +} 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 new file mode 100644 index 00000000..b49360f3 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/components/InputTextField.kt @@ -0,0 +1,53 @@ +/* + * 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.components + +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import com.sadellie.unitto.core.ui.theme.NumbersTextStyleDisplayLarge + +@Composable +internal fun InputTextField( + modifier: Modifier, + value: TextFieldValue, + onCursorChange: (IntRange) -> Unit +) { + CompositionLocalProvider( + // FIXME Can't paste if this is null + LocalTextInputService provides null + ) { + BasicTextField( + modifier = modifier, + value = value, + onValueChange = { onCursorChange(it.selection.start..it.selection.end) }, + textStyle = NumbersTextStyleDisplayLarge.copy( + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onBackground + ), + minLines = 1, + maxLines = 1, + ) + } +} diff --git a/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/navigation/CalculatorNavigation.kt b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/navigation/CalculatorNavigation.kt new file mode 100644 index 00000000..763f68c5 --- /dev/null +++ b/feature/calculator/src/main/java/com/sadellie/unitto/feature/calculator/navigation/CalculatorNavigation.kt @@ -0,0 +1,43 @@ +/* + * 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.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink +import com.sadellie.unitto.feature.calculator.CalculatorRoute + +private const val calculatorRoute = "calculator_route" + +fun NavController.navigateToCalculator() { + navigate(calculatorRoute) +} + +fun NavGraphBuilder.calculatorScreen( + navigateUpAction: () -> Unit +) { + composable( + route = calculatorRoute, + deepLinks = listOf( + navDeepLink { uriPattern = "app://com.sadellie.unitto/$calculatorRoute" } + )) { + CalculatorRoute(navigateUpAction = navigateUpAction) + } +} diff --git a/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/ToolsScreen.kt b/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/ToolsScreen.kt index dda4bfc9..d868feed 100644 --- a/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/ToolsScreen.kt +++ b/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/ToolsScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Calculate import androidx.compose.material.icons.filled.Schedule import androidx.compose.material3.Card import androidx.compose.material3.Icon @@ -23,6 +24,7 @@ import com.sadellie.unitto.core.ui.common.UnittoLargeTopAppBar @Composable internal fun ToolsScreen( navigateUpAction: () -> Unit, + navigateToCalculator: () -> Unit, navigateToEpoch: () -> Unit ) { UnittoLargeTopAppBar( @@ -57,6 +59,19 @@ internal fun ToolsScreen( } } } + item { + ListItem( + leadingContent = { + Icon( + Icons.Default.Calculate, + stringResource(R.string.calculator) + ) + }, + headlineText = { Text(stringResource(R.string.calculator)) }, + supportingText = { Text(stringResource(R.string.calculator_support)) }, + modifier = Modifier.clickable { navigateToCalculator() } + ) + } item { ListItem( leadingContent = { @@ -79,6 +94,7 @@ internal fun ToolsScreen( internal fun PreviewToolsScreen() { ToolsScreen( navigateUpAction = {}, - navigateToEpoch = {} + navigateToEpoch = {}, + navigateToCalculator = {} ) } \ No newline at end of file diff --git a/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/navigation/ToolsNavigation.kt b/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/navigation/ToolsNavigation.kt index 30ff5f15..462dcb2a 100644 --- a/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/navigation/ToolsNavigation.kt +++ b/feature/tools/src/main/java/com/sadellie/unitto/feature/tools/navigation/ToolsNavigation.kt @@ -31,11 +31,13 @@ fun NavController.navigateToTools() { fun NavGraphBuilder.toolsScreen( navigateUpAction: () -> Unit, + navigateToCalculator: () -> Unit, navigateToEpoch: () -> Unit ) { composable(toolsRoute) { ToolsScreen( navigateUpAction = navigateUpAction, + navigateToCalculator = navigateToCalculator, navigateToEpoch = navigateToEpoch ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 35977dcb..ff4dc0fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ orgBurnoutcrewComposereorderable = "0.9.6" comGithubSadellieExprk = "e55cba8f41" androidGradlePlugin = "7.4.1" kotlin = "1.8.0" +mxParser = "5.2.1" [libraries] androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } @@ -42,7 +43,7 @@ org-jetbrains-kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidxComposeUi" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxComposeUi" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxComposeUi" } -androidx-compose-ui-test-junit4 = { group= "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxCompose" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxCompose" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidxCompose" } androidx-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycleRuntimeCompose" } @@ -63,6 +64,7 @@ com-github-sadellie-themmo = { group = "com.github.sadellie", name = "themmo", v org-burnoutcrew-composereorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version.ref = "orgBurnoutcrewComposereorderable" } com-github-sadellie-exprk = { group = "com.github.sadellie", name = "ExprK", version.ref = "comGithubSadellieExprk" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } +org-mariuszgromada-math-mxparser = { group = "org.mariuszgromada.math", name = "MathParser.org-mXparser", version.ref = "mxParser" } # classpath android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5b05d959..96749ac9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,7 @@ include(":core:base") include(":core:ui") include(":feature:converter") include(":feature:unitslist") +include(":feature:calculator") include(":feature:settings") include(":feature:tools") include(":feature:epoch")